Skip to content

About

Interactive long dark map

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

The Long Dark — Interactive Map

A no-build, offline-friendly interactive map for The Long Dark. The world view shows Great Bear Island with clickable region labels; clicking a region opens a zoomable, pannable detail map. Static files only — index.html + styles.css + src/logic.js + the bundled map images, no toolchain, no runtime server.

▶ Live demo: https://longdarkmap.helledb.dev/

Usage

Open the live demo, or clone the repo and open index.html in any modern browser. No build step, no server required.

  • Click a region label to open its detail map
  • Click the region name in the detail-view header to switch region without going back to the world map
  • The header shows the region's difficulty (Beginner / Intermediate / Advanced); hover or click the badge for the in-game region description. Hovering a region label on the world map shows both
  • Scroll to zoom, drag to pan (stops at the map edge), double-click to reset — on phones, pinch to zoom and double-tap to zoom in; both views open at a centred cover crop instead of the letterboxed fit
  • + / − / ⊡ zoom controls in the bottom-right of the detail view
  • Click a place name on a region map to mark it as visited (green ✓; click again to unmark). The header shows ✓ n/N visited for the region and search results carry a ✓ for visited places. Stored per browser in localStorage (ldm.looted.v1), nothing leaves the device
  • ? opens a legend explaining the tool-badge glyphs and layer colours (detail-view header; bottom-left on phones)
  • D (dev server only) toggles a coordinate overlay: [x%, y%] of the world map on the world view, [x, y] as 0..1 of the region map on the detail view. The key is a no-op when the page is opened directly or from the public deploy. In the detail view with D on:
    • Every stored bounding box for the region is drawn as a labelled blue rectangle, so misplaced ones jump out.
    • Click any box (including the search highlight) to grab it for editing.
    • Shift-drag to draw a fresh bounding box.
    • In editing mode, drag the body to move and drag the corner handles to resize. Click Save to persist (requires the dev server, see below) or Cancel to dismiss.

Place search

The world view has a search box for finding any named location across all regions.

  • Type to filter — e.g. carter matches Carter Hydro Dam in Mystery Lake
  • Press / anywhere on the world view to focus the search
  • ↑ / ↓ to navigate results, Enter to open, Esc to clear / blur
  • Each result shows the location name and the region it belongs to; clicking opens that region's detail map and highlights the matching label: the view zooms in on the box, briefly dims the rest of the map, and leaves a glowing outline around the label

The index (~450 named locations) plus per-label bounding boxes are bundled into index.html so search and highlighting work fully offline, including from a static deploy.

Crafting-tool keywords

The search also recognises crafting-tool keywords:

  • forge, arrowheads, smithing → the 6 in-game forges
  • workbench, wb, crafting → the 42 wiki-tagged workbenches
  • ammo, ammunition, reloading → the ammunition workbench at Last Resort Cannery

Each result row shows a small badge for the tools at that place (F/W/A). Opening a tool result accents the matching badges in the detail view and dims the rest, so you can see at a glance "I'm at Field 31, and there are two more forges (Hangar Basement, Main Hangar) here too." Deep links carry the filter — share a URL like index.html#forsaken_airfield/Field%2031/forge and the filter activates on load.

The tool data is scraped from the Long Dark wiki's structured categories (Locations with a forge, Locations with a workbench) by tools/scrape_tools.py, layered with:

  • manual entries from data/crafting_tools_extra.json for tools the wiki doesn't categorise (e.g. the ammunition workbench at Last Resort Cannery), and
  • vision-LLM amenity passes (per region, optional) that pick up amenities printed only as map pictograms — bed, stove, first-aid kit, ice fishing hut. These layer on top of the wiki tags and never remove them.

Output: data/crafting_tools.json plus an inlined PLACE_TOOLS block in index.html.

Resource keywords

Typing a resource name (moose, bear, wolf, timberwolf, cougar, deer, cattails, sapling, salt, …) lists the regions where that resource was detected, biggest count first, above any place-name matches. Each row shows the resource swatch, the region, and the hit count; opening one jumps straight into that region with the resource's layer on (#region/$resource:moose). The synonym table lives in RESOURCES_META in src/logic.js.

Vision-LLM amenity pass (per region)

.venv/bin/python tools/amenity_prep.py coastal_highway   # crops + task.json + legend.jpg
# Then in Claude Code, dispatch one vision subagent per region using
# tools/amenity_prompt.md as the prompt template. Each subagent reads
# data/tiles/<region>/amenities/ and writes result.json there.
python3 tools/scrape_tools.py --inline                   # merges all result.json files

