initial commit: classic-only M+ planner with upreza-derived maps

This commit is contained in:
2026-04-25 21:39:15 +02:00
commit aa1cd9ee40
11 changed files with 11732 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Python
.venv/
__pycache__/
*.pyc
*.pyo
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Large data assets — not tracked in git (no LFS yet)
# data/uprez is an 11 GB sparse-clone of upreza interface
data/uprez/
# legacy hires PNG dump (gone, but keep ignored)
data/maps_png_hires/
# 336 MB of WebP map tiles — exclude until LFS is set up
web/assets/maps/
# Build output
output/
+111
View File
@@ -0,0 +1,111 @@
# mplus-routes
Mythic+ route planner targeted at `mplus.exil.es` (Ascension community).
Static web app: pick a dungeon, see the 4K AI-upscaled map with boss
positions, click to drop waypoints/pull markers, draw a route, share via
URL hash.
## Data sources
- **Boss coords:** AtlasLootAscension `MapData.lua` (vanilla / TBC / WotLK
data files), exported by `bisbeard/export_atlasloot_maps.lua`
`data/atlasloot_maps.json`.
- **Dungeon maps:** [keyboardturner/WoWMapUprez_Dungeons][uprez] —
AI-upscaled (SRCNN) ~4K dungeon maps for every WoW expansion. Tiles are
4×3 grids of 1024×1024 BLPs that we stitch into 4096×3072 PNGs.
[uprez]: https://github.com/keyboardturner/WoWMapUprez_Dungeons
## Stack
- **Frontend:** vanilla HTML/CSS/JS, single page, no build step. Pan/zoom
via CSS transform on the canvas stage; all overlays drawn into an SVG
matching the map's image-pixel coord space.
- **Build pipeline:** Python + Pillow (uses Pillow's native BLP decoder).
- **Hosting:** static. ~260 MB of WebPs total — drop behind any nginx /
CDN. No backend.
## Layout
```
mplus-routes/
├── data/
│ ├── uprez/ # sparse-clone of WoWMapUprez_Dungeons
│ ├── atlasloot_maps.json # boss coords by MapName
│ └── aliases.json # AtlasLoot id → uprez basename overrides
├── tools/
│ ├── stitch_uprez.py # BLP tiles → 4096×3072 WebP per floor
│ └── build_data.py # combine into web/assets/dungeons.json
└── web/
├── index.html
├── style.css
├── app.js
└── assets/
├── dungeons.json # combined data feeding the UI
└── maps/ # per-dungeon WebP files served to browsers
```
## Build
```bash
python3 -m venv .venv
.venv/bin/pip install Pillow
# 1. (one-time) sparse-clone the uprez map pack into data/uprez/
git clone --depth=1 --no-checkout https://github.com/keyboardturner/WoWMapUprez_Dungeons.git data/uprez
cd data/uprez && git sparse-checkout init --cone && git sparse-checkout set "Interface/Worldmap" && git checkout && cd ../..
# 2. stitch BLP tiles → WebP (parallel, ~2 minutes on a modern laptop)
.venv/bin/python tools/stitch_uprez.py --no-png
# 3. combine boss coords + map index → dungeons.json
.venv/bin/python tools/build_data.py
```
`stitch_uprez.py --max-width 2048` halves output size if 4K is overkill.
## Run locally
```bash
cd web && python3 -m http.server 8765
# http://localhost:8765/
```
## Hosting
The whole `web/` directory is fully static.
- **Domain:** `mplus.exil.es`
- **TLS:** wildcard via the cluster's existing edge (Caddy / Traefik / certbot).
- **Backend:** none — routes encode into `#<base64>` for sharing.
A short-URL service is a clean follow-up if shareable `/r/<slug>` URLs
become wanted (KV-backed Cloudflare Worker would be enough).
## Refresh data sources
- **AtlasLoot coords:** re-export with `lua5.4 ../bisbeard/export_atlasloot_maps.lua > data/atlasloot_maps.json`
after AtlasLootAscension updates.
- **Maps:** `cd data/uprez && git pull` then re-run `stitch_uprez.py`
+ `build_data.py` after upstream uprez updates.
## Attribution
Map textures derived from [WoWMapUprez_Dungeons][uprez].
World of Warcraft trademarks belong to Blizzard Entertainment.
This site is not affiliated with Blizzard.
## Known gaps
- AtlasLootAscension's WotLK `MapData.lua` has a known upstream bug where
every dungeon's `MapName` is `"DireMaul"` — we ignore `MapName` for
resolution and use the AtlasLoot dungeon-id (with explicit aliases as
needed) instead.
- A handful of dungeons in AtlasLoot don't have a corresponding upreza
texture (e.g. SerpentshrineCavern interior) — those land in the
picker without a map and can still be used for blank-canvas routing.
- No trash-pull data — coords are boss-only. Trash groups are authored
by hand using the Pull tool.
- Multi-floor dungeons currently put all bosses into `unassignedBosses`
rather than mapping each pin to its specific floor. Floor-assignment
is a follow-up tool.
+54
View File
@@ -0,0 +1,54 @@
{
"_comment": "Maps AtlasLoot dungeon-id → list of webp basenames (or base map names). A bare base like 'Ulduar' expands to every stitched floor: Ulduar_overview, Ulduar_floor1, …. Case-insensitive auto-match handles most names — only put entries here when the names truly differ.",
"AuchAuchenaiCrypts": ["AuchenaiCrypts"],
"AuchManaTombs": ["ManaTombs"],
"AuchSethekkHalls": ["SethekkHalls"],
"AuchShadowLabyrinth": ["ShadowLabyrinth"],
"CFRSerpentshrineCavern": ["CoilfangReservoir"],
"CFRTheSlavePens": ["TheSlavePens"],
"CFRTheSteamvault": ["TheSteamvault"],
"CFRTheUnderbog": ["TheUnderbog"],
"CoTBlackMorass": ["CoTTheBlackMorass"],
"CoTHyjal": ["CoTMountHyjal"],
"CoTOldHillsbrad": ["CoTHillsbradFoothills"],
"CoTOldStratholme": ["CoTStratholme"],
"FHHallsOfReflection": ["HallsofReflection"],
"FHPitOfSaron": ["PitofSaron"],
"FHTheForgeOfSouls": ["TheForgeofSouls"],
"HCBloodFurnace": ["TheBloodFurnace"],
"HCHellfireRamparts": ["HellfireRamparts"],
"HCMagtheridonsLair": ["Magtheridonslair"],
"HCTheShatteredHalls": ["TheShatteredHalls"],
"TempestKeepArcatraz": ["TheArcatraz"],
"TempestKeepBotanica": ["TheBotanica"],
"TempestKeepMechanar": ["TheMechanar"],
"TempestKeepTheEye": ["TempestKeep"],
"Naxxramas60": ["Naxxramas"],
"Onyxia60": ["OnyxiasLair"],
"ObsidianSanctum": ["TheObsidianSanctum"],
"RubySanctum": ["TheRubySanctum"],
"TheRuinsofAhnQiraj": ["RuinsofAhnQiraj"],
"TheTempleofAhnQiraj": ["AhnQiraj"],
"TheSunkenTemple": ["TheTempleofAtalhakkar"],
"ragefire": ["Ragefire"],
"_icc": "AtlasLoot has IcecrownCitadelA/B/C as separate; uprez has only IcecrownCitadel",
"IcecrownCitadelA": ["IcecrownCitadel"],
"IcecrownCitadelB": ["IcecrownCitadel"],
"IcecrownCitadelC": ["IcecrownCitadel"],
"_ulduar": "AtlasLoot has UlduarA/B/C/D/E as separate (5 wings); uprez has multi-floor Ulduar — show all floors for any wing",
"UlduarA": ["Ulduar"],
"UlduarB": ["Ulduar"],
"UlduarC": ["Ulduar"],
"UlduarD": ["Ulduar"],
"UlduarE": ["Ulduar"]
}
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Combine AtlasLoot boss coords + stitched hi-res maps into web/assets/dungeons.json.
Pipeline assumption:
data/maps_png_hires/<MapName>[_floor<n>].png ← stitch_uprez.py
web/assets/maps/<MapName>[_floor<n>].webp ← compress_for_web.py
Resolution order for AtlasLoot dungeon-id → map file(s):
1. data/aliases.json explicit override (keyed by AtlasLoot id OR MapName)
2. exact basename match (case-sensitive)
3. case-insensitive basename match
4. nothing → dungeon listed without a map (still pickable)
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
WEB_ASSETS = ROOT / "web" / "assets"
WEB_MAPS = WEB_ASSETS / "maps" # holds the .webp files (and is also the source of truth for dimensions)
ALIASES_PATH = DATA / "aliases.json"
ATLASLOOT_PATH = DATA / "atlasloot_maps.json"
OUT_PATH = WEB_ASSETS / "dungeons.json"
FLOOR_RE = re.compile(r"^(.+?)(?:_floor\d+|_overview)$")
def load_json(p: Path) -> dict:
with p.open() as f:
return json.load(f)
def join_zone_name(zn) -> str | None:
if isinstance(zn, list) and zn:
return str(zn[0])
if isinstance(zn, str):
return zn
return None
def join_location(loc) -> str | None:
if isinstance(loc, list) and loc:
return str(loc[0])
if isinstance(loc, str):
return loc
return None
def collect_bosses(dungeon: dict) -> list[dict]:
out: list[dict] = []
seen = set()
for k, v in dungeon.items():
if not k.isdigit() or not isinstance(v, list):
continue
for entry in v:
if not isinstance(entry, dict):
continue
if entry.get("SubZone"):
continue
cords = entry.get("cords")
name = entry.get("1")
if not (isinstance(cords, list) and len(cords) == 2 and name):
continue
key = (name, cords[0], cords[1])
if key in seen:
continue
seen.add(key)
out.append({
"name": name,
"x": cords[0],
"y": cords[1],
"type": entry.get("pinType", "boss"),
})
return out
def floor_sort_key(stem: str):
"""Sort _overview before any _floor<n>; floor numbers in numeric order."""
if stem.endswith("_overview"):
return (0, 0, stem)
m = re.match(r".+_floor(\d+)$", stem)
if m:
return (1, int(m.group(1)), stem)
return (2, 0, stem)
def index_map_files() -> dict[str, list[str]]:
"""Group web .webp files by base map name → { base: [stem1, stem2, ...] }."""
if not WEB_MAPS.exists():
return {}
grouped: dict[str, list[str]] = {}
for p in WEB_MAPS.glob("*.webp"):
stem = p.stem
m = FLOOR_RE.match(stem)
base = m.group(1) if m else stem
grouped.setdefault(base, []).append(stem)
for base in grouped:
grouped[base].sort(key=floor_sort_key)
return grouped
def expand_bases(bases: list[str], grouped: dict[str, list[str]]) -> list[str]:
"""An alias entry like "Karazhan" expands to all floors; a specific stem
like "Karazhan_floor1" passes through as-is."""
out: list[str] = []
for b in bases:
if b in grouped:
out.extend(grouped[b])
else:
out.append(b)
return out
def resolve_basenames(
dungeon_id: str,
map_name: str | None,
aliases: dict,
grouped: dict[str, list[str]],
) -> list[str]:
"""Return list of webp basenames to use for this dungeon entry.
Note: AtlasLoot's WotLK MapData has a known bug where every dungeon's
MapName is "DireMaul"; we therefore never resolve by MapName, only by
dungeon_id (with explicit aliases for the rest)."""
# Explicit alias override wins
if dungeon_id in aliases:
return expand_bases(aliases[dungeon_id], grouped)
if map_name and map_name in aliases:
return expand_bases(aliases[map_name], grouped)
# Auto-resolve by AtlasLoot id, exact then case-insensitive
if dungeon_id in grouped:
return list(grouped[dungeon_id])
lc_index = {k.lower(): k for k in grouped}
if dungeon_id and dungeon_id.lower() in lc_index:
return list(grouped[lc_index[dungeon_id.lower()]])
return []
def main() -> int:
atlasloot = load_json(ATLASLOOT_PATH)
aliases = load_json(ALIASES_PATH) if ALIASES_PATH.exists() else {}
aliases = {k: v for k, v in aliases.items() if not k.startswith("_")}
# Ascension M+ pool is classic-only.
atlasloot = {k: v for k, v in atlasloot.items() if k == "OriginalWoW"}
grouped = index_map_files()
if not grouped:
print(f"warning: no hi-res PNGs in {PNG_HIRES}", file=sys.stderr)
dungeons: list[dict] = []
used_bases: set[str] = set()
no_map: list[str] = []
for expansion, entries in atlasloot.items():
for did, dungeon in entries.items():
if not isinstance(dungeon, dict):
continue
map_name = dungeon.get("MapName")
zone_name = join_zone_name(dungeon.get("ZoneName")) or did
bosses = collect_bosses(dungeon)
basenames = resolve_basenames(did, map_name, aliases, grouped)
maps_out: list[dict] = []
for basename in basenames:
src = WEB_MAPS / f"{basename}.webp"
if not src.exists():
continue
with Image.open(src) as im:
w, h = im.size
used_bases.add(basename)
maps_out.append({
"image": f"maps/{basename}.webp",
"width": w, "height": h,
"label": basename,
"bosses": bosses if len(basenames) == 1 else [],
})
entry = {
"id": did,
"expansion": expansion,
"name": zone_name,
"acronym": dungeon.get("Acronym"),
"levelRange": dungeon.get("LevelRange"),
"playerLimit": dungeon.get("PlayerLimit"),
"location": join_location(dungeon.get("Location")),
"reputation": dungeon.get("Reputation"),
"maps": maps_out,
}
if not maps_out:
no_map.append(f"{expansion}/{did} (MapName={map_name})")
elif len(maps_out) > 1:
entry["unassignedBosses"] = bosses
dungeons.append(entry)
dungeons.sort(key=lambda d: (d["expansion"], d["name"]))
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with OUT_PATH.open("w") as f:
json.dump({"dungeons": dungeons}, f, indent=2)
mapped = sum(1 for d in dungeons if d["maps"])
print(f"wrote {OUT_PATH}{len(dungeons)} dungeons, {mapped} with maps")
if no_map:
print(f"\n{len(no_map)} AtlasLoot entries had no resolvable map:", file=sys.stderr)
for s in no_map:
print(f" {s}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Render every classic dungeon's webp with AtlasLoot boss coords overlaid as
red rings, side-by-side with the bare texture. Output to /tmp/alignment/ for
human review.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
WEB_MAPS = ROOT / "web" / "assets" / "maps"
DUNGEONS_JSON = ROOT / "web" / "assets" / "dungeons.json"
OUT_DIR = Path("/tmp/alignment")
def main() -> int:
if not DUNGEONS_JSON.exists():
print("dungeons.json missing, run build_data.py first", file=sys.stderr)
return 1
OUT_DIR.mkdir(parents=True, exist_ok=True)
data = json.loads(DUNGEONS_JSON.read_text())
rendered = []
for d in data["dungeons"]:
if d.get("expansion") != "OriginalWoW":
continue
# Use bosses from the first map (single-floor) or unassignedBosses (multi)
bosses = d["maps"][0]["bosses"] if d["maps"] and d["maps"][0]["bosses"] else d.get("unassignedBosses", [])
if not d["maps"]:
continue
# Pick the first map for the alignment check
m = d["maps"][0]
src = WEB_MAPS / Path(m["image"]).name
if not src.exists():
continue
with Image.open(src) as im:
im = im.convert("RGBA")
W, H = im.size
draw = ImageDraw.Draw(im)
for b in bosses:
px = int((b["x"] / 100) * W)
py = int((b["y"] / 100) * H)
r = 80
draw.ellipse([px - r, py - r, px + r, py + r], outline="red", width=14)
draw.ellipse([px - 4, py - 4, px + 4, py + 4], fill="red")
# Scale down for review
scale = 1024 / W
im_small = im.resize((1024, int(H * scale)), Image.LANCZOS)
out = OUT_DIR / f"{d['id']}.png"
im_small.save(out)
rendered.append((d["id"], d["name"], len(bosses), out))
print(f"Rendered {len(rendered)} dungeons → {OUT_DIR}")
for did, name, n, p in rendered:
print(f" {did:30s} {n:>2d} bosses → {p.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
Stitch WoWMapUprez_Dungeons 4×3 BLP tile grids and emit BOTH a hi-res PNG
(lossless intermediate) and a web-ready WebP per dungeon-floor in parallel.
Inputs: data/uprez/Interface/Worldmap/<MapName>/<MapName><floor>_<idx>.blp
Outputs: data/maps_png_hires/<MapName>[_floor<n>].png (lossless, ~8 MB)
web/assets/maps/<MapName>[_floor<n>].webp (q=82, ~600 KB-1.5 MB)
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "data" / "uprez" / "Interface" / "Worldmap"
DST_PNG = ROOT / "data" / "maps_png_hires"
DST_WEBP = ROOT / "web" / "assets" / "maps"
# Two tile-naming conventions:
# <Map><floor>_<idx>.blp — multi-level dungeons (e.g. Naxxramas2_5.blp)
# <Map><idx>.blp — single-level dungeons (e.g. ZulFarrak5.blp)
# In both cases, idx is 1-12 (12 tiles per level in a 4×3 grid).
TILE_IDX_RE = re.compile(r"^(?P<prefix>.+?)(?P<idx>1[0-2]|[1-9])\.blp$", re.IGNORECASE)
FLOOR_SUFFIX_RE = re.compile(r"^(?P<map>.+?)(?P<floor>\d+)_$")
COLS, ROWS = 4, 3
TILES_PER_FLOOR = COLS * ROWS
def parse_tile(filename: str):
"""Return (mapname, floor, idx) or None. floor=0 indicates the
"single-level" form with no explicit floor in the filename."""
m = TILE_IDX_RE.match(filename)
if not m:
return None
prefix = m.group("prefix")
idx = int(m.group("idx"))
fm = FLOOR_SUFFIX_RE.match(prefix)
if fm:
return fm.group("map"), int(fm.group("floor")), idx
return prefix, 0, idx
def stitch_one(args):
out_basename, tile_paths, png_dir, webp_dir, max_width, quality, write_png = args
# Open tile #1 to learn dimensions; assume uniform.
with Image.open(tile_paths[1]) as t1:
tw, th = t1.size
canvas = Image.new("RGBA", (COLS * tw, ROWS * th))
for idx in range(1, TILES_PER_FLOOR + 1):
with Image.open(tile_paths[idx]) as tile:
col = (idx - 1) % COLS
row = (idx - 1) // COLS
canvas.paste(tile.convert("RGBA"), (col * tw, row * th))
if write_png:
canvas.save(png_dir / f"{out_basename}.png", optimize=False)
# WebP needs RGB and may want a downscale
img = canvas.convert("RGB")
if max_width and img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
img.save(webp_dir / f"{out_basename}.webp", "WEBP", quality=quality, method=4)
return out_basename, canvas.size
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--max-width", type=int, default=4096,
help="downscale WebP to this max width (default: keep 4096)")
ap.add_argument("--quality", type=int, default=82)
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 1))
ap.add_argument("--no-png", action="store_true",
help="skip lossless PNG intermediate (save disk + time)")
args = ap.parse_args()
if not SRC.exists():
print(f"missing {SRC} — clone WoWMapUprez_Dungeons first", file=sys.stderr)
return 1
DST_PNG.mkdir(parents=True, exist_ok=True)
DST_WEBP.mkdir(parents=True, exist_ok=True)
# Group: { (mapname, floor): {idx: path} }; floor=0 is "no floor in filename"
groups: dict[tuple[str, int], dict[int, Path]] = defaultdict(dict)
for dungeon_dir in sorted(SRC.iterdir()):
if not dungeon_dir.is_dir():
continue
for blp in dungeon_dir.glob("*.blp"):
parsed = parse_tile(blp.name)
if not parsed:
continue
mapname, floor, idx = parsed
groups[(mapname, floor)][idx] = blp
floors_per_map: dict[str, set[int]] = defaultdict(set)
for (mapname, floor) in groups:
floors_per_map[mapname].add(floor)
jobs = []
skipped = []
for (mapname, floor), tiles in sorted(groups.items()):
if len(tiles) != TILES_PER_FLOOR:
skipped.append(f"{mapname} floor {floor}: {len(tiles)}/{TILES_PER_FLOOR}")
continue
floors = floors_per_map[mapname]
if len(floors) == 1:
suffix = "" # only one variant
elif floor == 0:
suffix = "_overview" # mixed: 0 + others
else:
suffix = f"_floor{floor}"
out_base = f"{mapname}{suffix}"
jobs.append((out_base, tiles, DST_PNG, DST_WEBP, args.max_width, args.quality, not args.no_png))
print(f"stitching {len(jobs)} dungeon-floors with {args.workers} workers...")
done = 0
with ProcessPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(stitch_one, j) for j in jobs]
for fut in as_completed(futures):
try:
base, size = fut.result()
done += 1
if done % 20 == 0 or done == len(jobs):
print(f" {done}/{len(jobs)} (latest: {base} {size[0]}×{size[1]})")
except Exception as e:
print(f" FAIL: {e}", file=sys.stderr)
print(f"done: {done}/{len(jobs)} stitched")
if skipped:
print(f"\nskipped {len(skipped)} (incomplete tile sets):", file=sys.stderr)
for s in skipped[:10]:
print(f" {s}", file=sys.stderr)
if len(skipped) > 10:
print(f" ... and {len(skipped) - 10} more", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+616
View File
@@ -0,0 +1,616 @@
// Ascension M+ Route Planner — vanilla JS, single-file SPA.
const SVG_NS = "http://www.w3.org/2000/svg";
const state = {
dungeons: [],
current: null, // selected dungeon
floorIndex: 0, // index into dungeon.maps
tool: "route", // "route" | "pull"
// routes/pulls keyed by `${dungeonId}::${floorIndex}`
routes: {}, // { key: [{x,y}] }
pulls: {}, // { key: [{x,y,label?}] }
history: [],
drag: null, // { kind, key, idx }
view: { scale: 1, tx: 0, ty: 0 }, // canvas pan/zoom
pan: null, // active pan drag {x0,y0,tx0,ty0}
};
const $ = (id) => document.getElementById(id);
async function init() {
const r = await fetch("assets/dungeons.json", { cache: "no-cache" });
const data = await r.json();
state.dungeons = data.dungeons;
populateExpansionFilter();
renderDungeonList();
hookEvents();
loadFromHash();
if (!state.current && state.dungeons.length) {
selectDungeon(state.dungeons[0].id);
}
}
function populateExpansionFilter() {
const sel = $("expansion-filter");
const exps = [...new Set(state.dungeons.map((d) => d.expansion))].sort();
for (const e of exps) {
const opt = document.createElement("option");
opt.value = e;
opt.textContent = labelExpansion(e);
sel.appendChild(opt);
}
}
function labelExpansion(e) {
return ({
OriginalWoW: "Classic",
BurningCrusade: "Burning Crusade",
WrathoftheLichKing: "Wrath of the Lich King",
Other: "Other / Unmapped",
})[e] || e;
}
function renderDungeonList() {
const ul = $("dungeon-list");
const q = $("search").value.trim().toLowerCase();
const expFilter = $("expansion-filter").value;
ul.innerHTML = "";
const filtered = state.dungeons.filter((d) => {
if (expFilter && d.expansion !== expFilter) return false;
if (!q) return true;
return (
d.name.toLowerCase().includes(q) ||
(d.acronym || "").toLowerCase().includes(q) ||
d.id.toLowerCase().includes(q)
);
});
let lastExp = null;
for (const d of filtered) {
if (d.expansion !== lastExp) {
const h = document.createElement("li");
h.className = "group-header";
h.textContent = labelExpansion(d.expansion);
ul.appendChild(h);
lastExp = d.expansion;
}
const li = document.createElement("li");
li.dataset.id = d.id;
li.dataset.expansion = d.expansion;
if (state.current && d.id === state.current.id && d.expansion === state.current.expansion) {
li.classList.add("active");
}
const name = document.createElement("span");
name.textContent = d.name;
const acr = document.createElement("span");
acr.className = "acronym";
acr.textContent = d.acronym || "";
li.append(name, acr);
li.onclick = () => selectDungeon(d.id, d.expansion);
ul.appendChild(li);
}
}
function selectDungeon(id, expansion) {
const d = state.dungeons.find(
(x) => x.id === id && (!expansion || x.expansion === expansion)
);
if (!d) return;
state.current = d;
state.floorIndex = 0;
renderDungeonList();
renderViewer();
updateHash();
}
function currentKey() {
return state.current ? `${state.current.expansion}::${state.current.id}::${state.floorIndex}` : null;
}
function currentMap() {
return state.current?.maps[state.floorIndex] || null;
}
function renderViewer() {
const d = state.current;
if (!d) return;
$("dungeon-name").textContent = d.name;
const meta = [d.acronym, d.levelRange && `Lv ${d.levelRange}`, d.location, d.playerLimit && `${d.playerLimit} players`]
.filter(Boolean)
.join(" · ");
$("dungeon-meta").textContent = meta;
// Floor tabs
const tabs = $("floor-tabs");
tabs.innerHTML = "";
if (d.maps.length > 1) {
d.maps.forEach((m, i) => {
const b = document.createElement("button");
b.textContent = humanizeLabel(m.label);
if (i === state.floorIndex) b.classList.add("active");
b.onclick = () => { state.floorIndex = i; renderViewer(); updateHash(); };
tabs.appendChild(b);
});
}
// Map image
const m = currentMap();
const img = $("map-img");
if (m) {
img.src = "assets/" + m.image;
img.width = m.width;
img.height = m.height;
const svg = $("overlay");
svg.setAttribute("width", m.width);
svg.setAttribute("height", m.height);
img.onload = () => { fitToScreen(); renderOverlay(); };
if (img.complete) { fitToScreen(); }
} else {
img.removeAttribute("src");
}
renderOverlay();
renderInfoPane();
}
function humanizeLabel(s) {
return s
.replace(/^CFR/, "")
.replace(/^HC/, "")
.replace(/^CoT/, "CoT ")
.replace(/^TempestKeep/, "TK ")
.replace(/^SM/, "SM ")
.replace(/^Auch/, "Auch ")
.replace(/^FH/, "")
.replace(/^Ulduar([A-Z])/, "Ulduar $1")
.replace(/^IcecrownCitadel([A-Z])/, "ICC $1")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.trim();
}
function renderOverlay() {
const svg = $("overlay");
svg.innerHTML = "";
const m = currentMap();
if (!m) return;
// SVG works in image-pixel space, so circles stay circular regardless of
// the map's aspect ratio. Coords on disk are 0-100 percent (AtlasLoot
// convention) and converted at render/click time.
svg.setAttribute("viewBox", `0 0 ${m.width} ${m.height}`);
svg.setAttribute("preserveAspectRatio", "none");
const key = currentKey();
const wps = state.routes[key] || [];
const pulls = state.pulls[key] || [];
const px = (p) => ({ x: (p.x / 100) * m.width, y: (p.y / 100) * m.height });
// route line first (under pins)
if (wps.length > 1) {
const path = document.createElementNS(SVG_NS, "polyline");
path.setAttribute("class", "route-line");
path.setAttribute(
"points",
wps.map((p) => { const q = px(p); return `${q.x},${q.y}`; }).join(" ")
);
svg.appendChild(path);
}
// boss pins
for (const b of m.bosses || []) {
const q = px(b);
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", "boss-pin");
g.setAttribute("transform", `translate(${q.x},${q.y})`);
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 32);
g.appendChild(c);
const t = document.createElementNS(SVG_NS, "title");
t.textContent = b.name;
g.appendChild(t);
svg.appendChild(g);
}
// pull markers
pulls.forEach((p, i) => {
const q = px(p);
svg.appendChild(makePin(q.x, q.y, i + 1, "pull", "pull", i));
});
// user waypoints (on top)
wps.forEach((p, i) => {
const q = px(p);
svg.appendChild(makePin(q.x, q.y, i + 1, "waypoint", "route", i));
});
svg.onclick = onCanvasClick;
}
function makePin(x, y, label, cssClass, kind, idx) {
// x/y here are SVG pixel coords (already converted from percent).
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", cssClass);
g.setAttribute("transform", `translate(${x},${y})`);
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 36);
g.appendChild(c);
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 14);
t.setAttribute("font-size", 36);
t.textContent = label;
g.appendChild(t);
g.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.drag = { kind, idx };
g.classList.add("dragging");
g.setPointerCapture(e.pointerId);
});
g.addEventListener("pointermove", (e) => {
if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return;
const m = currentMap();
const pt = svgPointFromEvent(e); // percent (0-100)
const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()];
arr[idx] = { ...arr[idx], x: pt.x, y: pt.y };
const px = (pt.x / 100) * m.width;
const py = (pt.y / 100) * m.height;
g.setAttribute("transform", `translate(${px},${py})`);
if (kind === "route") {
const polyline = document.querySelector("#overlay .route-line");
if (polyline) {
polyline.setAttribute(
"points",
state.routes[currentKey()]
.map((p) => `${(p.x / 100) * m.width},${(p.y / 100) * m.height}`)
.join(" ")
);
}
}
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
state.drag = null;
g.classList.remove("dragging");
pushHistory();
renderInfoPane();
updateHash();
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
removePoint(kind, idx);
});
return g;
}
function onCanvasClick(e) {
if (state.drag) return;
const pt = svgPointFromEvent(e);
const key = currentKey();
if (state.tool === "route") {
state.routes[key] = state.routes[key] || [];
state.routes[key].push({ x: pt.x, y: pt.y });
} else if (state.tool === "pull") {
state.pulls[key] = state.pulls[key] || [];
state.pulls[key].push({ x: pt.x, y: pt.y });
}
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
function svgPointFromEvent(e) {
// Convert client coords → 0-100% of map (same coord space as AtlasLoot)
const svg = $("overlay");
const rect = svg.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
return { x: clamp(x), y: clamp(y) };
}
function clamp(v) { return Math.max(0, Math.min(100, +v.toFixed(2))); }
/* --- pan / zoom ---------------------------------------------------------- */
function applyView() {
const stage = $("canvas-stage");
stage.style.transform =
`translate(${state.view.tx}px, ${state.view.ty}px) scale(${state.view.scale})`;
}
function fitToScreen() {
const m = currentMap();
const host = $("canvas-host");
if (!m || !host) return;
const pad = 24;
const sx = (host.clientWidth - pad * 2) / m.width;
const sy = (host.clientHeight - pad * 2) / m.height;
const s = Math.min(sx, sy);
// Center on host: stage origin is at host center (50/50 in CSS), so
// shift by -half-image-size * scale to center the image visually.
state.view.scale = s;
state.view.tx = -(m.width * s) / 2;
state.view.ty = -(m.height * s) / 2;
applyView();
}
function zoomBy(factor, anchorX, anchorY) {
const host = $("canvas-host");
const rect = host.getBoundingClientRect();
// Anchor defaults to host center
const ax = anchorX ?? rect.width / 2;
const ay = anchorY ?? rect.height / 2;
const cx = rect.width / 2 + state.view.tx;
const cy = rect.height / 2 + state.view.ty;
const dx = ax - cx;
const dy = ay - cy;
const before = state.view.scale;
const after = Math.max(0.05, Math.min(20, before * factor));
const ratio = after / before;
state.view.scale = after;
state.view.tx -= dx * (ratio - 1);
state.view.ty -= dy * (ratio - 1);
applyView();
}
function hookCanvasPanZoom() {
const host = $("canvas-host");
host.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = host.getBoundingClientRect();
const ax = e.clientX - rect.left;
const ay = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
zoomBy(factor, ax, ay);
}, { passive: false });
host.addEventListener("pointerdown", (e) => {
// Skip if click is on a pin (those handle their own pointerdown).
if (e.target.closest("g.waypoint, g.pull, g.boss-pin")) return;
// Skip on SVG clicks (those are handled by onCanvasClick to drop pins).
// Use middle mouse, shift-drag, or right-drag for pan; plain left-click on
// empty area also pans if it falls outside the SVG.
const onSvg = e.target.closest("#overlay");
const isPanIntent = e.button === 1 || e.shiftKey || e.button === 2 || !onSvg;
if (!isPanIntent) return;
e.preventDefault();
state.pan = {
x0: e.clientX, y0: e.clientY,
tx0: state.view.tx, ty0: state.view.ty,
};
host.classList.add("panning");
host.setPointerCapture(e.pointerId);
});
host.addEventListener("pointermove", (e) => {
if (!state.pan) return;
state.view.tx = state.pan.tx0 + (e.clientX - state.pan.x0);
state.view.ty = state.pan.ty0 + (e.clientY - state.pan.y0);
applyView();
});
host.addEventListener("pointerup", () => {
state.pan = null;
host.classList.remove("panning");
});
host.addEventListener("contextmenu", (e) => {
// suppress the menu when shift-right-clicking to allow pan
if (state.pan) e.preventDefault();
});
// Buttons
$("zoom-in").addEventListener("click", () => zoomBy(1.25));
$("zoom-out").addEventListener("click", () => zoomBy(1 / 1.25));
$("zoom-reset").addEventListener("click", fitToScreen);
window.addEventListener("resize", () => {
// Refit on first resize, but keep user zoom afterwards.
if (!state._userZoomed) fitToScreen();
});
}
function removePoint(kind, idx) {
const key = currentKey();
const arr = kind === "route" ? state.routes[key] : state.pulls[key];
if (!arr) return;
arr.splice(idx, 1);
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
function renderInfoPane() {
const m = currentMap();
const bossUl = $("boss-list");
bossUl.innerHTML = "";
if (m) {
for (const b of m.bosses || []) {
const li = document.createElement("li");
li.innerHTML = `<span class="swatch"></span><span>${escapeHtml(b.name)}</span>`;
bossUl.appendChild(li);
}
if (!m.bosses?.length) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">no boss data</span>`;
bossUl.appendChild(li);
}
}
const wpUl = $("waypoint-list");
wpUl.innerHTML = "";
const wps = state.routes[currentKey()] || [];
wps.forEach((p, i) => {
const li = document.createElement("li");
li.innerHTML = `
<span class="swatch"></span>
<span>#${i + 1} (${p.x.toFixed(1)}, ${p.y.toFixed(1)})</span>
<button title="Remove">×</button>
`;
li.querySelector("button").onclick = () => removePoint("route", i);
wpUl.appendChild(li);
});
if (!wps.length) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">click map to add</span>`;
wpUl.appendChild(li);
}
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
function pushHistory() {
state.history.push(JSON.stringify({ routes: state.routes, pulls: state.pulls }));
if (state.history.length > 200) state.history.shift();
}
function undo() {
state.history.pop();
const last = state.history[state.history.length - 1];
if (last) {
const s = JSON.parse(last);
state.routes = s.routes;
state.pulls = s.pulls;
} else {
state.routes = {};
state.pulls = {};
}
renderOverlay();
renderInfoPane();
updateHash();
}
function clearCurrent() {
const key = currentKey();
delete state.routes[key];
delete state.pulls[key];
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
/* --- URL hash sharing ---------------------------------------------------- */
function updateHash() {
if (!state.current) return;
const payload = {
d: `${state.current.expansion}/${state.current.id}`,
f: state.floorIndex,
r: state.routes,
p: state.pulls,
};
const json = JSON.stringify(payload);
const minified = compressForUrl(json);
const newHash = `#${minified}`;
if (location.hash !== newHash) {
history.replaceState(null, "", location.pathname + location.search + newHash);
}
}
function loadFromHash() {
const h = location.hash.slice(1);
if (!h) return;
try {
const json = decompressFromUrl(h);
const p = JSON.parse(json);
if (p.d) {
const [expansion, id] = p.d.split("/");
const d = state.dungeons.find((x) => x.id === id && x.expansion === expansion);
if (d) {
state.current = d;
state.floorIndex = p.f || 0;
state.routes = p.r || {};
state.pulls = p.p || {};
renderViewer();
}
}
} catch (e) {
console.warn("hash parse failed", e);
}
}
// Tiny base64-of-utf8 — small payloads make for OK-length URLs.
function compressForUrl(s) {
return btoa(unescape(encodeURIComponent(s))).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function decompressFromUrl(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
return decodeURIComponent(escape(atob(s)));
}
function shareUrl() {
updateHash();
navigator.clipboard?.writeText(location.href);
toast("Link copied");
}
function exportJson() {
const d = state.current;
if (!d) return;
const payload = {
dungeon: { id: d.id, expansion: d.expansion, name: d.name },
floors: d.maps.map((m, i) => ({
label: m.label,
route: state.routes[`${d.expansion}::${d.id}::${i}`] || [],
pulls: state.pulls[`${d.expansion}::${d.id}::${i}`] || [],
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${d.id}-route.json`;
a.click();
URL.revokeObjectURL(url);
}
function toast(msg) {
let t = document.querySelector(".toast");
if (!t) {
t = document.createElement("div");
t.className = "toast";
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add("show");
clearTimeout(toast._h);
toast._h = setTimeout(() => t.classList.remove("show"), 1500);
}
/* --- events -------------------------------------------------------------- */
function hookEvents() {
$("search").addEventListener("input", renderDungeonList);
$("expansion-filter").addEventListener("change", renderDungeonList);
$("undo").addEventListener("click", undo);
$("clear").addEventListener("click", clearCurrent);
$("share").addEventListener("click", shareUrl);
$("export").addEventListener("click", exportJson);
$("tool-route").addEventListener("click", () => setTool("route"));
$("tool-pull").addEventListener("click", () => setTool("pull"));
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
e.preventDefault();
undo();
}
});
window.addEventListener("hashchange", loadFromHash);
hookCanvasPanZoom();
}
function setTool(t) {
state.tool = t;
$("tool-route").classList.toggle("active", t === "route");
$("tool-pull").classList.toggle("active", t === "pull");
}
init().catch((e) => {
console.error(e);
document.body.innerHTML = `<pre style="color:#d63b3b;padding:20px">init failed: ${e.message}</pre>`;
});
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ascension M+ Route Planner</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<aside class="sidebar">
<header class="brand">
<h1>M+ Routes</h1>
<p class="sub">Ascension dungeon planner</p>
</header>
<div class="filters">
<input id="search" type="search" placeholder="Search dungeons…" autocomplete="off">
<select id="expansion-filter">
<option value="">All expansions</option>
</select>
</div>
<ul id="dungeon-list" class="dungeon-list"></ul>
</aside>
<main class="viewer">
<header class="viewer-header">
<div class="title-block">
<h2 id="dungeon-name">Pick a dungeon</h2>
<p id="dungeon-meta" class="meta"></p>
</div>
<div id="floor-tabs" class="floor-tabs"></div>
<div class="toolbar">
<button id="tool-route" class="tool active" title="Click map to add waypoints">Route</button>
<button id="tool-pull" class="tool" title="Click map to drop pull markers">Pull</button>
<button id="undo" title="Undo (⌘Z)">Undo</button>
<button id="clear" title="Clear current floor">Clear</button>
<button id="share">Share</button>
<button id="export">Export JSON</button>
</div>
</header>
<section class="canvas-wrap">
<div id="canvas-host" class="canvas-host">
<div id="canvas-stage" class="canvas-stage">
<img id="map-img" alt="">
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<div class="zoom-controls">
<button id="zoom-in" title="Zoom in">+</button>
<button id="zoom-out" title="Zoom out"></button>
<button id="zoom-reset" title="Fit to screen"></button>
</div>
</div>
<aside class="info-pane">
<h3>Bosses</h3>
<ul id="boss-list" class="boss-list"></ul>
<h3>Waypoints</h3>
<ol id="waypoint-list" class="waypoint-list"></ol>
</aside>
</section>
</main>
<script src="app.js" type="module"></script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
/* Ascension M+ Route Planner — dark, gold-accented, no frameworks. */
:root {
--bg: #0e0d10;
--panel: #17151b;
--panel-2: #1f1c25;
--line: #2e2932;
--text: #d8d2c4;
--text-dim: #8d8576;
--accent: #d4a44a; /* WoW gold */
--accent-2: #f0c674;
--boss: #d63b3b; /* boss pin */
--waypoint: #6ea8ff; /* route point */
--pull: #6ad17b; /* pull marker */
--route: #f0c674;
--shadow: 0 1px 0 rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.35);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, "SF Pro Text", "Inter", "Segoe UI", system-ui, sans-serif;
overflow: hidden;
}
body {
display: grid;
grid-template-columns: 280px 1fr;
}
/* --- sidebar -------------------------------------------------------------- */
.sidebar {
background: var(--panel);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
min-height: 0;
}
.brand {
padding: 18px 20px 12px;
border-bottom: 1px solid var(--line);
}
.brand h1 {
margin: 0;
font-size: 18px;
letter-spacing: .04em;
color: var(--accent);
font-weight: 600;
}
.brand .sub {
margin: 2px 0 0;
font-size: 11px;
color: var(--text-dim);
letter-spacing: .08em;
text-transform: uppercase;
}
.filters {
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
border-bottom: 1px solid var(--line);
}
.filters input,
.filters select {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
padding: 6px 10px;
font: inherit;
outline: none;
}
.filters input:focus,
.filters select:focus { border-color: var(--accent); }
.dungeon-list {
list-style: none;
margin: 0;
padding: 6px 0;
overflow-y: auto;
flex: 1 1 auto;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.dungeon-list li {
padding: 7px 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 8px;
border-left: 3px solid transparent;
font-size: 13px;
}
.dungeon-list li:hover { background: var(--panel-2); }
.dungeon-list li.active {
background: var(--panel-2);
border-left-color: var(--accent);
color: var(--accent-2);
}
.dungeon-list li .acronym {
color: var(--text-dim);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.dungeon-list .group-header {
padding: 12px 16px 4px;
color: var(--text-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .12em;
cursor: default;
}
.dungeon-list .group-header:hover { background: transparent; }
/* --- main viewer ---------------------------------------------------------- */
.viewer {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.viewer-header {
padding: 14px 18px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.title-block { flex: 0 1 auto; min-width: 200px; }
.title-block h2 {
margin: 0;
font-size: 18px;
color: var(--accent-2);
font-weight: 600;
}
.title-block .meta {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-dim);
}
.floor-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.floor-tabs button {
background: var(--panel);
color: var(--text-dim);
border: 1px solid var(--line);
border-radius: 4px;
padding: 5px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.floor-tabs button:hover { color: var(--text); }
.floor-tabs button.active {
border-color: var(--accent);
color: var(--accent-2);
background: var(--panel-2);
}
.toolbar {
margin-left: auto;
display: flex;
gap: 6px;
}
.toolbar button {
background: var(--panel);
border: 1px solid var(--line);
color: var(--text);
border-radius: 4px;
padding: 5px 12px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.toolbar button:hover { border-color: var(--accent); color: var(--accent-2); }
.toolbar .tool.active {
background: var(--accent);
color: #1a1208;
border-color: var(--accent);
font-weight: 600;
}
/* --- canvas / overlay ----------------------------------------------------- */
.canvas-wrap {
flex: 1 1 auto;
display: grid;
grid-template-columns: 1fr 240px;
min-height: 0;
}
.canvas-host {
position: relative;
background: #050507;
background-image:
linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px);
background-size: 32px 32px;
overflow: hidden;
user-select: none;
cursor: grab;
}
.canvas-host.panning { cursor: grabbing; }
/* The pan/zoom transform is applied to this wrapper. Image and SVG share it. */
.canvas-stage {
position: absolute;
top: 50%;
left: 50%;
transform-origin: 0 0;
/* set by JS: transform: translate(...) scale(...) */
will-change: transform;
}
.canvas-stage img {
display: block;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.canvas-stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: auto;
cursor: crosshair;
}
.zoom-controls {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 5;
}
.zoom-controls button {
background: var(--panel-2);
border: 1px solid var(--line);
color: var(--text);
border-radius: 4px;
width: 32px;
height: 32px;
font-size: 16px;
cursor: pointer;
font-weight: 600;
}
.zoom-controls button:hover { border-color: var(--accent); color: var(--accent-2); }
/* boss pins (non-interactive markers from AtlasLoot data) */
.boss-pin circle {
fill: var(--boss);
stroke: #200;
stroke-width: 6;
}
/* user-placed waypoints */
.waypoint circle {
fill: var(--waypoint);
stroke: #002;
stroke-width: 6;
cursor: grab;
}
.waypoint.dragging circle { cursor: grabbing; }
.pull circle {
fill: var(--pull);
stroke: #020;
stroke-width: 6;
cursor: grab;
}
/* labels for all pin types */
.boss-pin text,
.waypoint text,
.pull text {
fill: #fff;
font-weight: 700;
font-family: system-ui, sans-serif;
text-anchor: middle;
paint-order: stroke;
stroke: #000;
stroke-width: 6;
pointer-events: none;
}
.route-line {
fill: none;
stroke: var(--route);
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16 12;
opacity: .85;
pointer-events: none;
}
/* --- info pane ----------------------------------------------------------- */
.info-pane {
border-left: 1px solid var(--line);
background: var(--panel);
padding: 14px 16px;
overflow-y: auto;
scrollbar-width: thin;
}
.info-pane h3 {
margin: 14px 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .12em;
color: var(--text-dim);
}
.info-pane h3:first-child { margin-top: 0; }
.boss-list, .waypoint-list {
list-style: none;
margin: 0;
padding: 0;
font-size: 13px;
}
.boss-list li,
.waypoint-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 3px;
}
.boss-list li:hover,
.waypoint-list li:hover { background: var(--panel-2); }
.boss-list .swatch,
.waypoint-list .swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.boss-list .swatch { background: var(--boss); }
.waypoint-list .swatch { background: var(--waypoint); }
.waypoint-list li button {
margin-left: auto;
background: transparent;
border: 0;
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
}
.waypoint-list li button:hover { color: var(--boss); }
/* --- toast ---------------------------------------------------------------- */
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel-2);
color: var(--accent-2);
border: 1px solid var(--accent);
padding: 8px 18px;
border-radius: 4px;
font-size: 13px;
box-shadow: var(--shadow);
pointer-events: none;
opacity: 0;
transition: opacity .2s ease;
z-index: 99;
}
.toast.show { opacity: 1; }