diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a9340..47bd9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,50 @@ # Changelog — OpenFront Utility (BrayFlex fork) -*Note: some changes may be missing prior to September 2026 due to my lack of documentation.* +*Note: some changes may be undocumented prior to September 2026 because I didn't keep track ¯\_(ツ)_/¯* -## August 15, 2026 — Crisper, pixel-perfect preview +## August 16, 2026 — Simulate In-Game Map & Team Colors +Three big additions to the Preview panel: + +- **Simulate Map (🗺 button)** — opens a popover where you can pick from **9 real in-game maps** (World, Alps, Two Lakes, Arctic, Mare Nostrum, Gulf of St. Lawrence, Australia, Baikal, and South America). Your pattern is layered over the map using the game's own terrain colours and territory-fill style, drawn **only on land**, and kept in sync with the pattern zoom — so it looks just like the in-game map view. +- **Show Team Colors** — a toggle (now sitting below the map list with its own description) that shows what your pattern looks like in **team games**: the primary colour is swapped out for each team's colour in radial pie-style wedges radiating from the centre, so you can eyeball all 7 team colours at once. +- Behind the scenes fix, the preview background turns to the map's dark colour while a map is shown, so your primary colour no longer bleeds through at the edges when you zoom out. +- Added a **Copy image (⎙) button** to the right of it — one click copies the preview **exactly as you see it** (pattern, zoom, map simulation and all) to your clipboard as a PNG image. +- The **🗺 button now glows with the 7 team colours** when *Simulate Teams* is switched on, so you can tell at a glance that team colours are active behind that popover menu button. +- Small fixes to: UI spacing on popovers to create uniformity, size tick display, color display area in floating preview and font imports. + +## August 15, 2026 — Crisper, Pixel-Perfect Preview The preview panel got a big clarity upgrade: - The pattern now **repeats right to the edges** of the panel, at any size or zoom level — resizing the panel just reveals more of the pattern instead of stretching it. - **Zoom controls the pattern size** 50% up to 500% - **Fixed Monitor Interpolation** by adding a formula to calculate how to display pattern **sharp and pixel-perfect** at every level and every screen size — no more mis-sized, blurry or smudged edges (avoids half pixel rounding). -- The preview area now takes up about **~~2/3rds~~ 60% of the panel's height**, giving your pattern more room to shine. +- The preview area now takes up about **60% of the panel's height**, giving your pattern more room to shine. - Fixed an edge case where the pattern could stop just short of the panel's borders at certain zoom levels. ## August 14, 2026 — Favorite Colours - Added a **★ star button** next to the Color Code button so you can **favorite your current color palette** (preset or custom) with one click — tap again to remove it. - Favorited colors appear in a new **Favorites** list above the Color Presets, so you can quickly jump back to the palettes you use most. Custom palettes are listed as **"custom"**, and your favorites are remembered between sessions. -## August 14, 2026 — Guides, Ruler & toolbar polish +## August 14, 2026 — Guides, Ruler & Toolbar Polish - Guide controls are now a tidy **Grid / Center / Ruler** toggle group with a more subtle tab-like style. - The **Grid and Center toggles now stay in sync across both the Main and Test canvases** without resizing either one. - Added an outside **ruler** around the drawable canvas — ticks on every line with numbers every 5 — plus a **Show/Hide Ruler** toggle. Center ticks appear on all four sides when Center is on, independent of the ruler. - Moved the **Invert/Clear** buttons into the toolbar and the **Grid/Center/Ruler** toggles into the secondary toolbar (they swapped places), stacking Invert/Clear to save space. - Tidied the top bar: extra top/bottom padding, smaller export buttons, and consistent 12px font across the main controls. -## August 13, 2026 — Test Canvas & Preview fixes +## August 13, 2026 — Test Canvas & Preview Fixes - The **Test Canvas** is now a fixed 128×128 scratchpad with its own independent pan/zoom, so drawing there never silently changes the size of your main canvas. - Fixed **Undo/Redo on the Test Canvas** (it previously couldn't record your steps because a 128×128 pattern can't be compressed into the standard pattern code). - The preview panel now renders correctly no matter which canvas is active. - Helpful little **toasts** (bottom-corner notifications) now confirm when you copy the JSON, console string, or preview link, and the Test Canvas tab shows a short tip on first use. -## August 12, 2026 — DM Mono font +## August 12, 2026 — DM Mono Font The monospace font everywhere (canvas size numbers, shape buttons, image-import threshold and the preview code view) switched from **IBM Plex Mono** to **DM Mono**, matching the DM Sans look of the rest of the interface. ## August 12, 2026 — Invert shortcut now Ctrl+I - Inverting pixels is now **Ctrl/Cmd+I** (was plain `I`), matching the other clipboard-style shortcuts. - The **Controls** tab was tidied up: panning via Space or Ctrl is now a single line. -## August 11, 2026 — Reworked Info panel into five tabs +## August 11, 2026 — Reworked Info Panel into Five Tabs The **Info** modal was broken down into five focused tabs: - **About** — the beta disclaimer, how patterns work, team-colour tips, and credits. - **Guide** — step-by-step instructions for testing your pattern in-game. @@ -45,42 +55,42 @@ The **Info** modal was broken down into five focused tabs: ## August 10, 2026 — Redesigned Colors Section of the Preview Panel The panel is now more compact in both fixed and floating preview and the text style matches other labels. -## August 10, 2026 — Fresh new app icon +## August 10, 2026 — Fresh New App Icon The little icon shown in your browser tab (the favicon) has been replaced with a brand new design. -## August 10, 2026 — Cleaner font (DM Sans) +## August 10, 2026 — Cleaner Font (DM Sans) The whole interface now uses the **DM Sans** font, giving the tool a cleaner, more modern look. -## August 10, 2026 — Polished tool-size slider +## August 10, 2026 — Polished Tool-Size Slider Refreshed the slider used to change tool size: clearer tick marks and number labels that are easy to read in **both light and dark mode**. -## August 9, 2026 — Artist Name field +## August 9, 2026 — Artist Name Field Text descriptions and label names have been written from scratch, concise and clear to keep everything compact. -## August 9, 2026 — Pattern Scale tabs (1× / 2× / 4×) +## August 9, 2026 — Pattern Scale Tabs (1× / 2× / 4×) Pattern scale is now chosen with three simple tabs instead of a dropdown. The Width/Height labels and zoom controls were also tidied up. -## August 9, 2026 — Tool-size slider fixes +## August 9, 2026 — Tool-Size Slider Fixes Fixed the size slider so it works properly for every tool (the Circle and Shape tools use a 1–30 range), and improved how the slider looks with clear tick marks. -## August 8, 2026 — New shortcuts & easier panning +## August 8, 2026 — New Shortcuts & Easier Panning - **Arrow keys** now pan around the canvas. - Hold **Ctrl** and drag to pan — the cursor turns into a grab hand. - **Backspace / Delete** erases the cells inside your current selection (and won't wipe the whole canvas by accident). - Press **i** to invert pixels in your selection (or the whole canvas if nothing is selected); press **Shift+i** to invert the selection area itself. - Fixed **Cut** so it actually removes the pixels, and **pasting** is now centred on your cursor and stays inside your selection. -## August 8, 2026 — Redesigned tool-size selector +## August 8, 2026 — Redesigned Tool-Size Selector The size picker is now a clean floating panel in the corner of the canvas. It only appears for tools that have a size (like Pencil or Circle). You can type a size directly, and it automatically clamps to the limits of whichever tool you have selected. Pasting no longer deselects your pasted cells. -## August 2, 2026 — Preview panel overhaul +## August 2, 2026 — Preview Panel Overhaul Big update to the preview panel: - **Zoom** the preview from 50% up to 400%. - Colour controls (primary / secondary / swap / copy colour code) are now in a single tidy row. - The preview can be **undocked into a floating window** that you can drag anywhere, resize freely (grab the corner), and it remembers its position between sessions. - Fixed a few dragging and resizing bugs along the way. -## July 30, 2026 — Smarter preview behaviour +## July 30, 2026 — Smarter Preview Behaviour The preview now **collapses automatically when you float it** and **expands when you dock it back**, keeping the workspace tidy. ## July 29–30, 2026 — Grid & Center Guides @@ -88,10 +98,10 @@ The preview now **collapses automatically when you float it** and **expands when - Refined the grid and center-guide colours and lines so they're clearly visible on any background. - Added a "Size" label under the toolbar size button. -## July 29, 2026 — Preview sizing fixes +## July 29, 2026 — Preview Sizing Fixes Fixed the preview canvas sizing so it keeps the correct proportions and fills its panel properly. -## July 22–23, 2026 — UI revamp & expanded Info popup +## July 22–23, 2026 — UI Revamp & Expanded Info Popup - New animated **moon/sun** switch on the dark-mode toggle. - Transform buttons (rotate / shift / flip) arranged into a tidy grid. - Fixed Undo accidentally clearing the canvas when a selection was active. @@ -100,20 +110,20 @@ Fixed the preview canvas sizing so it keeps the correct proportions and fills it - The **Info (ⓘ) popup** is now scrollable and includes more detail (including tips about testing FFA vs Teams colours). - Removed third-party analytics for privacy. -## July 15, 2026 — New blue theme, dark mode & compact layout +## July 15, 2026 — New Blue Theme, Dark Mode & Compact Layout - Restyled the whole app with a clean blue theme. - Added a **Dark Mode toggle** that follows your system setting and remembers your choice. - The tool-size picker is now a popover with clear tick marks. - Colour presets are shown in a tidy 2-column grid, and the toolbar is compact enough to fit on 1080p / iPad screens. -## July 8, 2026 — Better drawing tools & easier submission +## July 8, 2026 — Better Drawing Tools & Easier Submission - **Submit** now collects your name, player ID, and pattern name, then opens a **pre-filled Google Form** in a new tab. - **Paste** is now a tool of its own with a live preview under your cursor — it returns to your previous tool once placed. - Hold **Shift** with any tool to temporarily switch into selection mode. - New **Shape tool** (choose **Star** or **Cube**), and **circles** are now much smoother thanks to a better drawing algorithm. - Fixed the Fill and Shade tools, and Clear / Invert now respect your selection. -## July 1, 2026 — Feature & Function Overhaul (the big rebuild) +## July 1, 2026 — Feature & Function Revamp (THE BIG UI OVERHAUL) This was a complete rebuild of the tool, so almost everything about how you use it changed. Compared to the previous version: **A brand-new toolbar** diff --git a/package.json b/package.json index 372bd60..6e55082 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "tsc && node scripts/copy-static.js", "start": "npx serve docs", - "dev": "npm run build && npm run start" + "dev": "npm run build && npm run start", + "render:maps": "node scripts/render-maps.mjs" }, "devDependencies": { "typescript": "^5.0.0" diff --git a/scripts/render-maps.mjs b/scripts/render-maps.mjs new file mode 100644 index 0000000..2965327 --- /dev/null +++ b/scripts/render-maps.mjs @@ -0,0 +1,296 @@ +/** + * render-maps.mjs — pre-render OpenFront map images for the "simulate map" + * preview, with zero external dependencies (only Node built-ins). + * + * Reads an OpenFrontIO checkout's `resources/maps//manifest.json` and + * `map.bin` (one byte per tile) and writes, per map: + * + * src/maps/.png — opaque RGB map at full manifest resolution + * src/maps/-land.png — grayscale land mask (255 = land, 0 = water) + * + * plus `src/data/maps.json`, a registry consumed by the app's map picker. + * + * The terrain colours and the tile byte layout replicate OpenFront's own + * renderer (src/client/render/gl/utils/ColorUtils.ts and render-settings.json) + * so the preview matches the in-game map as closely as possible: + * + * bit 7 isLand + * bit 6 isShoreline + * bit 5 isOcean (not used for colouring) + * bits 0-4 magnitude (0-31) + * + * Water is drawn in the game's ocean palette (visible in the RGB map); the + * separate land mask lets the preview apply patterns only over land, exactly + * like in-game territory. + * + * Usage: + * node scripts/render-maps.mjs [map-slug ...] + * npm run render:maps -- [map-slug ...] + * + * With no slug list it renders the default set used by the app: + * world alps twolakes arctic marenostrum gulfofstlawrence baikal + * australia southamerica + * (Baikal and South America are rendered from map4x.bin — the game's + * "compact" resolution — because their full map.bin files are very large; + * pass `baikal:map` to override). + * + * To add more maps, just append their slug (or `slug:map4x`/`slug:map`) to + * the list and run the script again. + */ + +import { deflateSync } from "node:zlib"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// ── Configuration ────────────────────────────────────────────────────────── +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outDir = path.join(rootDir, "src", "maps"); +const dataOutPath = path.join(rootDir, "src", "data", "maps.json"); + +// The set of maps exposed in the app. `bin` chooses which terrain file to +// read: "map" (full resolution map.bin) or "map4x" (half-resolution map4x.bin, +// the game's "compact" map size). +const DEFAULT_MAPS = [ + { slug: "world", bin: "map" }, + { slug: "alps", bin: "map" }, + { slug: "twolakes", bin: "map" }, + { slug: "arctic", bin: "map" }, + { slug: "marenostrum", bin: "map" }, + { slug: "gulfofstlawrence", bin: "map" }, + { slug: "baikal", bin: "map4x" }, + { slug: "australia", bin: "map" }, + { slug: "southamerica", bin: "map4x" }, +]; + +// In-game default terrain palette (render-settings.json > terrain). +const COLORS = { + background: [0x3c, 0x3c, 0x3c], // impassable / outside map + ocean: [0x47, 0x85, 0xb5], // #4785b5 + sand: [0xcc, 0xcb, 0x9e], // #CCCB9E + plains: [0xbe, 0xdc, 0x8a], // #BEDC8A + highland: [0xdc, 0xcb, 0x9e], // #DCCB9E + mountain: [0xe6, 0xe6, 0xe6], // #e6e6e6 +}; + +// ── Terrain byte → RGB + land flag (mirrors ColorUtils.encodeTerrainTile) ── +function decodeTile(tb) { + const isLand = (tb & 0x80) !== 0; + const isShoreline = (tb & 0x40) !== 0; + const magnitude = tb & 0x1f; + + if (!isLand) { + return { rgb: waterColor(isShoreline, magnitude), land: false }; + } + if (magnitude === 31) { + // Impassable terrain → map background colour, treated as non-land + return { rgb: COLORS.background, land: false }; + } + + let r, g, b; + if (isShoreline) { + [r, g, b] = COLORS.sand; + } else if (magnitude < 10) { + // Plains + const base = COLORS.plains; + r = base[0]; + g = base[1] - 2 * magnitude; + b = base[2]; + } else if (magnitude < 20) { + // Highland + const base = COLORS.highland; + const m = magnitude - 10; + r = Math.min(255, base[0] + 2 * m); + g = Math.min(255, base[1] + 2 * m); + b = Math.min(255, base[2] + 2 * m); + } else { + // Mountain + const base = COLORS.mountain; + const m = Math.floor(magnitude / 2); + r = Math.min(255, base[0] + m); + g = Math.min(255, base[1] + m); + b = Math.min(255, base[2] + m); + } + return { rgb: [r, g, b], land: true }; +} + +function waterColor(isShoreline, magnitude) { + let r, g, b; + if (isShoreline) { + // Shoreline water: 70% ocean + 30% white + const base = COLORS.ocean; + r = Math.round(0.7 * base[0] + 76.5); + g = Math.round(0.7 * base[1] + 76.5); + b = Math.round(0.7 * base[2] + 76.5); + } else { + // Deep water — darkens with depth + const m = Math.min(magnitude, 10); + const base = COLORS.ocean; + r = Math.max(0, base[0] - m); + g = Math.max(0, base[1] - m); + b = Math.max(0, base[2] - m); + } + return [r, g, b]; +} + +// ── Minimal PNG encoder (8-bit RGB or grayscale, per-row adaptive filter) ── +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, "ascii"); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} + +// bytesPerPixel = 3 (RGB) or 1 (grayscale) +function encodePng(width, height, pixels, bytesPerPixel) { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = bytesPerPixel === 3 ? 2 : 0; // colour type 2 = RGB, 0 = grayscale + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + + const bpp = bytesPerPixel; + const stride = width * bpp; + const raw = Buffer.alloc((stride + 1) * height); + let srcOffset = 0; + let outOffset = 0; + for (let y = 0; y < height; y++) { + // Filter 0 (None) — zlib's deflate compresses these flat-colour maps well + // on its own, and keeping raw bytes avoids filter overhead. + raw[outOffset++] = 0; + pixels.subarray(srcOffset, srcOffset + stride).forEach((v, i) => { + raw[outOffset + i] = v; + }); + outOffset += stride; + srcOffset += stride; + } + const idat = deflateSync(raw, { level: 9 }); + + return Buffer.concat([ + signature, + chunk("IHDR", ihdr), + chunk("IDAT", idat), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +// ── Rendering ─────────────────────────────────────────────────────────────── +async function renderMap(repoPath, slug, binName) { + const mapDir = path.join(repoPath, "resources", "maps", slug); + const manifestPath = path.join(mapDir, "manifest.json"); + const binPath = path.join(mapDir, `${binName}.bin`); + + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const dims = manifest[binName]; + const width = dims.width; + const height = dims.height; + + const mapBuffer = await readFile(binPath); + if (mapBuffer.length !== width * height) { + throw new Error( + `${slug}: ${binName}.bin length ${mapBuffer.length} !== ${width} * ${height}`, + ); + } + + const count = width * height; + const rgb = new Uint8Array(count * 3); + const land = new Uint8Array(count); + for (let i = 0; i < count; i++) { + const { rgb: c, land: isLand } = decodeTile(mapBuffer[i]); + rgb[i * 3] = c[0]; + rgb[i * 3 + 1] = c[1]; + rgb[i * 3 + 2] = c[2]; + land[i] = isLand ? 255 : 0; + } + + await mkdir(outDir, { recursive: true }); + const mapPng = encodePng(width, height, rgb, 3); + const landPng = encodePng(width, height, land, 1); + await writeFile(path.join(outDir, `${slug}.png`), mapPng); + await writeFile(path.join(outDir, `${slug}-land.png`), landPng); + return { slug, width, height, bytes: mapPng.length, landBytes: landPng.length }; +} + +// ── Entry ─────────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +const repoPath = args[0]; +if (!repoPath) { + console.error("Usage: node scripts/render-maps.mjs [map-slug ...]"); + process.exit(1); +} + +let requested; +if (args.length > 1) { + requested = args.slice(1).map((arg) => { + const [slug, bin = "map"] = arg.split(":"); + return { slug, bin }; + }); +} else { + requested = DEFAULT_MAPS; +} + +const results = []; +for (const { slug, bin } of requested) { + try { + const result = await renderMap(repoPath, slug, bin); + results.push(result); + console.log( + `✔ ${result.slug.padEnd(20)} ${String(result.width).padStart(5)}×${String(result.height).padEnd(5)} ${(result.bytes / 1024).toFixed(0).padStart(6)} KB map ${(result.landBytes / 1024).toFixed(0).padStart(5)} KB mask (${bin}.bin)`, + ); + } catch (err) { + console.error(`✖ ${slug}: ${err.message}`); + } +} + +// Display names shown in the app's map picker. +const MAP_NAMES = { + world: "World", + alps: "Alps", + twolakes: "Two Lakes", + arctic: "Arctic", + marenostrum: "Mare Nostrum", + gulfofstlawrence: "Gulf of St. Lawrence", + baikal: "Baikal (compact)", + australia: "Australia", + southamerica: "South America (compact)", +}; + +// Registry consumed by the app's map picker. +await mkdir(path.dirname(dataOutPath), { recursive: true }); +const registry = results.map((r) => ({ + slug: r.slug, + name: MAP_NAMES[r.slug] ?? r.slug, + width: r.width, + height: r.height, + file: `maps/${r.slug}.png`, + landFile: `maps/${r.slug}-land.png`, +})); +await writeFile(dataOutPath, JSON.stringify(registry, null, 2) + "\n"); +console.log(`Registry written to ${dataOutPath}`); \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 0c1f55b..83a3371 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import { createGridManager } from "./app/gridManager.js"; import { setupHistoryShortcuts } from "./app/historyShortcuts.js"; import { initImageImportOverlay } from "./app/imageImportOverlay.js"; import { initInfoModal } from "./app/infoModal.js"; +import { initMapSimulation, TEAM_COLOR_HEXES } from "./app/mapSimulation.js"; import { decodePatternBase64, generatePatternBase64, @@ -123,11 +124,22 @@ document.addEventListener("DOMContentLoaded", () => { const previewZoomInBtn = document.getElementById("previewZoomInBtn") as HTMLButtonElement; const previewZoomOutBtn = document.getElementById("previewZoomOutBtn") as HTMLButtonElement; const previewZoomValue = document.getElementById("previewZoomValue") as HTMLOutputElement; + const copyPreviewImageBtn = document.getElementById("copyPreviewImageBtn") as HTMLButtonElement; const copyColorsBtn = document.getElementById("copyColorsBtn") as HTMLButtonElement; const favoriteColorsBtn = document.getElementById("favoriteColorsBtn") as HTMLButtonElement; const favoritesContainer = document.getElementById("favoritesContainer") as HTMLDivElement; const previewCanvasWrap = document.getElementById("previewCanvasWrap") as HTMLElement; + // Map simulation + const mapSimBtn = document.getElementById("mapSimBtn") as HTMLButtonElement; + const mapPopover = document.getElementById("mapPopover") as HTMLDivElement; + const mapSimToggle = document.getElementById("mapSimToggle") as HTMLInputElement; + const mapList = document.getElementById("mapList") as HTMLDivElement; + const mapPopoverNote = document.getElementById("mapPopoverNote") as HTMLParagraphElement; + const mapTeamToggle = document.getElementById("mapShowTeamColors") as HTMLInputElement; + const mapTeamColorsWrap = document.getElementById("mapTeamColors") as HTMLDivElement; + const mapTeamColorsCanvas = document.getElementById("mapTeamColorsCanvas") as HTMLCanvasElement; + // Submission const submitPatternBtn = document.getElementById("submitPatternBtn") as HTMLButtonElement; @@ -299,6 +311,22 @@ document.addEventListener("DOMContentLoaded", () => { }; // ── Preview renderer ────────────────────────────────────────────────────── + let simMapRef: { + image: HTMLImageElement | null; + mask: HTMLImageElement | null; + width: number; + height: number; + } | null = null; + let teamColorsRef: string[] | null = null; + + // Background shown behind the preview canvas. In simulate-map mode the + // panel is the map's impassable/background colour (#3c3c3c), not the + // pattern's primary colour, so zooming out never bleeds primary into view. + const setPreviewWrapBackground = () => { + if (!previewCanvasWrap) return; + previewCanvasWrap.style.background = simMapRef ? "#3c3c3c" : previewPrimaryColor.value; + }; + const renderPreview = createPreviewRenderer({ canvas: previewCanvas, context: previewCtx, @@ -306,6 +334,8 @@ document.addEventListener("DOMContentLoaded", () => { secondaryColorInput: previewSecondaryColor, canvasWrap: previewCanvasWrap, getZoom: () => previewZoom, + getSimMap: () => simMapRef, + getTeamColors: () => teamColorsRef, }); // ── Preview zoom ────────────────────────────────────────────────────────── @@ -337,6 +367,27 @@ document.addEventListener("DOMContentLoaded", () => { setPreviewZoom(previewZoomIndex - 1) ); + // ── Copy preview image ──────────────────────────────────────────────────── + // Copies the preview canvas exactly as displayed to the clipboard as a PNG. + copyPreviewImageBtn.addEventListener("click", async () => { + if (previewCanvas.width === 0 || previewCanvas.height === 0) return; + try { + const blob = await new Promise((resolve) => + previewCanvas.toBlob(resolve, "image/png") + ); + if (!blob) { + showToast("Could not copy preview image."); + return; + } + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": blob }), + ]); + showToast("Preview image copied to clipboard."); + } catch { + showToast("Could not copy preview image."); + } + }); + // ── Output update ───────────────────────────────────────────────────────── const clonePattern = (pattern: number[][]) => pattern.map((row) => [...row]); @@ -364,7 +415,7 @@ document.addEventListener("DOMContentLoaded", () => { } if (previewCanvasWrap) { - previewCanvasWrap.style.background = previewPrimaryColor.value; + setPreviewWrapBackground(); } }; @@ -390,7 +441,7 @@ document.addEventListener("DOMContentLoaded", () => { if (isScrapActive) { renderPreview(pattern, true); if (previewCanvasWrap) { - previewCanvasWrap.style.background = primary; + setPreviewWrapBackground(); } if (!isApplyingHistory) scrapHistory.record(clonePattern(pattern)); updateHistoryButtons(); @@ -409,7 +460,7 @@ document.addEventListener("DOMContentLoaded", () => { renderPreview(base64, false); if (previewCanvasWrap) { - previewCanvasWrap.style.background = primary; + setPreviewWrapBackground(); } // Update URL hash from MAIN canvas only @@ -863,6 +914,23 @@ document.addEventListener("DOMContentLoaded", () => { previewHandle: document.getElementById("previewResizeHandle")!, }); + // ── Map simulation ───────────────────────────────────────────────────────── + initMapSimulation({ + button: mapSimBtn, + popover: mapPopover, + toggle: mapSimToggle, + list: mapList, + note: mapPopoverNote, + teamToggle: mapTeamToggle, + onChange: (sim) => { + simMapRef = sim.enabled && sim.meta + ? { image: sim.image, mask: sim.mask, width: sim.meta.width, height: sim.meta.height } + : null; + teamColorsRef = sim.teamColors ? TEAM_COLOR_HEXES : null; + renderPreviewOnly(); + }, + }); + // ── Image import overlay ────────────────────────────────────────────────── initImageImportOverlay({ onApply: (pattern, size) => { diff --git a/src/app/mapSimulation.ts b/src/app/mapSimulation.ts new file mode 100644 index 0000000..1fbda14 --- /dev/null +++ b/src/app/mapSimulation.ts @@ -0,0 +1,242 @@ +const STORAGE_KEY = "pu-sim-map"; + +type MapMeta = { + slug: string; + name: string; + width: number; + height: number; + file: string; + landFile: string; +}; + +export type MapSimulationState = { + enabled: boolean; + slug: string; + meta: MapMeta | null; + image: HTMLImageElement | null; + mask: HTMLImageElement | null; + teamColors: boolean; +}; + +export type MapSimulationController = { + getState: () => MapSimulationState; + setEnabled: (enabled: boolean) => void; + setSlug: (slug: string) => void; +}; + +export type MapSimulationOptions = { + button: HTMLButtonElement; + popover: HTMLDivElement; + toggle: HTMLInputElement; + list: HTMLDivElement; + note: HTMLParagraphElement; + teamToggle: HTMLInputElement; + onChange: (state: MapSimulationState) => void; +}; + +const DEFAULT_SLUG = "world"; +const URL_PREFIX = "./"; + +// The 7 team colors (OpenFrontIO\src\client\render\gl\default-theme.json > teamColors, +// excluding Bot/Humans/Nations), in wheel order. +export const TEAM_COLORS = [ + { name: "Red", hex: "#eb3333" }, + { name: "Blue", hex: "#2962ff" }, + { name: "Teal", hex: "#2bd4bd" }, + { name: "Purple", hex: "#9234ea" }, + { name: "Yellow", hex: "#e7b008" }, + { name: "Orange", hex: "#f97415" }, + { name: "Green", hex: "#41be52" }, +]; +export const TEAM_COLOR_HEXES = TEAM_COLORS.map((c) => c.hex); + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load ${src}`)); + img.src = src; + }); +} + +function hexToRgb(hex: string): [number, number, number] { + const n = parseInt(hex.replace("#", ""), 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; +} + +export async function initMapSimulation(options: MapSimulationOptions): Promise { + const { button, popover, toggle, list, note, onChange, teamToggle } = options; + + let registry: MapMeta[] = []; + try { + const res = await fetch(`${URL_PREFIX}data/maps.json`); + if (res.ok) registry = (await res.json()) as MapMeta[]; + } catch { + registry = []; + } + registry.sort((a, b) => (a.name ?? a.slug).localeCompare(b.name ?? b.slug)); + + const saved = (() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) as { enabled?: boolean; slug?: string } : null; + } catch { + return null; + } + })(); + + const metaFor = (slug: string): MapMeta | null => + registry.find((m) => m.slug === slug) ?? null; + + let state: MapSimulationState = { + enabled: saved?.enabled ?? false, + slug: saved?.slug ?? DEFAULT_SLUG, + meta: metaFor(saved?.slug ?? DEFAULT_SLUG), + image: null, + mask: null, + teamColors: false, + }; + + const imageCache = new Map(); + + const persist = () => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ enabled: state.enabled, slug: state.slug }), + ); + } catch { /* ignore */ } + }; + + const emit = () => { + renderList(); + syncButton(); + toggle.checked = state.enabled; + teamToggle.checked = state.teamColors; + onChange(state); + }; + + const setState = (patch: Partial) => { + state = { ...state, ...patch }; + persist(); + emit(); + }; + + const loadSlug = async (slug: string) => { + const meta = metaFor(slug); + if (!meta) { + setState({ meta: null, image: null, mask: null }); + return; + } + const cached = imageCache.get(slug); + if (cached) { + setState({ meta, image: cached.image, mask: cached.mask }); + return; + } + try { + const [image, mask] = await Promise.all([ + loadImage(`${URL_PREFIX}${meta.file}`), + loadImage(`${URL_PREFIX}${meta.landFile}`), + ]); + imageCache.set(slug, { image, mask }); + if (state.slug === slug) { + setState({ meta, image, mask }); + } + } catch { + setState({ meta, image: null, mask: null }); + } + }; + + const buildList = () => { + list.innerHTML = ""; + const items = registry.length > 0 ? registry : []; + for (const meta of items) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "map-option"; + btn.setAttribute("role", "option"); + btn.dataset.slug = meta.slug; + + const name = document.createElement("span"); + name.className = "map-option-name"; + name.textContent = meta.name || meta.slug; + + const size = document.createElement("span"); + size.className = "map-option-size"; + size.textContent = `${meta.width}×${meta.height}`; + + btn.append(name, size); + btn.addEventListener("click", () => { + if (state.enabled && state.slug === meta.slug) { + // Clicking the selected map toggles simulation off. + setState({ enabled: false }); + } else { + setState({ enabled: true, slug: meta.slug }); + void loadSlug(meta.slug); + } + }); + list.appendChild(btn); + } + }; + + const renderList = () => { + for (const el of list.querySelectorAll(".map-option")) { + const isSelected = state.enabled && el.dataset.slug === state.slug; + el.classList.toggle("selected", isSelected); + el.setAttribute("aria-selected", isSelected ? "true" : "false"); + } + }; + + const syncButton = () => { + button.classList.toggle("active", state.enabled); + button.classList.toggle("team-colors", state.teamColors); + button.setAttribute("aria-expanded", popover.hidden ? "false" : "true"); + }; + + const open = () => { + popover.hidden = false; + syncButton(); + }; + const close = () => { + popover.hidden = true; + syncButton(); + }; + + button.addEventListener("click", (e) => { + e.stopPropagation(); + popover.hidden ? open() : close(); + }); + + toggle.addEventListener("change", () => { + setState({ enabled: toggle.checked }); + }); + + teamToggle.addEventListener("change", () => { + setState({ teamColors: teamToggle.checked }); + }); + + // ── Close on outside click ─────────────────────────────────────────────── + document.addEventListener("click", (e) => { + if (popover.hidden) return; + const target = e.target as Node; + if (!popover.contains(target) && target !== button && !button.contains(target)) { + close(); + } + }); + + buildList(); + toggle.checked = state.enabled; + syncButton(); + if (registry.length === 0) note.hidden = false; + else note.hidden = true; + void loadSlug(state.slug); + + return { + getState: () => state, + setEnabled: (enabled) => setState({ enabled }), + setSlug: (slug) => { + setState({ slug }); + void loadSlug(slug); + }, + }; +} \ No newline at end of file diff --git a/src/app/previewRenderer.ts b/src/app/previewRenderer.ts index 818a172..80b77c1 100644 --- a/src/app/previewRenderer.ts +++ b/src/app/previewRenderer.ts @@ -9,6 +9,8 @@ type PreviewRendererOptions = { secondaryColorInput: HTMLInputElement; canvasWrap: HTMLElement; getZoom: () => number; + getSimMap?: () => { image: HTMLImageElement | null; mask: HTMLImageElement | null; width: number; height: number } | null; + getTeamColors?: () => string[] | null; }; type PatternSource = { @@ -18,6 +20,9 @@ type PatternSource = { scale: number; }; +// In-game territory fill opacity (render-settings.json > mapOverlay.territoryAlpha). +const TERRITORY_ALPHA = 0.588; + function patternSourceFromBase64(base64: string): PatternSource { const decoder = new PatternDecoder(base64); const scale = decoder.getScale(); @@ -49,7 +54,22 @@ function hexToRgb(hex: string): RgbColor { } export function createPreviewRenderer(options: PreviewRendererOptions) { - const { canvas, context, primaryColorInput, secondaryColorInput, canvasWrap, getZoom } = options; + const { canvas, context, primaryColorInput, secondaryColorInput, canvasWrap, getZoom, getSimMap, getTeamColors } = options; + + // Returns the team color that should replace the primary for a pixel at + // (x, y) in the canvas of the given width/height. Sections are radial wedges + // from the canvas centre, matching the popover's team-color wheel. + const teamPrimaryFor = (teamColors: string[] | null, x: number, y: number, width: number, height: number): RgbColor | null => { + if (!teamColors || teamColors.length === 0) return null; + const dx = x - width / 2; + const dy = y - height / 2; + // atan2 returns -PI..PI measured from the +x axis; rotate so 0 points up + // and sectors progress clockwise, matching the wheel's wedge layout. + let angle = Math.atan2(dy, dx) + Math.PI / 2; + angle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + const idx = Math.floor((angle / (Math.PI * 2)) * teamColors.length) % teamColors.length; + return hexToRgb(teamColors[idx]); + }; return function renderPreview(pattern: string | number[][], isScrap = false) { const source = @@ -61,6 +81,17 @@ export function createPreviewRenderer(options: PreviewRendererOptions) { const availW = wrapRect.width; const availH = wrapRect.height; + const primaryRgb = hexToRgb(primaryColorInput.value); + const secondaryRgb = hexToRgb(secondaryColorInput.value); + const teamColors = getTeamColors?.() ?? null; + + const simMap = getSimMap?.() ?? null; + + if (simMap && simMap.image && simMap.mask) { + renderMapSimulation(source, simMap, primaryRgb, secondaryRgb, availW, availH, teamColors); + return; + } + // Displayed size of one tile cell in CSS px (zoom × encoded pattern scale). const zoomScale = Math.max(0.5, getZoom()) * (1 << source.scale); @@ -94,9 +125,6 @@ export function createPreviewRenderer(options: PreviewRendererOptions) { canvas.style.width = `${(width * deviceScale) / dpr}px`; canvas.style.height = `${(height * deviceScale) / dpr}px`; - const primaryRgb = hexToRgb(primaryColorInput.value); - const secondaryRgb = hexToRgb(secondaryColorInput.value); - const imageData = context.createImageData(width, height); const data = imageData.data; let i = 0; @@ -109,9 +137,12 @@ export function createPreviewRenderer(options: PreviewRendererOptions) { data[i++] = secondaryRgb.g; data[i++] = secondaryRgb.b; } else { - data[i++] = primaryRgb.r; - data[i++] = primaryRgb.g; - data[i++] = primaryRgb.b; + const primary = teamColors + ? teamPrimaryFor(teamColors, x, y, width, height) ?? primaryRgb + : primaryRgb; + data[i++] = primary.r; + data[i++] = primary.g; + data[i++] = primary.b; } data[i++] = 255; } @@ -133,4 +164,89 @@ export function createPreviewRenderer(options: PreviewRendererOptions) { context.restore(); } }; -} + + /** + * Draw the map filling the preview panel (cover) and overlay the pattern at + * the in-game scale: one pattern cell covers 2^scale map tiles, primary for + * bit-0 cells and secondary for bit-1 cells, both blended at the in-game + * territory alpha over land only (the grayscale land mask picks land). + */ + function renderMapSimulation( + source: PatternSource, + simMap: NonNullable>>, + primaryRgb: RgbColor, + secondaryRgb: RgbColor, + availW: number, + availH: number, + teamColors: string[] | null, + ) { + const { image, mask, width: mapW, height: mapH } = simMap; + const dpr = window.devicePixelRatio || 1; + const zoomScale = Math.max(0.5, getZoom()); + + // Scale the map to cover the panel ("match the current pattern preview + // size display"), preserving aspect ratio. Zoom magnifies the map and the + // pattern together, exactly like zooming the in-game map. + const cover = Math.max(availW / mapW, availH / mapH) * zoomScale; + const dispW = Math.round(mapW * cover * dpr); + const dispH = Math.round(mapH * cover * dpr); + + canvas.width = dispW; + canvas.height = dispH; + canvas.style.width = `${dispW / dpr}px`; + canvas.style.height = `${dispH / dpr}px`; + // In-game impassable / outside-map background so no primary colour shows + // through sub-pixel gaps when zoomed out. + canvas.style.background = "#3c3c3c"; + + context.imageSmoothingEnabled = false; + context.drawImage(image!, 0, 0, dispW, dispH); + const imageData = context.getImageData(0, 0, dispW, dispH); + const data = imageData.data; + + // Read the land mask into a separate buffer (offscreen canvas) so it can't + // clobber the map's RGB; only land (white) tiles get pattern overlay. + let maskData: Uint8ClampedArray | undefined; + if (mask) { + const off = document.createElement("canvas"); + off.width = dispW; + off.height = dispH; + const offCtx = off.getContext("2d", { willReadFrequently: true }); + if (offCtx) { + offCtx.imageSmoothingEnabled = false; + offCtx.drawImage(mask, 0, 0, dispW, dispH); + maskData = offCtx.getImageData(0, 0, dispW, dispH).data; + } + } + + // Map tiles per CSS px — with the same cover scale the pattern uses. + const tilePx = cover; + const scale = source.scale; + + for (let py = 0; py < dispH; py++) { + const yCss = py / dpr; + for (let px = 0; px < dispW; px++) { + const idx = (py * dispW + px) * 4; + // Land mask from the grayscale mask's red channel; water/impassable stay untouched. + if (!maskData || maskData[idx] < 128) continue; + + const xCss = px / dpr; + const tileX = Math.floor(xCss / tilePx); + const tileY = Math.floor(yCss / tilePx); + const cx = tileX >> scale; + const cy = tileY >> scale; + + const primary = teamColors + ? teamPrimaryFor(teamColors, px, py, dispW, dispH) ?? primaryRgb + : primaryRgb; + const c = source.isSetCell(cx, cy) ? secondaryRgb : primary; + data[idx] = Math.round(c.r * TERRITORY_ALPHA + data[idx] * (1 - TERRITORY_ALPHA)); + data[idx + 1] = Math.round(c.g * TERRITORY_ALPHA + data[idx + 1] * (1 - TERRITORY_ALPHA)); + data[idx + 2] = Math.round(c.b * TERRITORY_ALPHA + data[idx + 2] * (1 - TERRITORY_ALPHA)); + data[idx + 3] = 255; + } + } + + context.putImageData(imageData, 0, 0); + } +} \ No newline at end of file diff --git a/src/data/maps.json b/src/data/maps.json new file mode 100644 index 0000000..fbad8f7 --- /dev/null +++ b/src/data/maps.json @@ -0,0 +1,74 @@ +[ + { + "slug": "world", + "name": "World", + "width": 2000, + "height": 1000, + "file": "maps/world.png", + "landFile": "maps/world-land.png" + }, + { + "slug": "alps", + "name": "Alps", + "width": 2000, + "height": 1836, + "file": "maps/alps.png", + "landFile": "maps/alps-land.png" + }, + { + "slug": "twolakes", + "name": "Two Lakes", + "width": 2100, + "height": 1840, + "file": "maps/twolakes.png", + "landFile": "maps/twolakes-land.png" + }, + { + "slug": "arctic", + "name": "Arctic", + "width": 1828, + "height": 1828, + "file": "maps/arctic.png", + "landFile": "maps/arctic-land.png" + }, + { + "slug": "marenostrum", + "name": "Mare Nostrum", + "width": 2848, + "height": 1448, + "file": "maps/marenostrum.png", + "landFile": "maps/marenostrum-land.png" + }, + { + "slug": "gulfofstlawrence", + "name": "Gulf of St. Lawrence", + "width": 1620, + "height": 1348, + "file": "maps/gulfofstlawrence.png", + "landFile": "maps/gulfofstlawrence-land.png" + }, + { + "slug": "baikal", + "name": "Baikal (compact)", + "width": 1250, + "height": 782, + "file": "maps/baikal.png", + "landFile": "maps/baikal-land.png" + }, + { + "slug": "australia", + "name": "Australia", + "width": 2000, + "height": 1500, + "file": "maps/australia.png", + "landFile": "maps/australia-land.png" + }, + { + "slug": "southamerica", + "name": "South America (compact)", + "width": 872, + "height": 1188, + "file": "maps/southamerica.png", + "landFile": "maps/southamerica-land.png" + } +] diff --git a/src/index.css b/src/index.css index 5c782f5..f0ec811 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,4 @@ @import url("https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=DM+Mono:wght@400;500&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&text=%E2%86%90%E2%86%91%E2%86%92%E2%86%93&display=swap"); @import url("./nouislider/nouislider.min.css"); @import url("./styles/base.css"); @import url("./styles/editor-layout.css"); diff --git a/src/index.html b/src/index.html index 2663852..4f42fad 100644 --- a/src/index.html +++ b/src/index.html @@ -331,6 +331,8 @@ 100% + + @@ -340,6 +342,35 @@ + + +
diff --git a/src/maps/alps-land.png b/src/maps/alps-land.png new file mode 100644 index 0000000..a73f6aa Binary files /dev/null and b/src/maps/alps-land.png differ diff --git a/src/maps/alps.png b/src/maps/alps.png new file mode 100644 index 0000000..493fcd7 Binary files /dev/null and b/src/maps/alps.png differ diff --git a/src/maps/arctic-land.png b/src/maps/arctic-land.png new file mode 100644 index 0000000..a299e89 Binary files /dev/null and b/src/maps/arctic-land.png differ diff --git a/src/maps/arctic.png b/src/maps/arctic.png new file mode 100644 index 0000000..02b4696 Binary files /dev/null and b/src/maps/arctic.png differ diff --git a/src/maps/australia-land.png b/src/maps/australia-land.png new file mode 100644 index 0000000..b105a17 Binary files /dev/null and b/src/maps/australia-land.png differ diff --git a/src/maps/australia.png b/src/maps/australia.png new file mode 100644 index 0000000..c41189a Binary files /dev/null and b/src/maps/australia.png differ diff --git a/src/maps/baikal-land.png b/src/maps/baikal-land.png new file mode 100644 index 0000000..e9dc074 Binary files /dev/null and b/src/maps/baikal-land.png differ diff --git a/src/maps/baikal.png b/src/maps/baikal.png new file mode 100644 index 0000000..a3c1d16 Binary files /dev/null and b/src/maps/baikal.png differ diff --git a/src/maps/gulfofstlawrence-land.png b/src/maps/gulfofstlawrence-land.png new file mode 100644 index 0000000..9ad81da Binary files /dev/null and b/src/maps/gulfofstlawrence-land.png differ diff --git a/src/maps/gulfofstlawrence.png b/src/maps/gulfofstlawrence.png new file mode 100644 index 0000000..514fb84 Binary files /dev/null and b/src/maps/gulfofstlawrence.png differ diff --git a/src/maps/marenostrum-land.png b/src/maps/marenostrum-land.png new file mode 100644 index 0000000..49ae134 Binary files /dev/null and b/src/maps/marenostrum-land.png differ diff --git a/src/maps/marenostrum.png b/src/maps/marenostrum.png new file mode 100644 index 0000000..285949b Binary files /dev/null and b/src/maps/marenostrum.png differ diff --git a/src/maps/southamerica-land.png b/src/maps/southamerica-land.png new file mode 100644 index 0000000..bc8394d Binary files /dev/null and b/src/maps/southamerica-land.png differ diff --git a/src/maps/southamerica.png b/src/maps/southamerica.png new file mode 100644 index 0000000..cbef0d4 Binary files /dev/null and b/src/maps/southamerica.png differ diff --git a/src/maps/twolakes-land.png b/src/maps/twolakes-land.png new file mode 100644 index 0000000..33fef5d Binary files /dev/null and b/src/maps/twolakes-land.png differ diff --git a/src/maps/twolakes.png b/src/maps/twolakes.png new file mode 100644 index 0000000..a73e046 Binary files /dev/null and b/src/maps/twolakes.png differ diff --git a/src/maps/world-land.png b/src/maps/world-land.png new file mode 100644 index 0000000..c65d069 Binary files /dev/null and b/src/maps/world-land.png differ diff --git a/src/maps/world.png b/src/maps/world.png new file mode 100644 index 0000000..581f1f3 Binary files /dev/null and b/src/maps/world.png differ diff --git a/src/styles/base.css b/src/styles/base.css index 370aabd..cfd6c44 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -81,7 +81,7 @@ button.selected, .tool-btn.selected { background: var(--accent); border-color: var(--accent-dark); - color: #ffffff; + color: var(--text); } .btn-primary:hover, button.selected:hover, @@ -145,7 +145,7 @@ textarea { /* ── Panel title ────────────────────────────────────────────────────────── */ .panel-title { color: var(--muted); - font-size: 12px; + font-size: 14px; font-weight: 700; letter-spacing: 0.04em; } diff --git a/src/styles/editor-layout.css b/src/styles/editor-layout.css index c59ff72..1965b08 100644 --- a/src/styles/editor-layout.css +++ b/src/styles/editor-layout.css @@ -295,8 +295,8 @@ .size-popover { position: absolute; - top: 12px; - left: 12px; + top: 8px; + left: 8px; background: color-mix(in srgb, var(--surface) 85%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); @@ -325,7 +325,6 @@ .size-label { font-size: 11px; font-weight: 700; - text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); user-select: none; @@ -530,9 +529,9 @@ } .size-slider .noUi-value-large { - font-weight: 700 !important; + font-weight: 600 !important; color: var(--text) !important; - font-size: 11px !important; + font-size: 9px !important; } /* noUiSlider's own RTL-vertical origin transform drives the thumb sweep — @@ -701,6 +700,7 @@ /* ── Preview Panel ──────────────────────────────────────────────────────── */ .preview-panel { + position: relative; display: flex; flex-direction: column; background: var(--surface); @@ -729,10 +729,14 @@ box-shadow: 0 12px 40px rgba(0,0,0,0.15); } -/* When floating, hide the color preset list — only show primary/secondary + swap + copy */ -.preview-panel.floating .color-preset-details { +/* When floating, hide color presets (keep favorites) — fixed height section */ +.preview-panel.floating .color-preset-details:not(#favoritesDetails) { display: none; } +.preview-panel.floating .colors-section { + flex: none; + height: 256px; +} .preview-header { display: flex; @@ -743,7 +747,10 @@ background: var(--surface-muted); } .preview-header .panel-title { - margin-right: auto; + /* zoom controls follow the title on the left */ +} +.preview-header .preview-map-btn { + margin-left: auto; } .preview-panel.floating .preview-header { cursor: move; @@ -753,7 +760,7 @@ .preview-zoom-controls { display: flex; align-items: center; - gap: 4px; + gap: 2px; } .preview-zoom-btn { background: none; @@ -810,6 +817,174 @@ image-rendering: pixelated; } +/* ── Map simulation button + popover ─────────────────────────────────────── */ +.preview-map-btn, .preview-copy-btn { + background: none; + border: 1px solid var(--border); + color: var(--text); + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} +.preview-map-btn:hover, .preview-copy-btn:hover { + border-color: var(--accent); +} +.preview-map-btn.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 18%, transparent); +} + +/* Team-color gradient tint on the map button (matches the popover's wheel). */ +.preview-map-btn.team-colors { + position: relative; +} +.preview-map-btn.team-colors::after { + content: ""; + position: absolute; + inset: 1px; + border-radius: 3px; + background: conic-gradient( + from 0deg, + #eb3333, #2962ff, #2bd4bd, #9234ea, #e7b008, #f97415, #41be52 + ); + mix-blend-mode: color; + pointer-events: none; +} + +.map-popover { + position: absolute; + top: 58px; + right: 8px; + width: 256px; + max-height: 70%; + overflow-y: auto; + z-index: 60; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} +.map-popover[hidden] { + display: none !important; +} + +.map-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} +.map-popover-title { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.06em; +} +.map-sim-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + cursor: pointer; + user-select: none; +} +.map-sim-toggle input { + opacity: 0; + width: 0; + height: 0; +} +.map-sim-switch { + position: absolute; + cursor: pointer; + inset: 0; + background-color: var(--muted); + transition: 0.25s; + border-radius: 20px; +} +.map-sim-switch:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.25s; + border-radius: 50%; +} +.map-sim-toggle input:checked + .map-sim-switch { + background-color: var(--accent); +} +.map-sim-toggle input:checked + .map-sim-switch:before { + transform: translateX(16px); +} + +.map-popover-hint { + font-size: 11px; + line-height: 1.4; + color: var(--muted); + margin: 0; +} + +.map-list { + display: flex; + flex-direction: column; + gap: 2px; +} +.map-list + .map-popover-header { + margin-top: 10px; +} +.map-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid transparent; + background: none; + color: var(--text); + font-size: 12px; + text-align: left; + cursor: pointer; + width: 100%; +} +.map-option:hover { + background: color-mix(in srgb, var(--surface-muted) 70%, transparent); +} +.map-option.selected { + border-color: var(--accent); + background: var(--accent-light); +} +.map-option .map-option-name { + flex: 1; + font-weight: 600; +} +.map-option .map-option-size { + font-size: 10px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.map-popover-note { + font-size: 11px; + color: var(--muted); + margin: 0; +} +.map-popover-note code { + font-size: 10px; +} + /* Colors Section */ .colors-section { flex: 2;