The prep step (amenity_prep.py) crops each PLACE_BOXES entry from the region's standard map with generous padding (the icons sit next to the place's label, not on top), saves them under data/tiles/<region>/amenities/<slug>.jpg, plus a legend.jpg strip from the bottom of the map and a task.json listing the canonical tag set the subagent is allowed to emit.

The subagent prompt (amenity_prompt.md) constrains the output to a fixed canonical-tag set — forge, workbench, ammunition_workbench, milling_machine, bed, stove, first_aid, ice_fishing_hut, ice_fishing_hole — and instructs the agent to ignore loot icons, animal areas, foraging markers, and DLC trinkets, since those are visible on the map already. False positives are worse than false negatives: the wiki layer authoritatively covers forge and workbench.

Recall is uneven by map style: regions where amenities cluster in styled "info chips" next to each label (e.g. Quonset Garage in Coastal Highway) come back well-tagged; regions where amenity icons are drawn at their actual workshop building inside a building cluster (e.g. Coastal Townsite) often produce empty crops because the icon is outside the label-centred crop window. The wiki layer is what catches those.

Region difficulty and description

cd tools && python3 scrape_region_info.py && python3 inline_regions.py

scrape_region_info.py reads each region's wiki page: the difficulty field of the Infobox/Region and the {{Quote2|...|In-game description}} quote of the region-select blurb (marketing quotes on the same page are skipped). When the infobox has no difficulty, it comes from the blurb's "For advanced players." opening, which is then stripped. Transition zones mostly have neither and are left out. Output: data/region_info.json, inlined as REGION_INFO between the REGION_INFO_START / REGION_INFO_END sentinels.

Show on map panel

A floating panel in the top-left of the detail view (collapsible, starts collapsed on phones; the choice is remembered in localStorage as ldm.layersCollapsed.v1). Each row is a checkbox with a count; checking it rings every location on the map and desaturates the map so the rings stand out. Several layers can be on at once and they stay on when you switch region. Clear (n) in the panel head turns them all off (it stays visible when the panel is collapsed); closing the detail view clears them too.

  • Crafting: the rare crafting tools (forge, workbench, ammunition workbench, milling machine). Tools present in the region are checkboxes. Missing ones get a dashed box and Find on map, which switches to the world map in find mode: every region that has the tool lights up in its colour, the region you came from is marked You are here, a dashed line traces the shortest route (BFS over the region adjacency graph) to the nearest one, and a banner names it (e.g. "Closest to Mystery Lake: Old Spence Family Homestead, Forlorn Muskeg (next region)"). Opening a lit region turns that tool's layer on. ✕ or Escape ends find mode.
  • Wildlife & foraging: every legend pictogram detected on the region's map (moose, bear, wolf, timberwolf, deer, cougar, cattails, sapling, salt deposit).

A $resource: hash turns layers on, so search results and links can open a region with them showing:

  • index.html#coastal_highway/$resource:moose → Coastal Highway with the moose layer on
  • index.html#mystery_lake/$resource:bear,wolf → Mystery Lake with bears and wolves ringed

Resource hits are detected by tools/find_resources.py (see "Refreshing the region resources" below) and inlined into index.html between the REGION_RESOURCES_START / REGION_RESOURCES_END sentinels. Region adjacencies live in the adjacencies field of data/regions.json, seeded from the Long Dark wiki's connections infobox field. The graph is undirected and seeded once. To regenerate after wiki changes, run python3 tools/scrape_connections.py to dump each region's parsed connections to data/regions_connections_raw.json; the symmetric union is hand-merged into data/regions.json and re-inlined via python3 tools/inline_regions.py.

Offline / PWA

The site is installable as a PWA (see manifest.json) and works offline via a service worker (sw.js) with two caches:

  • Shell cache (versioned): index.html, styles.css, src/logic.js, manifest, and icons — precached on install, then served stale-while-revalidate, so a deploy reaches users on the visit after it lands without bumping SHELL_VERSION.
  • Maps cache (unversioned): the region map images. Each map is cached lazily the first time its region is opened, and the "Save all maps offline" button in the world view prefetches all of them (~29 MB) for fully offline use — on a plane, in the woods, anywhere without signal. The maps cache survives shell deploys, so updating the app never re-downloads the maps.

Bump SHELL_VERSION in sw.js only to force an immediate refetch of the shell (stale-while-revalidate already picks changes up one visit later).

Refreshing the region maps

The map images live in maps/ and are committed to the repo so the site works as a static deploy (e.g. GitHub Pages). To pull the latest versions from the source guide, run:

python3 tools/download_maps.py

