Compare commits
10 commits
724ae08394
...
738605b6c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 738605b6c0 | |||
| c2ae7a7218 | |||
| 247633546a | |||
| 58b3f74292 | |||
| ad511d54e1 | |||
| 15b7fdeead | |||
| 5b1c198757 | |||
| 65ae0158f3 | |||
| e11dc1eed5 | |||
| 48c401909e |
4
.gitignore
vendored
|
|
@ -13,11 +13,9 @@ Thumbs.db
|
|||
.idea/
|
||||
*.swp
|
||||
|
||||
# Large data assets — not tracked in git (no LFS yet)
|
||||
# Large data assets
|
||||
# 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/
|
||||
|
|
|
|||
11
README.md
|
|
@ -14,7 +14,8 @@ courtesy of RaiderIO (verbal permission, see attribution below).
|
|||
comments, gateways), NPC names from companion `en_US.js`.
|
||||
- **Frontend:** vanilla HTML/CSS/JS, single page, no build step. Pan/zoom
|
||||
via CSS transform on the canvas stage; SVG overlay in image-pixel space.
|
||||
- **Hosting:** static. ~470 MB of WebPs total. Drop behind nginx; no backend.
|
||||
- **Hosting:** static. ~120 MB of WebPs total, committed to the repo. Drop
|
||||
behind nginx; no backend.
|
||||
|
||||
## Layout
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ mplus-routes/
|
|||
├── app.js
|
||||
└── assets/
|
||||
├── dungeons.json
|
||||
└── maps/ per-dungeon WebPs (gitignored — too big without LFS)
|
||||
└── maps/ per-dungeon WebPs (~120 MB, committed)
|
||||
```
|
||||
|
||||
## Build
|
||||
|
|
@ -50,7 +51,7 @@ python3 -m venv .venv
|
|||
.venv/bin/pip install Pillow
|
||||
|
||||
.venv/bin/python tools/kg_fetch.py --workers 32 --zoom 4 # fetch tiles + data (~1.3 GB raw)
|
||||
.venv/bin/python tools/kg_stitch.py --workers 4 # → web/assets/maps/*.webp (~470 MB)
|
||||
.venv/bin/python tools/kg_stitch.py --workers 4 # → web/assets/maps/*.webp (~120 MB)
|
||||
.venv/bin/python tools/kg_build_data.py # → web/assets/dungeons.json
|
||||
```
|
||||
|
||||
|
|
@ -71,8 +72,8 @@ TLS automatically.
|
|||
First-time bring-up:
|
||||
1. Add `mplus.exil.es` A/AAAA records in NetBox (→ public HAProxy VIPs)
|
||||
2. Push the Ansible commit, trigger `Ansible_DeployPublicHaproxy`
|
||||
3. Materialize `web/assets/maps/` on the deploy host (run the build pipeline above)
|
||||
4. Run `playbooks/setup_mplus_routes.yml`
|
||||
3. Run `playbooks/setup_mplus_routes.yml` — `web/assets/maps/` ships in the
|
||||
repo, no pre-build step needed.
|
||||
|
||||
## Coverage
|
||||
|
||||
|
|
|
|||
51
data/ascension_overrides.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"_comment": "Ascension-specific corrections to the upstream kg / AtlasLoot data. Each entry overrides where an enemy/extra is rendered. Match by tile_key + name (case-insensitive substring). pos is in kg pixel space (z=4 stitched image, 6144x4096). kg_floor_id picks the floor (omit if the dungeon is single-floor).",
|
||||
|
||||
"_dungeon_replacements_comment": "When the kg map is the wrong layout for Ascension (e.g. retail/Cata layout vs classic), replace the entire dungeon's map with one we author manually. Coords are in the new map's pixel space (0..width, 0..height). Bosses come from AtlasLoot's per-dungeon entries with cords.",
|
||||
"dungeon_replacements": {
|
||||
"scholomance": {
|
||||
"image": "maps/scholomance.webp",
|
||||
"width": 2048,
|
||||
"height": 2048,
|
||||
"label": "Scholomance",
|
||||
"note": "kg + upreza both ship the post-Cata 4-floor redesign; Ascension uses the original classic single-room layout. Map is the Atlas-addon Scholomance.blp (512x512) upscaled to 2048x2048 with Real-ESRGAN x4plus. Boss positions are hand-pinned to the numbered rooms drawn on the Atlas map.",
|
||||
"_room_position_comment": "Room centers read off the upscaled Atlas map (2048x2048). Numbers refer to the Atlas-addon room numbers visible on the map texture. If a boss is rendered in the wrong room, edit the pos here.",
|
||||
"bosses": [
|
||||
{"name": "Blood Steward of Kirtonos", "pos": [1046, 637], "cls": 3, "room": 1},
|
||||
{"name": "Kirtonos the Herald", "pos": [555, 264], "cls": 3, "room": 2},
|
||||
{"name": "Vectus", "pos": [327, 746], "cls": 3, "room": 4},
|
||||
{"name": "Marduk Blackpool", "pos": [391, 928], "cls": 3, "room": 5},
|
||||
{"name": "Rattlegore", "pos": [564, 974], "cls": 3, "room": 6},
|
||||
{"name": "Lorekeeper Polkelt", "pos": [928, 1174], "cls": 3, "room": 10},
|
||||
{"name": "Doctor Theolen Krastinov", "pos": [1328, 1456], "cls": 3, "room": 9},
|
||||
{"name": "Ras Frostwhisper", "pos": [937, 1729], "cls": 3, "room": 8},
|
||||
{"name": "Lord Alexei Barov", "pos": [1483, 1702], "cls": 3, "room": 11},
|
||||
{"name": "Lady Illucia Barov", "pos": [1856, 1456], "cls": 3, "room": 12},
|
||||
{"name": "The Ravenian", "pos": [1502, 1092], "cls": 3, "room": 13},
|
||||
{"name": "Jandice Barov", "pos": [1665, 355], "cls": 3, "room": 3},
|
||||
{"name": "Instructor Malicia", "pos": [391, 1401], "cls": 3, "room": 7},
|
||||
{"name": "Darkmaster Gandling", "pos": [1529, 1456], "cls": 4, "room": 14}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"_map_image_swaps_comment": "Swap the rendered image for one or more floors WITHOUT changing the kg enemy/pack/patrol data. Coords stay in kg's pixel space; the swapped image is force-stretched to those dims by the browser. Use this when a different render of the SAME WoW texture is sharper but has a different aspect ratio.",
|
||||
"map_image_swaps": {},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"tile_key": "stratholme",
|
||||
"name": "Magistrate Barthilas",
|
||||
"kg_floor_id": 235,
|
||||
"pos": [3498, 3300],
|
||||
"note": "Ascension moved Barthilas to the southern courtyard of the Undead Side"
|
||||
},
|
||||
{
|
||||
"tile_key": "blackfathom_deeps",
|
||||
"name": "Lorgus Jett",
|
||||
"kg_floor_id": 192,
|
||||
"pos": [2893, 2827],
|
||||
"note": "kg places Lorgus on floor 1 (entrance pool) but he's actually in Moonshrine Sanctum on floor 2 — confirmed by AtlasLoot subzone tag"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -98,28 +98,26 @@ def parse_js_var(p: Path) -> dict:
|
|||
|
||||
|
||||
def get_kg_bosses_per_floor(tile_key: str):
|
||||
"""Return list of (npc_id, name, x_px, y_px, classification) for
|
||||
boss-like enemies in the dungeon's first floor only — enough to fit
|
||||
a transform; later floors share the same coord scale anyway."""
|
||||
"""Return list of dicts per boss-like enemy across ALL floors.
|
||||
Caller groups by floor_id when fitting per-floor transforms."""
|
||||
sf = parse_js_var(KG_DIR / tile_key / "split_floors.js")
|
||||
lang = parse_js_var(KG_DIR / tile_key / "lang.js")
|
||||
name_by_id = {n["id"]: n["name"] for n in lang.get("dungeonNpcs", [])}
|
||||
cls_by_id = {n["id"]: n.get("classification_id") for n in lang.get("dungeonNpcs", [])}
|
||||
# First floor: pick the lowest floor_id present
|
||||
enemies = sf["dungeon"].get("enemies", [])
|
||||
if not enemies:
|
||||
return []
|
||||
out = []
|
||||
for e in enemies:
|
||||
for e in sf["dungeon"].get("enemies", []):
|
||||
npc_id = e.get("npc_id")
|
||||
name = name_by_id.get(npc_id, "?")
|
||||
cls = cls_by_id.get(npc_id, 0)
|
||||
if cls < 3:
|
||||
continue # only bosses
|
||||
# kg coord transform: pixel_x = lng*16, pixel_y = -lat*16 (z=4)
|
||||
pix_x = e["lng"] * 16
|
||||
pix_y = -e["lat"] * 16
|
||||
out.append((npc_id, name, pix_x, pix_y, cls, e.get("floor_id")))
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"x": e["lng"] * 16,
|
||||
"y": -e["lat"] * 16,
|
||||
"cls": cls,
|
||||
"floor_id": e.get("floor_id"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -192,40 +190,12 @@ def main() -> int:
|
|||
if not al_dungeon:
|
||||
continue
|
||||
al_entries = list(collect_al_entries(al_dungeon))
|
||||
# Match AL entries to kg bosses by normalized name
|
||||
al_by_norm = {norm_name(e[0]): e for e in al_entries if not e[3]} # non-rare bosses for fitting
|
||||
# For each kg wing, fit a transform from anchors that match
|
||||
al_by_norm = {norm_name(e[0]): e for e in al_entries if not e[3]}
|
||||
|
||||
for kg_key in kg_keys:
|
||||
kg_b = kg_bosses.get(kg_key, [])
|
||||
if not kg_b:
|
||||
continue
|
||||
kg_by_norm = {norm_name(b[1]): b for b in kg_b}
|
||||
common_names = set(al_by_norm) & set(kg_by_norm)
|
||||
if len(common_names) < 2:
|
||||
summary.append(f"{al_id} → {kg_key}: only {len(common_names)} anchor(s); skipping")
|
||||
continue
|
||||
al_pts = []
|
||||
kg_pts = []
|
||||
for n in sorted(common_names):
|
||||
al_e = al_by_norm[n]
|
||||
kg_e = kg_by_norm[n]
|
||||
al_pts.append((al_e[1], al_e[2]))
|
||||
kg_pts.append((kg_e[2], kg_e[3]))
|
||||
tr = fit_transform(al_pts, kg_pts)
|
||||
if not tr:
|
||||
summary.append(f"{al_id} → {kg_key}: degenerate fit")
|
||||
continue
|
||||
sx, ox, sy, oy = tr
|
||||
transforms[(al_id, kg_key)] = tr
|
||||
summary.append(
|
||||
f"{al_id} → {kg_key}: {len(common_names)} anchors, "
|
||||
f"scale=({sx:.2f},{sy:.2f}) offset=({ox:.0f},{oy:.0f})"
|
||||
)
|
||||
|
||||
# Apply transform to AL entries that don't already exist as kg bosses
|
||||
kg_existing_norms = {norm_name(b[1]) for b in kg_b}
|
||||
# Build the set of "this dungeon's bosses pinned to a different
|
||||
# wing" so we can exclude them here.
|
||||
other_wings = [k for k in kg_keys if k != kg_key]
|
||||
forced_elsewhere = set()
|
||||
for w in other_wings:
|
||||
|
|
@ -233,24 +203,63 @@ def main() -> int:
|
|||
forced_elsewhere.add(needle.lower())
|
||||
this_wing_pins = {n.lower() for n in WING_FORCE.get(kg_key, [])}
|
||||
|
||||
# Group kg bosses by floor_id and fit one transform per floor
|
||||
# so a boss on floor B doesn't drag the floor-A fit off.
|
||||
by_floor = defaultdict(list)
|
||||
for b in kg_b:
|
||||
by_floor[b["floor_id"]].append(b)
|
||||
|
||||
floor_transforms = {} # floor_id → (sx, ox, sy, oy, anchor_pts)
|
||||
for fid, fb in by_floor.items():
|
||||
kg_by_norm = {norm_name(b["name"]): b for b in fb}
|
||||
common = set(al_by_norm) & set(kg_by_norm)
|
||||
if len(common) < 2:
|
||||
continue
|
||||
al_pts, kg_pts = [], []
|
||||
for n in sorted(common):
|
||||
al_pts.append((al_by_norm[n][1], al_by_norm[n][2]))
|
||||
kg_pts.append((kg_by_norm[n]["x"], kg_by_norm[n]["y"]))
|
||||
tr = fit_transform(al_pts, kg_pts)
|
||||
if not tr:
|
||||
continue
|
||||
floor_transforms[fid] = (*tr, al_pts, kg_pts)
|
||||
transforms[(al_id, kg_key, fid)] = tr
|
||||
summary.append(
|
||||
f"{al_id} → {kg_key} floor={fid}: {len(common)} anchors, "
|
||||
f"scale=({tr[0]:.2f},{tr[2]:.2f}) offset=({tr[1]:.0f},{tr[3]:.0f})"
|
||||
)
|
||||
|
||||
if not floor_transforms:
|
||||
summary.append(f"{al_id} → {kg_key}: no per-floor transform fit")
|
||||
continue
|
||||
|
||||
kg_existing = set()
|
||||
for fb in by_floor.values():
|
||||
for b in fb:
|
||||
kg_existing.add(norm_name(b["name"]))
|
||||
|
||||
for e in al_entries:
|
||||
name = e[0]
|
||||
lname = name.lower()
|
||||
if norm_name(name) in kg_existing_norms:
|
||||
continue # already in kg
|
||||
# If this boss is force-pinned to another wing, skip here.
|
||||
if norm_name(name) in kg_existing:
|
||||
continue
|
||||
if any(p in lname for p in forced_elsewhere) and \
|
||||
not any(p in lname for p in this_wing_pins):
|
||||
continue
|
||||
# Pick the floor whose anchors are nearest the AL coord —
|
||||
# that's the floor this entry most likely belongs to.
|
||||
best_fid = None
|
||||
best_dist = float("inf")
|
||||
for fid, (sx, ox, sy, oy, al_pts, kg_pts) in floor_transforms.items():
|
||||
d = min(((e[1] - a[0]) ** 2 + (e[2] - a[1]) ** 2) ** 0.5 for a in al_pts)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best_fid = fid
|
||||
sx, ox, sy, oy, al_pts, kg_pts = floor_transforms[best_fid]
|
||||
px = e[1] * sx + ox
|
||||
py = e[2] * sy + oy
|
||||
# Reject points outside the kg image bounds (multi-wing
|
||||
# dungeons: a boss in a wing is OOB on a different wing's
|
||||
# image).
|
||||
if not (-200 <= px <= 6344 and -200 <= py <= 4296):
|
||||
continue
|
||||
# Also reject points that are >1.5x the diagonal away from
|
||||
# all anchors — likely a wrong-wing match.
|
||||
anchor_dists = [
|
||||
((px - a[0]) ** 2 + (py - a[1]) ** 2) ** 0.5 for a in kg_pts
|
||||
]
|
||||
|
|
@ -261,12 +270,13 @@ def main() -> int:
|
|||
"x": round(px, 1),
|
||||
"y": round(py, 1),
|
||||
"rare": e[3],
|
||||
"kg_floor_id": best_fid,
|
||||
"source": "atlasloot",
|
||||
})
|
||||
|
||||
OUT.write_text(json.dumps({
|
||||
"_comment": "Supplemental rare bosses + interactives lifted from AtlasLootAscension and transformed into keystone.guru pixel space. Generated by tools/atlasloot_extras.py.",
|
||||
"transforms": {f"{k[0]}→{k[1]}": v for k, v in transforms.items()},
|
||||
"transforms": {f"{k[0]}→{k[1]}@floor{k[2]}": v for k, v in transforms.items()},
|
||||
"extras": dict(extras),
|
||||
}, indent=2))
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ REGISTRY = DATA / "kg_dungeons.json"
|
|||
WEB_ASSETS = ROOT / "web" / "assets"
|
||||
WEB_MAPS = WEB_ASSETS / "maps"
|
||||
EXTRAS_PATH = DATA / "atlasloot_extras.json"
|
||||
OVERRIDES_PATH = DATA / "ascension_overrides.json"
|
||||
OUT_PATH = WEB_ASSETS / "dungeons.json"
|
||||
|
||||
# Map kg's mapIconType names → simple labels we render in the UI.
|
||||
|
|
@ -223,6 +224,155 @@ def main() -> int:
|
|||
if EXTRAS_PATH.exists():
|
||||
extras_db = json.loads(EXTRAS_PATH.read_text()).get("extras", {})
|
||||
|
||||
overrides = []
|
||||
dungeon_replacements = {}
|
||||
map_image_swaps = {}
|
||||
if OVERRIDES_PATH.exists():
|
||||
ov_doc = json.loads(OVERRIDES_PATH.read_text())
|
||||
overrides = ov_doc.get("overrides", [])
|
||||
dungeon_replacements = ov_doc.get("dungeon_replacements", {})
|
||||
map_image_swaps = ov_doc.get("map_image_swaps", {})
|
||||
|
||||
# Load AtlasLoot map data on demand for dungeon replacements.
|
||||
atlasloot_data = None
|
||||
al_path = Path("/tmp/atlasloot_maps.json")
|
||||
if al_path.exists():
|
||||
try:
|
||||
atlasloot_data = json.loads(al_path.read_text())
|
||||
except Exception:
|
||||
atlasloot_data = None
|
||||
|
||||
def replacement_entry(tile_key, repl, registry_entry):
|
||||
"""Build a complete dungeon record from a manual-override map.
|
||||
Three shapes are supported:
|
||||
1. `floors`: list of per-floor maps each with their own bosses.
|
||||
For multi-floor dungeons we want to ship at parity with the
|
||||
rest of the picker (4 webps with floor-tabs).
|
||||
2. `bosses` list at top level: single-floor + explicit pins.
|
||||
3. `atlasloot_id`: single-floor + pull from AtlasLoot. Cords
|
||||
are 0-100 percent of the AL subzone frame — only roughly
|
||||
correct on full-map images."""
|
||||
|
||||
# --- multi-floor case ---
|
||||
if "floors" in repl:
|
||||
maps_out = []
|
||||
for f in repl["floors"]:
|
||||
fW, fH = f["width"], f["height"]
|
||||
enemies = []
|
||||
for b in f.get("bosses", []):
|
||||
enemies.append({
|
||||
"id": None, "npc_id": None,
|
||||
"name": b["name"],
|
||||
"pos": [round(b["pos"][0], 1), round(b["pos"][1], 1)],
|
||||
"classification": b.get("cls", 3),
|
||||
"skippable": False, "required": False,
|
||||
"kill_priority": None, "pack_id": None, "patrol_id": None,
|
||||
"ascension_pinned": True,
|
||||
})
|
||||
maps_out.append({
|
||||
"image": f["image"],
|
||||
"width": fW, "height": fH,
|
||||
"label": f.get("label", tile_key),
|
||||
"kg_floor_id": f.get("kg_floor_id"),
|
||||
"enemies": enemies,
|
||||
"packs": [], "patrols": [], "icons": [],
|
||||
})
|
||||
return {
|
||||
"id": tile_key,
|
||||
"expansion": "OriginalWoW",
|
||||
"name": registry_entry.get("name", tile_key),
|
||||
"acronym": registry_entry.get("acronym"),
|
||||
"tile_key": tile_key,
|
||||
"data_slug": registry_entry.get("data_slug"),
|
||||
"mapping_id": registry_entry.get("mapping_id"),
|
||||
"maps": maps_out,
|
||||
"ascension_replaced": True,
|
||||
"replacement_note": repl.get("note"),
|
||||
}
|
||||
|
||||
W, H = repl["width"], repl["height"]
|
||||
enemies = []
|
||||
|
||||
if "bosses" in repl:
|
||||
for b in repl["bosses"]:
|
||||
enemies.append({
|
||||
"id": None, "npc_id": None,
|
||||
"name": b["name"],
|
||||
"pos": [round(b["pos"][0], 1), round(b["pos"][1], 1)],
|
||||
"classification": b.get("cls", 3),
|
||||
"skippable": False, "required": False,
|
||||
"kill_priority": None, "pack_id": None, "patrol_id": None,
|
||||
"ascension_pinned": True,
|
||||
})
|
||||
elif "atlasloot_id" in repl:
|
||||
al_id = repl["atlasloot_id"]
|
||||
al = (atlasloot_data or {}).get("OriginalWoW", {}).get(al_id, {})
|
||||
seen = set()
|
||||
for k, v in al.items():
|
||||
if not k.isdigit() or not isinstance(v, list):
|
||||
continue
|
||||
for ent in v:
|
||||
if not isinstance(ent, dict):
|
||||
continue
|
||||
if ent.get("SubZone"):
|
||||
continue
|
||||
cords = ent.get("cords")
|
||||
name = ent.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)
|
||||
pin = ent.get("pinType")
|
||||
lname = name.lower()
|
||||
if pin == "dungeonskull":
|
||||
cls = 3
|
||||
elif pin is None and "rare" in lname:
|
||||
cls = 5
|
||||
else:
|
||||
cls = 2
|
||||
enemies.append({
|
||||
"id": None, "npc_id": None,
|
||||
"name": name,
|
||||
"pos": [round(cords[0] / 100 * W, 1), round(cords[1] / 100 * H, 1)],
|
||||
"classification": cls,
|
||||
"skippable": False, "required": False,
|
||||
"kill_priority": None, "pack_id": None, "patrol_id": None,
|
||||
})
|
||||
map_obj = {
|
||||
"image": repl["image"],
|
||||
"width": W, "height": H,
|
||||
"label": repl.get("label", tile_key),
|
||||
"kg_floor_id": None,
|
||||
"enemies": enemies,
|
||||
"packs": [], "patrols": [], "icons": [],
|
||||
}
|
||||
return {
|
||||
"id": tile_key,
|
||||
"expansion": "OriginalWoW",
|
||||
"name": registry_entry.get("name", tile_key),
|
||||
"acronym": registry_entry.get("acronym"),
|
||||
"tile_key": tile_key,
|
||||
"data_slug": registry_entry.get("data_slug"),
|
||||
"mapping_id": registry_entry.get("mapping_id"),
|
||||
"maps": [map_obj],
|
||||
"ascension_replaced": True,
|
||||
"replacement_note": repl.get("note"),
|
||||
}
|
||||
|
||||
def apply_overrides(tile_key, name, pos, floor_id):
|
||||
"""Return (pos, floor_id) possibly replaced by an Ascension override."""
|
||||
for o in overrides:
|
||||
if o["tile_key"] != tile_key:
|
||||
continue
|
||||
if o["name"].lower() not in name.lower():
|
||||
continue
|
||||
new_floor = o.get("kg_floor_id", floor_id)
|
||||
new_pos = o.get("pos", pos)
|
||||
return new_pos, new_floor
|
||||
return pos, floor_id
|
||||
|
||||
# Build a global icon-type index from the static.js if present, else
|
||||
# fall back to known IDs.
|
||||
icon_index: dict[int, str] = {
|
||||
|
|
@ -251,6 +401,11 @@ def main() -> int:
|
|||
except Exception:
|
||||
npc_index = {}
|
||||
|
||||
# Dungeon replacement: skip kg entirely for this one.
|
||||
if d["tile_key"] in dungeon_replacements:
|
||||
dungeons.append(replacement_entry(d["tile_key"], dungeon_replacements[d["tile_key"]], d))
|
||||
continue
|
||||
|
||||
entry = build_one(d, summary, npc_index, icon_index)
|
||||
if entry:
|
||||
extras = extras_db.get(d["tile_key"], [])
|
||||
|
|
@ -261,9 +416,60 @@ def main() -> int:
|
|||
"pos": [e["x"], e["y"]],
|
||||
"rare": bool(e.get("rare")),
|
||||
"source": e.get("source", "atlasloot"),
|
||||
"kg_floor_id": e.get("kg_floor_id"),
|
||||
}
|
||||
for e in extras
|
||||
]
|
||||
|
||||
# Ascension overrides: change pos and/or relocate to a different
|
||||
# floor. We collect relocations first, then apply them in a
|
||||
# second pass so iteration semantics stay clean.
|
||||
relocations = [] # list of (enemy_dict, source_map, target_map)
|
||||
for m in entry["maps"]:
|
||||
for e in list(m["enemies"]):
|
||||
new_pos, new_floor = apply_overrides(d["tile_key"], e["name"], e["pos"], m.get("kg_floor_id"))
|
||||
if new_pos is e["pos"] and new_floor == m.get("kg_floor_id"):
|
||||
continue
|
||||
e["pos"] = list(new_pos)
|
||||
e["ascension_override"] = True
|
||||
if new_floor is not None and new_floor != m.get("kg_floor_id"):
|
||||
target = next((mm for mm in entry["maps"] if mm.get("kg_floor_id") == new_floor), None)
|
||||
if target:
|
||||
relocations.append((e, m, target))
|
||||
for e, src, tgt in relocations:
|
||||
src["enemies"].remove(e)
|
||||
tgt["enemies"].append(e)
|
||||
|
||||
for ex in entry.get("extras", []):
|
||||
new_pos, new_floor = apply_overrides(d["tile_key"], ex["name"], ex["pos"], ex.get("kg_floor_id"))
|
||||
if new_pos is not ex["pos"] or new_floor != ex.get("kg_floor_id"):
|
||||
ex["pos"] = list(new_pos)
|
||||
ex["kg_floor_id"] = new_floor
|
||||
ex["ascension_override"] = True
|
||||
|
||||
# Map image swap: rescale every coord from kg's pixel space
|
||||
# (image dimensions in floor_summary) into the new image space.
|
||||
swap = map_image_swaps.get(d["tile_key"])
|
||||
if swap:
|
||||
new_w, new_h = swap["width"], swap["height"]
|
||||
for m in entry["maps"]:
|
||||
sx = new_w / m["width"]
|
||||
sy = new_h / m["height"]
|
||||
m["width"], m["height"] = new_w, new_h
|
||||
for e in m["enemies"]:
|
||||
e["pos"] = [round(e["pos"][0] * sx, 1), round(e["pos"][1] * sy, 1)]
|
||||
for p in m["packs"]:
|
||||
p["vertices"] = [[round(v[0] * sx, 1), round(v[1] * sy, 1)] for v in p["vertices"]]
|
||||
for pa in m["patrols"]:
|
||||
pa["vertices"] = [[round(v[0] * sx, 1), round(v[1] * sy, 1)] for v in pa["vertices"]]
|
||||
for ic in m["icons"]:
|
||||
ic["pos"] = [round(ic["pos"][0] * sx, 1), round(ic["pos"][1] * sy, 1)]
|
||||
# Extras already use the kg pixel space; rescale too.
|
||||
# Use the first map's pre-swap factor — extras are dungeon-level.
|
||||
for ex in entry.get("extras", []):
|
||||
ex["pos"] = [round(ex["pos"][0] * (new_w / 6144), 1),
|
||||
round(ex["pos"][1] * (new_h / 4096), 1)]
|
||||
|
||||
dungeons.append(entry)
|
||||
|
||||
dungeons.sort(key=lambda d: d["name"])
|
||||
|
|
|
|||
126
web/app.js
|
|
@ -194,13 +194,17 @@ function renderOverlay() {
|
|||
}
|
||||
}
|
||||
|
||||
// AtlasLoot-derived extras: rare bosses + interactives kg doesn't ship.
|
||||
// These are dungeon-level (not per-floor) and only render on floor 0
|
||||
// unless we get richer data; for single-floor dungeons that's all that
|
||||
// matters, and for multi-floor we leave them on the first floor by
|
||||
// default — the user can drag a Note over them on any floor.
|
||||
if (state.floorIndex === 0 && state.current?.extras) {
|
||||
// AtlasLoot-derived extras: render only ones that match the current
|
||||
// floor. Single-floor dungeons have kg_floor_id null on every extra,
|
||||
// so they all render on floor 0.
|
||||
if (state.current?.extras) {
|
||||
const curFloor = m.kg_floor_id;
|
||||
for (const ex of state.current.extras) {
|
||||
// If the extra has no floor assignment, show only on floor 0.
|
||||
// If it does, show on the matching floor only.
|
||||
const fid = ex.kg_floor_id;
|
||||
const matches = fid == null ? state.floorIndex === 0 : fid === curFloor;
|
||||
if (!matches) continue;
|
||||
svg.appendChild(makeExtraPin(ex));
|
||||
}
|
||||
}
|
||||
|
|
@ -254,14 +258,20 @@ function makeNotePin(note, idx) {
|
|||
g.dataset.tooltip = note.text || "(empty)";
|
||||
|
||||
// drag-to-move + right-click delete + double-click to edit text
|
||||
// Click on a pin must NOT bubble to the SVG (which would add a waypoint
|
||||
// / pull / etc. at the same spot, depending on the active tool).
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("pointerdown", (e) => {
|
||||
e.stopPropagation();
|
||||
state.drag = { kind: "note", idx };
|
||||
state.drag = { kind: "note", idx, moved: false, downX: e.clientX, downY: e.clientY };
|
||||
g.classList.add("dragging");
|
||||
g.setPointerCapture(e.pointerId);
|
||||
});
|
||||
g.addEventListener("pointermove", (e) => {
|
||||
if (!state.drag || state.drag.kind !== "note" || state.drag.idx !== idx) return;
|
||||
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
|
||||
state.drag.moved = true;
|
||||
}
|
||||
const pt = svgPointFromEvent(e);
|
||||
const arr = state.notes[currentKey()];
|
||||
arr[idx].x = pt.x; arr[idx].y = pt.y;
|
||||
|
|
@ -269,29 +279,40 @@ function makeNotePin(note, idx) {
|
|||
});
|
||||
g.addEventListener("pointerup", () => {
|
||||
if (!state.drag) return;
|
||||
const moved = state.drag.moved;
|
||||
state.drag = null;
|
||||
g.classList.remove("dragging");
|
||||
pushHistory();
|
||||
updateHash();
|
||||
});
|
||||
g.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
removeNote(idx);
|
||||
});
|
||||
g.addEventListener("dblclick", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const txt = prompt("Edit note text", note.text || "");
|
||||
if (txt !== null) {
|
||||
state.notes[currentKey()][idx].text = txt;
|
||||
if (moved) {
|
||||
pushHistory();
|
||||
renderOverlay();
|
||||
updateHash();
|
||||
}
|
||||
});
|
||||
g.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeNote(idx);
|
||||
});
|
||||
// Double-click → edit. Works regardless of active tool because the
|
||||
// pin captures click + dblclick before the canvas click handler runs.
|
||||
g.addEventListener("dblclick", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
editNote(idx);
|
||||
});
|
||||
return g;
|
||||
}
|
||||
|
||||
function editNote(idx) {
|
||||
const cur = state.notes[currentKey()]?.[idx];
|
||||
if (!cur) return;
|
||||
const txt = prompt("Edit note text:", cur.text || "");
|
||||
if (txt === null) return;
|
||||
cur.text = txt;
|
||||
pushHistory();
|
||||
renderOverlay();
|
||||
updateHash();
|
||||
}
|
||||
|
||||
function removeNote(idx) {
|
||||
const key = currentKey();
|
||||
const arr = state.notes[key];
|
||||
|
|
@ -338,15 +359,21 @@ function makeTextLabel(item, idx) {
|
|||
hit.setAttribute("fill", "transparent");
|
||||
g.appendChild(hit);
|
||||
|
||||
// Drag / right-click delete / dbl-click edit
|
||||
// Stop click propagating so the canvas's tool handler doesn't fire when
|
||||
// the user is interacting with this label (otherwise editing in Route
|
||||
// mode would also drop a waypoint underneath).
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("pointerdown", (e) => {
|
||||
e.stopPropagation();
|
||||
state.drag = { kind: "text", idx };
|
||||
state.drag = { kind: "text", idx, moved: false, downX: e.clientX, downY: e.clientY };
|
||||
g.classList.add("dragging");
|
||||
g.setPointerCapture(e.pointerId);
|
||||
});
|
||||
g.addEventListener("pointermove", (e) => {
|
||||
if (!state.drag || state.drag.kind !== "text" || state.drag.idx !== idx) return;
|
||||
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
|
||||
state.drag.moved = true;
|
||||
}
|
||||
const pt = svgPointFromEvent(e);
|
||||
const arr = state.texts[currentKey()];
|
||||
arr[idx].x = pt.x; arr[idx].y = pt.y;
|
||||
|
|
@ -354,25 +381,23 @@ function makeTextLabel(item, idx) {
|
|||
});
|
||||
g.addEventListener("pointerup", () => {
|
||||
if (!state.drag) return;
|
||||
const moved = state.drag.moved;
|
||||
state.drag = null;
|
||||
g.classList.remove("dragging");
|
||||
pushHistory();
|
||||
updateHash();
|
||||
if (moved) {
|
||||
pushHistory();
|
||||
updateHash();
|
||||
}
|
||||
});
|
||||
g.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeText(idx);
|
||||
});
|
||||
g.addEventListener("dblclick", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const txt = prompt("Edit label", item.text || "");
|
||||
if (txt !== null) {
|
||||
state.texts[currentKey()][idx].text = txt;
|
||||
pushHistory();
|
||||
renderOverlay();
|
||||
updateHash();
|
||||
}
|
||||
editText(idx);
|
||||
});
|
||||
return g;
|
||||
}
|
||||
|
|
@ -387,6 +412,17 @@ function removeText(idx) {
|
|||
updateHash();
|
||||
}
|
||||
|
||||
function editText(idx) {
|
||||
const cur = state.texts[currentKey()]?.[idx];
|
||||
if (!cur) return;
|
||||
const txt = prompt("Edit label:", cur.text || "");
|
||||
if (txt === null) return;
|
||||
cur.text = txt;
|
||||
pushHistory();
|
||||
renderOverlay();
|
||||
updateHash();
|
||||
}
|
||||
|
||||
// kg classification IDs:
|
||||
// 1 = minor (NPCs, ambient mobs)
|
||||
// 2 = standard trash
|
||||
|
|
@ -555,14 +591,18 @@ function makeUserPin(x, y, label, cssClass, kind, idx) {
|
|||
t.textContent = label;
|
||||
g.appendChild(t);
|
||||
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("pointerdown", (e) => {
|
||||
e.stopPropagation();
|
||||
state.drag = { kind, idx };
|
||||
state.drag = { kind, idx, moved: false, downX: e.clientX, downY: e.clientY };
|
||||
g.classList.add("dragging");
|
||||
g.setPointerCapture(e.pointerId);
|
||||
});
|
||||
g.addEventListener("pointermove", (e) => {
|
||||
if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return;
|
||||
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
|
||||
state.drag.moved = true;
|
||||
}
|
||||
const pt = svgPointFromEvent(e);
|
||||
const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()];
|
||||
arr[idx] = { x: pt.x, y: pt.y };
|
||||
|
|
@ -579,14 +619,18 @@ function makeUserPin(x, y, label, cssClass, kind, idx) {
|
|||
});
|
||||
g.addEventListener("pointerup", () => {
|
||||
if (!state.drag) return;
|
||||
const moved = state.drag.moved;
|
||||
state.drag = null;
|
||||
g.classList.remove("dragging");
|
||||
pushHistory();
|
||||
renderInfoPane();
|
||||
updateHash();
|
||||
if (moved) {
|
||||
pushHistory();
|
||||
renderInfoPane();
|
||||
updateHash();
|
||||
}
|
||||
});
|
||||
g.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removePoint(kind, idx);
|
||||
});
|
||||
return g;
|
||||
|
|
@ -1135,6 +1179,16 @@ function hookEvents() {
|
|||
$("export").addEventListener("click", exportJson);
|
||||
const importBtn = $("import");
|
||||
if (importBtn) importBtn.addEventListener("click", importJson);
|
||||
// Layer toggles — repaint overlay on change. Persisted in state.show.
|
||||
for (const layer of ["enemies", "packs", "patrols", "icons"]) {
|
||||
const cb = $(`layer-${layer}`);
|
||||
if (!cb) continue;
|
||||
cb.checked = state.show[layer];
|
||||
cb.addEventListener("change", () => {
|
||||
state.show[layer] = cb.checked;
|
||||
renderOverlay();
|
||||
});
|
||||
}
|
||||
$("tool-route").addEventListener("click", () => setTool("route"));
|
||||
$("tool-pull").addEventListener("click", () => setTool("pull"));
|
||||
const noteBtn = $("tool-note");
|
||||
|
|
|
|||
BIN
web/assets/maps/blackfathom_deeps_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/blackfathom_deeps_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/blackfathom_deeps_floor3.webp
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
web/assets/maps/blackrock_depths_floor1.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/blackrock_depths_floor2.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/blackwinglair_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/blackwinglair_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/blackwinglair_floor3.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/blackwinglair_floor4.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
web/assets/maps/deadmines_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/deadmines_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/dire_maul_east_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/dire_maul_east_floor2.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/dire_maul_north.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/dire_maul_west_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/dire_maul_west_floor2.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/dire_maul_west_floor3.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/gnomeregan_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/gnomeregan_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/gnomeregan_floor3.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/gnomeregan_floor4.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/lower_blackrock_spire_floor1.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/lower_blackrock_spire_floor2.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/lower_blackrock_spire_floor3.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/lower_blackrock_spire_floor4.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/lower_blackrock_spire_floor5.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/lower_blackrock_spire_floor6.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/lower_blackrock_spire_floor7.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/mageclassshrine_floor1.webp
Normal file
|
After Width: | Height: | Size: 614 KiB |
BIN
web/assets/maps/mageclassshrine_floor2.webp
Normal file
|
After Width: | Height: | Size: 590 KiB |
BIN
web/assets/maps/maraudon_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/maraudon_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/moltencore.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/naxxramas_classic_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/naxxramas_classic_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/naxxramas_classic_floor3.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/naxxramas_classic_floor4.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/naxxramas_classic_floor5.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/naxxramas_classic_floor6.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/ragefire_chasm.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
web/assets/maps/razorfen_downs.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/razorfen_kraul.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
web/assets/maps/scarlet_monastery_armory.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/scarlet_monastery_cathedral.webp
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
web/assets/maps/scarlet_monastery_graveyard.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/scarlet_monastery_library.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/scholomance.webp
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
web/assets/maps/shadowfang_keep_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/shadowfang_keep_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/shadowfang_keep_floor3.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/shadowfang_keep_floor4.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/shadowfang_keep_floor5.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/shadowfang_keep_floor6.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/assets/maps/shadowfang_keep_floor7.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/stratholme_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/stratholme_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/the_stockade.webp
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
web/assets/maps/thebeyond.webp
Normal file
|
After Width: | Height: | Size: 762 KiB |
BIN
web/assets/maps/uldaman_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/uldaman_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
web/assets/maps/upper_blackrock_spire_floor1.webp
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
web/assets/maps/upper_blackrock_spire_floor2.webp
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
web/assets/maps/upper_blackrock_spire_floor3.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
web/assets/maps/wailing_caverns.webp
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/assets/maps/zul_farrak.webp
Normal file
|
After Width: | Height: | Size: 3 MiB |
BIN
web/assets/maps/zulgurub.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
|
|
@ -33,6 +33,12 @@
|
|||
<button id="tool-pull" class="tool" title="Click map to drop pull markers">Pull</button>
|
||||
<button id="tool-note" class="tool" title="Drop an (i) info icon — text shows on hover">Note</button>
|
||||
<button id="tool-text" class="tool" title="Drop a freetext label — text always visible on the map">Text</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<label class="layer-toggle" title="Show enemy/trash spawns"><input type="checkbox" id="layer-enemies" checked> Enemies</label>
|
||||
<label class="layer-toggle" title="Show enemy pack polygons"><input type="checkbox" id="layer-packs" checked> Packs</label>
|
||||
<label class="layer-toggle" title="Show enemy patrol routes"><input type="checkbox" id="layer-patrols" checked> Patrols</label>
|
||||
<label class="layer-toggle" title="Show map icons (start, doors, comments)"><input type="checkbox" id="layer-icons" checked> Icons</label>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button id="undo" title="Undo (⌘Z)">Undo</button>
|
||||
<button id="clear" title="Clear current floor">Clear</button>
|
||||
<button id="share">Share</button>
|
||||
|
|
|
|||
|
|
@ -194,6 +194,26 @@ body {
|
|||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: var(--line);
|
||||
align-self: center;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.layer-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.layer-toggle:hover { color: var(--text); }
|
||||
.layer-toggle input { margin: 0; cursor: pointer; }
|
||||
|
||||
/* --- canvas / overlay ----------------------------------------------------- */
|
||||
|
||||
|
|
|
|||