The script fetches both difficulty variants for each region into maps/, re-encodes them to WebP (quality 82, ~60% smaller than the source JPEGs with no visible loss on the map text), and skips files that already exist. Needs Pillow (requirements.txt). The world map stays a JPEG: its paper texture doesn't compress any better as WebP, and the social-preview og:image uses it.

Refreshing the place index

To re-scrape the location list from the Long Dark wiki:

python3 tools/scrape_places.py            # writes data/places_index.json
python3 tools/scrape_places.py --inline   # also bundles the result into index.html

The scraper hits the Fandom MediaWiki API for each region's Category:Locations_in_<Region> page. Use --inline after the scrape to bake the new index into index.html between the PLACES_INDEX_START / PLACES_INDEX_END sentinels — that's what the runtime search reads. Stdlib only.

Refreshing the place boxes

Each named location has a bounding box on its region map so search-result clicks can frame the right label. The boxes come from a two-stage pipeline:

  1. OCR: Apple Vision (ocrmac) reads each tile locally and emits pixel-precise bboxes for every text fragment it detects.
  2. Matcher: one Claude Haiku subagent per region maps the OCR text to canonical wiki names — handling parenthetical suffixes, #1/#2 instance suffixes, multi-line label merging, and ambiguous shorthand.
python3 tools/build_tiles.py            # split each map into a 3×3 overlapping tile grid
python3 tools/scrape_places.py          # writes places_index.json AND task.json per region
.venv/bin/python tools/ocr_run.py       # Stage 1: ocrmac → data/tiles/<region>/ocr_detections.json
# Stage 2: in Claude Code, dispatch one Haiku matcher subagent per region
# pointed at data/tiles/<region>/, using the prompt template at tools/match_prompt.md
python3 tools/merge_boxes.py --inline   # merge results + overrides, write data/place_boxes.json,
                                        # and inline into index.html

Stage 1 is deterministic and free (Apple Vision runs on-device). Stage 2 is pure text-to-text matching with no image reads, so Haiku is plenty and the cost is negligible. (An earlier vision-LLM-only flow couldn't produce pixel-tight bboxes; switching to real OCR fixed that fundamentally — git history has the deprecated prompt template if you ever want to compare.)

The merge script writes both data/place_boxes.json and refreshes the PLACE_BOXES_START / PLACE_BOXES_END block in index.html. Coordinates are stored as [x1, y1, x2, y2] fractions (0..1) of the region's full map image.

Fixing misplaced boxes from the browser

node dev-server.js   # http://127.0.0.1:8765/  (no npm install — stdlib only)

Press D in any region to overlay every stored bounding box at once, each labelled with its place name, so the wrong ones are obvious without searching them up one by one. Then either:

  • Click any box (or the brighter search highlight) to grab it. Drag the body to move, drag corner handles to resize.
  • Shift-drag anywhere to draw a fresh bounding box from scratch.

Click Save when the box looks right. The server writes the new bbox into data/place_boxes_overrides.json and re-runs merge_boxes.py --inline so data/place_boxes.json and the inlined PLACE_BOXES in index.html both reflect the change immediately. The page also updates its in-memory PLACE_BOXES so the highlight snaps to the new position without a reload.

The overrides file is the source of manual fixes; running merge_boxes.py on its own re-derives data/place_boxes.json from the OCR results in data/tiles/ and layers overrides on top, so re-running the OCR pipeline never destroys hand-tuned boxes.

Refreshing the region resources

data/region_resources.json holds detected legend-icon positions (moose, bear, wolf, deer, cougar, cattails, sapling, salt deposit, …) per region — fed straight into the detail-view's Show on map panel. To regenerate:

.venv/bin/python tools/find_resources.py             # all regions
.venv/bin/python tools/find_resources.py mystery_lake # one region
.venv/bin/python tools/find_resources.py --inline    # all + refresh index.html block
.venv/bin/python tools/find_resources.py --inline-only # re-inline existing JSON, skip matching
.venv/bin/python tools/find_resources.py --recheck-deer --inline # only redo the deer recheck (step 6) on existing JSON

The matcher uses OpenCV template matching against canonical legend swatches in data/legend_icons/canonical/ (one PNG per resource). HokuOwl reuses the same vector icons across all 21 regions, so a single canonical set suffices.

Pipeline per region:

  1. For each "class group" sharing an HSV colour filter (red-brown animals, brown cattails, black icons, gray cougar, green foraging), build a binary colour mask of the map.
  2. Multi-scale TM_CCOEFF_NORMED on the binary mask, against both the original template and its horizontal flip — animals on the map are drawn facing either direction.
  3. Cross-class NMS keeps each spatial cluster's highest-scoring class so moose / bear / wolf / timberwolf get disambiguated despite sharing the maroon palette.
  4. Auto legend-area suppression: clusters of high-score multi-class hits that touch a map edge get treated as legend swatches and zeroed out.
  5. Disambiguator templates (rabbit, coal, rose hips, lichen, reishi) compete in NMS but get dropped from the output — they exist to claim their own legend swatches and any on-map locations that look like them, so the user-facing classes don't false-positive on those.
  6. Deer recheck: every deer hit is re-scored at its own position (template placements centred inside the hit, so a neighbouring icon can't interfere) and dropped when it is
    • a rabbit: maps are drawn at different scales (icons on Bleak Inlet or Timberwolf Mountain are ~0.6–0.75× the legend swatch, below the matcher's scale range), so the rabbit template is re-run over 0.5–1.2× and wins;
    • a railway sleeper: deer doesn't beat a rotated railway.png template by 0.2 (real deer lead by ≥ 0.27, tracks by ≤ 0.14);
    • a legend swatch: the deer template was cut from one, so swatches score ≥ 0.96 while deer on a map top out at 0.94.

Per-group tuning knobs live in CLASS_GROUPS at the top of find_resources.py: morphological opening (kills thin text strokes for black/gray groups), a density floor on the matched bbox (rejects CCOEFF-high but sparse text-fragment matches), and a stricter score threshold for noisy groups.

The maple and birch sapling templates run separately during matching (so both icon variants on the map get caught), then merge into a single sapling class at output time — the tiny M/B letter is too small for reliable matchTemplate discrimination, and the user just wants to find "a sapling".

Coordinates are stored as [x1, y1, x2, y2] fractions (0..1) of the standard region map, same convention as data/place_boxes.json. Each hit also carries the matcher's confidence score.

Requires opencv-python-headless and numpy from requirements.txt. Runtime is ~3 minutes for all 21 regions on an M-series Mac.

Tests

npm install   # one-time; installs jsdom (the only dev dep)
npm test      # runs the full suite (~2s, ~60 tests)

Two layers, both running on Node's built-in node:test:

  • Unit tests (tests/logic.test.js) cover the pure helpers extracted into src/logic.js: search ranking, tool-keyword synonym expansion, BFS region pathfinding (findNearestTool + tie-breaks), and the hash-routing format (including the $resource: hash form).
  • DOM tests (tests/dom.test.js) boot index.html into jsdom via tests/harness.js and drive it: typing into the search box, ↑/↓/Enter/Esc keyboard nav, tool-badge accents, hash deep-links opening the right region, region-button clicks, the Show on map panel (rows, toggling rings on and off, Clear, $resource: deep links, persistence across regions), world-map find mode and the region picker.

src/logic.js dual-exports — it attaches window.LDLogic in the browser and module.exports = LDLogic in Node — so the same code is exercised in both environments. The inline <script> in index.html uses thin wrappers that pass the bundled data tables (PLACES_INDEX, PLACE_TOOLS, REGIONS, REGIONS_BY_ID) into those helpers.

The DOM harness stubs all subresources except src/logic.js (served from disk), so tests don't depend on map images or CSS being fetchable. Pan/zoom transforms and pinch-zoom are out of scope — jsdom doesn't model real layout — and would need a headless browser (e.g. Playwright) if those regress.

Map sources & credits

All map artwork is community-made and hosted on Steam. The images bundled here are mirrored only so the static site is usable; full credit and ownership belong to the creators below. Please visit, rate, and follow their work on Steam. If either creator would prefer the images not be re-hosted, open an issue and they'll be removed.

  • World map (maps/2899955301_preview_GREAT_BEAR_ISLAND_MAP_v12.jpg) — preview image from [spoilers] Tales from the Far Territory map locations by Krueger.
  • Langston Mine and Transition Cave maps (maps/langston_mine.webp, maps/transition_cave.webp) — from the same guide by Krueger, one variant each. Langston Mine opens from its label on the Zone of Contamination map; Transition Cave from the "transitional cave" text on the world map.
  • Connections between regions (maps/connections.webp) — schematic overview from the same guide by Krueger, opened from the "Region connections" button in the bottom-left of the world view.
  • Region maps — from Updated Region Maps [2025] by HokuOwl. Each region has two difficulty variants: Pilgrim/Voyageur/Stalker (saved as <region>.webp, tab "Standard") and Interloper/Misery (saved as <region>_loper.webp, tab "Interloper"). Exact image URLs are listed in data/regions.json.
  • Region label positions — adapted from TLD-Interactive-Map by Elektronixx, whose image-map hotspot coordinates were converted to percentages and used as the pos values in data/regions.json.
  • Place names — most are scraped from the per-region Category:Locations_in_* pages on the Long Dark Fandom wiki (CC-BY-SA 3.0). The rest (data/places_extra.json) are official (blue, upper-case) labels printed on HokuOwl's maps that the wiki doesn't list — buildings like Maintenance Shed, plus generic cabins, trailers, caves and prepper caches, numbered per region (Cabin #1, Cabin #2, …).
  • Per-place bounding boxes (data/place_boxes.json) — derived locally from Apple Vision OCR run over HokuOwl's region maps via tools/ocr_run.py, then matched to the wiki names by a Haiku subagent (tools/match_prompt.md). Manual fixes from the in-browser editor live in data/place_boxes_overrides.json. No external attribution is needed for the box coordinates themselves; the underlying labels they frame are the map artists' work.

The Long Dark is © Hinterland Studio Inc.

Project structure

index.html              # App shell, HTML markup, JS, and inlined data (place index + place boxes + regions)
styles.css              # All app styling, loaded by index.html
src/logic.js            # Pure helpers (search ranking, BFS, hash routing). Loaded by index.html and unit-tested
tests/                  # node:test suites (run via `npm test`)
  harness.js            #   Boots index.html into jsdom for DOM-level tests
  logic.test.js         #   Unit tests for src/logic.js
  dom.test.js           #   Search dropdown, keyboard nav, hash routing
package.json            # `npm test` script + jsdom dev-dep (the only npm dep in the project)
tools/                  # Build-time scripts — only needed when refreshing the data:
  download_maps.py      #   Fetches region maps into maps/
  scrape_places.py      #   Scrapes the place index from the Long Dark Fandom wiki
  build_tiles.py        #   Splits each region map into a 3×3 overlapping tile grid for OCR
  ocr_run.py            #   Stage 1: macOS Vision OCR (ocrmac) → data/tiles/<region>/ocr_detections.json
  match_prompt.md       #   Stage 2 prompt: matches OCR text fragments to canonical wiki names
  merge_boxes.py        #   Merges per-region matcher results + overrides into data/place_boxes.json + index.html
  find_unclaimed.py     #   Discovery tool: surfaces OCR labels not claimed by the wiki for review
  inline_regions.py     #   Inlines data/regions.json into index.html between REGIONS_START/END sentinels
  scrape_tools.py       #   Scrapes Locations_with_a_forge / _workbench wiki categories + extras → crafting_tools.json
  scrape_connections.py #   Scrapes per-region `connections` infobox → regions_connections_raw.json (audit trail)
  amenity_prep.py       #   Crops each PLACE_BOXES entry into per-place .jpg + legend.jpg for vision-LLM amenity tagging
  amenity_prompt.md     #   Subagent prompt template for the vision-LLM amenity pass
  find_resources.py     #   OpenCV template-matching: finds legend pictograms (moose/bear/.../sapling) → region_resources.json + index.html inline
sw.js                   # Service worker: shell precache + stale-while-revalidate, lazy maps cache
manifest.json           # PWA manifest (install metadata, icons, region shortcuts)
icons/                  # PWA / touch icons
dev-server.js           # Optional Node dev server: serves the site + persists in-browser bbox edits
maps/                   # Map images: per-region detail maps + the world map (committed; refresh via the script)
data/regions.json       # Canonical region list (id, display name, world-map pos, map paths, wiki + Steam URLs, adjacencies)
data/places_index.json  # Flat [{name, region}] index powering the world-view search (committed)
data/place_boxes.json   # {region: {name: [x1,y1,x2,y2]}} bounding boxes (committed; merge of OCR results + overrides)
data/place_boxes_overrides.json  # Manual edits from the dev server's Save button (committed)
data/crafting_tools.json         # {region: {name: [tool, ...]}} merge of wiki + extras + vision-LLM amenities (committed)
data/crafting_tools_extra.json   # Manual seed for tools the wiki doesn't categorise — ammo workbench, milling machine (committed)
data/regions_connections_raw.json # Wiki connections audit trail from scrape_connections.py (committed; small)
data/region_resources.json # {region: {resource: [{bbox, score}]}} detected resource pictograms (committed; from find_resources.py)
data/legend_icons/canonical/ # PNG templates per resource (moose.png, bear.png, …); shared across all regions
data/tiles/             # Per-region tiled maps + OCR/amenity intermediates (intermediate; gitignored)

Region positions on the world map live in data/regions.json as pos: [x%, y%], consumed by every Python tool and inlined into index.html by tools/inline_regions.py. Press D in the browser to overlay live coordinates while hovering, then update the matching entry in data/regions.json and re-run the inliner.

About

Interactive long dark map

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages