Import the standalone Allons/Wails explorer, embedded FMG catalog, stitched maps, icons, generator, and tests as the repository baseline.
- commit
- a12d44f7f1ef9656fe1246f8c7a797856bff499d
- author
- Dami <dami.etoile@gmail.com>
@@ -0,0 +1,53 @@
1+# Atlas2+3+Atlas is an offline game-map explorer built with4+[Allons](../allons). It is generated from the neighboring `gamemap` FMG5+archive and includes every complete, z13-compatible map plus its pins,6+categories, zones, and tile-set variants.7+8+Keep the three repositories beside one another:9+10+```text11+~/Developer/12+├── allons/13+├── atlas/14+└── gamemap/15+```16+17+Run Atlas from this repository:18+19+```sh20+go run -tags dev .21+```22+23+Wails requires a desktop build tag even when it is invoked through `go run`.24+Atlas supplies the additional macOS `UniformTypeIdentifiers` linker flag25+itself, so no shell environment setup is needed.26+27+The app supports scroll/pinch zoom, drag panning, pin search, map and tile-set28+selection, category toggles, group-level show/hide controls, floating29+`display_type: "text"` labels, optional region polygons and names, and pin30+details. Pins and legend entries use the archive's category SVG and resolved31+MapGenie color when available, with a white exterior treatment for contrast.32+Dense maps use stable, priority-based annotation sampling while selected and33+searched locations always remain visible.34+35+[SCRAPER_PROMPT.md](SCRAPER_PROMPT.md) contains a ready-to-use prompt for36+preserving MapGenie's text-display and zone fields in the upstream archive.37+38+## Refreshing the embedded data39+40+With `../gamemap` beside this repository:41+42+```sh43+go generate .44+```45+46+`main.go` embeds the entire `assets` tree with `//go:embed all:assets`.47+The resulting binary has no runtime dependency on `../gamemap` or the network.48+Generation copies referenced game icons into `assets/icons`, preserving their49+source colors; categories without an SVG receive a compact initials fallback.50+Generation skips maps with missing snapshots, incomplete layers, or no z1351+tiles instead of exposing a broken map in the picker.52+Repeated placeholder tiles around smaller source maps are detected during53+generation, then clipped and excluded from the viewer's fit bounds.@@ -0,0 +1,60 @@
1+# Prompt: preserve MapGenie text labels and zones2+3+Use this prompt in the `gamemap` scraper repository:4+5+> Extend the FMG scraper/archive pipeline so downstream map clients can render6+> MapGenie text labels and geographic zones without inferring behavior from7+> category names.8+>9+> First inspect the current full-map response and archive format. The raw10+> snapshots may already contain some or all of these fields; do not add a11+> second representation when preservation through the existing pipeline is12+> sufficient.13+>14+> Preserve these category and location fields:15+>16+> - `groups[].categories[].display_type` exactly as returned by MapGenie.17+> Known values are `marker` and `text`; retain unknown future values.18+> - `groups[].categories[].icon`, `id`, `title`, `visible`, and `locations`.19+> - `locations[].region_id`, including explicit `null`.20+> - Existing location `id`, `title`, `description`, `latitude`, and21+> `longitude`.22+>23+> Preserve the map's complete `regions` collection. For every region and24+> nested subregion retain:25+>26+> - `id`, `map_id`, `title`, `subtitle`, and `order`27+> - `parent_region_id`28+> - `center_x` and `center_y`, preserving nulls and numeric strings29+> - `features[]` as valid GeoJSON Features, including the complete30+> `geometry.type` and `geometry.coordinates`31+> - `subregions[]` recursively32+>33+> Do not flatten away hierarchy, discard polygon holes, reduce coordinate34+> precision, calculate centers during scraping, or classify a category as text35+> merely because its title is `Area`. Behavior must be driven by36+> `display_type`.37+>38+> Keep the archive deterministic: stable key ordering where the project39+> already guarantees it, no timestamps beyond the existing capture metadata,40+> and no changes to tile paths or content hashes for an unchanged response.41+> Update the JSON schema emitted beside snapshots so these fields are covered.42+>43+> Add fixtures and tests proving:44+>45+> 1. Marathon / Cryo Archive / Locations / Area retains46+> `display_type: "text"` and the `PANOPTICON` location with its original47+> latitude and longitude.48+> 2. All four Marathon `Area` categories remain text categories. The current49+> fixture set contains 40 such locations in total.50+> 3. Forza Horizon 6 / Japan retains its 10 titled regions, polygon geometry,51+> hierarchy, and the `region_id` on all 806 current locations.52+> 4. Polygon coordinates round-trip without precision loss.53+> 5. Pokémon FireRed/LeafGreen's category titled `Area` remains54+> `display_type: "marker"`; category names must not control rendering.55+> 6. Older snapshots lacking these optional fields still decode.56+>57+> Document the downstream contract: `display_type: "text"` is rendered as a58+> floating title at the location coordinate; `marker` is rendered as an59+> ordinary pin; `regions[].features[].geometry` supplies optional zone60+> boundaries and region titles.@@ -0,0 +1,1188 @@
1+:root {2+ color-scheme: dark;3+ --bg: #111310;4+ --panel: #171a16;5+ --panel-raised: #1e211c;6+ --line: rgba(236, 240, 218, 0.11);7+ --line-strong: rgba(236, 240, 218, 0.2);8+ --text: #eef0e4;9+ --muted: #93988b;10+ --accent: #d6f36b;11+ --accent-dark: #202a0e;12+ --topbar: 72px;13+ --sidebar: 330px;14+ --pin-overlay-size: 28px;15+ --text-overlay-size: 14px;16+ --zone-overlay-size: 14px;17+ --icon-outset:18+ drop-shadow(1px 0 0 rgba(255, 255, 255, 0.96))19+ drop-shadow(-1px 0 0 rgba(255, 255, 255, 0.96))20+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.96))21+ drop-shadow(0 -1px 0 rgba(255, 255, 255, 0.96))22+ drop-shadow(0 2px 2px rgba(0, 0, 0, 0.9));23+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;24+ font-synthesis: none;25+}26+27+* {28+ box-sizing: border-box;29+}30+31+html,32+body {33+ width: 100%;34+ height: 100%;35+ margin: 0;36+ overflow: hidden;37+ background: var(--bg);38+ color: var(--text);39+ -webkit-user-select: none;40+ user-select: none;41+}42+43+button,44+input,45+select {46+ font: inherit;47+}48+49+button {50+ color: inherit;51+}52+53+input {54+ -webkit-user-select: text;55+ user-select: text;56+}57+58+.app-shell {59+ display: grid;60+ grid-template-columns: var(--sidebar) 1fr;61+ grid-template-rows: var(--topbar) minmax(0, 1fr);62+ width: 100%;63+ height: 100%;64+}65+66+.topbar {67+ position: relative;68+ z-index: 20;69+ grid-column: 1 / -1;70+ display: grid;71+ grid-template-columns: var(--sidebar) minmax(0, 1fr) auto;72+ align-items: center;73+ border-bottom: 1px solid var(--line);74+ background: rgba(19, 21, 18, 0.96);75+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);76+}77+78+.brand {79+ display: flex;80+ align-items: center;81+ gap: 12px;82+ height: 100%;83+ padding: 0 24px;84+ border-right: 1px solid var(--line);85+}86+87+.brand-mark {88+ display: grid;89+ width: 35px;90+ height: 35px;91+ place-items: center;92+ color: var(--accent);93+}94+95+.brand-mark svg {96+ width: 100%;97+ fill: none;98+ stroke: currentColor;99+ stroke-linecap: round;100+ stroke-linejoin: round;101+ stroke-width: 1.7;102+}103+104+.brand strong,105+.brand small {106+ display: block;107+}108+109+.brand strong {110+ font-family: "Arial Narrow", "Roboto Condensed", sans-serif;111+ font-size: 16px;112+ letter-spacing: 0.2em;113+ line-height: 1;114+}115+116+.brand small {117+ margin-top: 5px;118+ color: var(--muted);119+ font-size: 10px;120+ letter-spacing: 0.08em;121+ text-transform: uppercase;122+}123+124+.map-pickers {125+ display: flex;126+ align-items: center;127+ gap: 7px;128+ min-width: 0;129+ padding: 0 18px;130+}131+132+.map-pickers label {133+ display: flex;134+ min-width: 0;135+ align-items: center;136+ gap: 7px;137+ color: var(--muted);138+ font-size: 10px;139+ font-weight: 700;140+ letter-spacing: 0.1em;141+ text-transform: uppercase;142+}143+144+.map-pickers select {145+ max-width: 230px;146+ height: 37px;147+ padding: 0 32px 0 12px;148+ border: 1px solid var(--line);149+ border-radius: 8px;150+ outline: none;151+ background: var(--panel-raised);152+ color: var(--text);153+ font-size: 13px;154+ font-weight: 600;155+ letter-spacing: normal;156+ text-transform: none;157+}158+159+.map-pickers select:focus {160+ border-color: rgba(214, 243, 107, 0.6);161+}162+163+#variant-field[hidden] {164+ display: none;165+}166+167+.icon-button {168+ display: grid;169+ width: 40px;170+ height: 40px;171+ padding: 10px;172+ place-items: center;173+ border: 1px solid var(--line);174+ border-radius: 8px;175+ background: var(--panel-raised);176+}177+178+.icon-button svg {179+ fill: none;180+ stroke: currentColor;181+ stroke-linecap: round;182+ stroke-width: 1.8;183+}184+185+.mobile-legend {186+ display: none;187+ margin-right: 16px;188+}189+190+.sidebar {191+ position: relative;192+ z-index: 10;193+ display: flex;194+ grid-column: 1;195+ grid-row: 2;196+ min-height: 0;197+ flex-direction: column;198+ border-right: 1px solid var(--line);199+ background:200+ linear-gradient(180deg, rgba(214, 243, 107, 0.025), transparent 22%),201+ var(--panel);202+}203+204+.sidebar-heading {205+ padding: 25px 22px 18px;206+}207+208+.eyebrow {209+ margin: 0 0 8px;210+ color: var(--accent);211+ font-size: 9px;212+ font-weight: 800;213+ letter-spacing: 0.2em;214+}215+216+.sidebar-heading h1 {217+ overflow: hidden;218+ margin: 0;219+ font-family: "Arial Narrow", "Roboto Condensed", sans-serif;220+ font-size: 27px;221+ font-weight: 700;222+ letter-spacing: -0.025em;223+ line-height: 1.05;224+ text-overflow: ellipsis;225+ white-space: nowrap;226+}227+228+.sidebar-heading p:last-child {229+ margin: 9px 0 0;230+ color: var(--muted);231+ font-size: 12px;232+}233+234+.search-wrap {235+ position: relative;236+ display: flex;237+ align-items: center;238+ margin: 0 17px 16px;239+}240+241+.search-wrap svg {242+ position: absolute;243+ left: 12px;244+ width: 16px;245+ fill: none;246+ stroke: var(--muted);247+ stroke-linecap: round;248+ stroke-width: 1.7;249+}250+251+.search-wrap input {252+ width: 100%;253+ height: 42px;254+ padding: 0 45px 0 37px;255+ border: 1px solid var(--line);256+ border-radius: 8px;257+ outline: none;258+ background: #11140f;259+ color: var(--text);260+ font-size: 13px;261+}262+263+.search-wrap input::placeholder {264+ color: #757a6e;265+}266+267+.search-wrap input:focus {268+ border-color: rgba(214, 243, 107, 0.5);269+ box-shadow: 0 0 0 3px rgba(214, 243, 107, 0.06);270+}271+272+.search-wrap kbd {273+ position: absolute;274+ right: 9px;275+ padding: 3px 5px;276+ border: 1px solid var(--line);277+ border-radius: 4px;278+ background: var(--panel-raised);279+ color: var(--muted);280+ font-family: inherit;281+ font-size: 9px;282+}283+284+.search-results {285+ z-index: 5;286+ overflow: auto;287+ max-height: 238px;288+ margin: -8px 17px 14px;289+ border: 1px solid var(--line);290+ border-radius: 8px;291+ background: #11140f;292+ box-shadow: 0 15px 35px rgba(0, 0, 0, 0.32);293+}294+295+.search-result {296+ display: grid;297+ width: 100%;298+ grid-template-columns: 22px 1fr;299+ gap: 10px;300+ padding: 10px 11px;301+ border: 0;302+ border-bottom: 1px solid var(--line);303+ background: transparent;304+ text-align: left;305+ cursor: pointer;306+}307+308+.search-result:last-child {309+ border-bottom: 0;310+}311+312+.search-result:hover {313+ background: rgba(255, 255, 255, 0.04);314+}315+316+.search-result .result-dot {317+ display: grid;318+ width: 20px;319+ height: 20px;320+ margin-top: 1px;321+ place-items: center;322+ border-radius: 50%;323+ background: var(--pin-color);324+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--pin-color) 18%, transparent);325+ color: #fff;326+ font-size: 7px;327+ font-weight: 900;328+}329+330+.search-result strong,331+.search-result small {332+ display: block;333+}334+335+.search-result strong {336+ overflow: hidden;337+ font-size: 12px;338+ text-overflow: ellipsis;339+ white-space: nowrap;340+}341+342+.search-result small,343+.search-empty {344+ margin-top: 3px;345+ color: var(--muted);346+ font-size: 10px;347+}348+349+.search-empty {350+ padding: 13px;351+}352+353+.legend-toolbar {354+ display: flex;355+ align-items: center;356+ justify-content: space-between;357+ padding: 12px 20px;358+ border-top: 1px solid var(--line);359+ border-bottom: 1px solid var(--line);360+ color: var(--muted);361+ font-size: 9px;362+ font-weight: 800;363+ letter-spacing: 0.14em;364+ text-transform: uppercase;365+}366+367+.legend-toolbar div {368+ display: flex;369+ gap: 5px;370+ align-items: center;371+}372+373+.legend-toolbar button {374+ padding: 0;375+ border: 0;376+ background: none;377+ color: var(--muted);378+ font-size: 9px;379+ font-weight: 700;380+ letter-spacing: 0.04em;381+ text-transform: uppercase;382+ cursor: pointer;383+}384+385+.legend-toolbar button:hover {386+ color: var(--accent);387+}388+389+.legend {390+ overflow-x: hidden;391+ overflow-y: auto;392+ min-height: 0;393+ flex: 1;394+ padding: 8px 0 14px;395+ scrollbar-color: #44483e transparent;396+ scrollbar-width: thin;397+}398+399+.overlay-controls {400+ flex: none;401+}402+403+.overlay-controls[hidden] {404+ display: none;405+}406+407+.zone-control,408+.overlay-control {409+ display: grid;410+ grid-template-columns: 18px 28px minmax(0, 1fr) auto;411+ align-items: center;412+ gap: 8px;413+ min-height: 48px;414+ padding: 5px 20px;415+ border-bottom: 1px solid var(--line);416+ color: #d2d6c8;417+ font-size: 11px;418+ font-weight: 650;419+ cursor: pointer;420+ user-select: none;421+}422+423+.zone-control[hidden],424+.overlay-control[hidden] {425+ display: none;426+}427+428+.zone-control:hover,429+.overlay-control:hover {430+ background: rgba(255, 255, 255, 0.025);431+}432+433+.zone-control input,434+.overlay-control input {435+ position: absolute;436+ width: 1px;437+ height: 1px;438+ opacity: 0;439+}440+441+.zone-control input:checked + .check,442+.overlay-control input:checked + .check {443+ border-color: var(--accent);444+ background: var(--accent);445+}446+447+.zone-control input:checked + .check::after,448+.overlay-control input:checked + .check::after {449+ opacity: 1;450+}451+452+.zone-symbol {453+ display: grid;454+ width: 27px;455+ height: 27px;456+ place-items: center;457+ border: 1px solid rgba(214, 243, 107, 0.3);458+ border-radius: 7px;459+ background: rgba(214, 243, 107, 0.08);460+ color: var(--accent);461+}462+463+.zone-symbol svg {464+ width: 17px;465+ fill: none;466+ stroke: currentColor;467+ stroke-linecap: round;468+ stroke-linejoin: round;469+ stroke-width: 1.5;470+}471+472+.text-symbol {473+ display: grid;474+ width: 27px;475+ height: 27px;476+ place-items: center;477+ border: 1px solid color-mix(in srgb, var(--pin-color) 38%, transparent);478+ border-radius: 7px;479+ background: color-mix(in srgb, var(--pin-color) 38%, #151814);480+ color: #f1f4e7;481+ font-family: "Arial Narrow", "Roboto Condensed", sans-serif;482+ font-size: 10px;483+ font-weight: 900;484+ letter-spacing: -0.04em;485+ text-shadow: 0 1px 2px #000;486+}487+488+#zone-count,489+.overlay-count {490+ color: #747a6d;491+ font-size: 10px;492+ font-variant-numeric: tabular-nums;493+}494+495+.legend-group {496+ border-bottom: 1px solid var(--line);497+}498+499+.legend-group:last-child {500+ border-bottom: 0;501+}502+503+.group-heading {504+ display: flex;505+ align-items: center;506+ justify-content: space-between;507+ width: 100%;508+ padding: 11px 20px 9px;509+ border: 0;510+ background: transparent;511+ color: #c8ccbd;512+ font-size: 10px;513+ font-weight: 800;514+ letter-spacing: 0.12em;515+ text-align: left;516+ text-transform: uppercase;517+ cursor: pointer;518+}519+520+.group-heading span:last-child {521+ color: var(--muted);522+ font-size: 9px;523+ font-weight: 600;524+ letter-spacing: 0;525+}526+527+.category-row {528+ display: grid;529+ grid-template-columns: 18px 28px minmax(0, 1fr) auto;530+ align-items: center;531+ gap: 8px;532+ min-height: 39px;533+ padding: 4px 20px;534+ color: #c0c4b7;535+ cursor: pointer;536+ user-select: none;537+}538+539+.category-row:hover {540+ background: rgba(255, 255, 255, 0.025);541+ color: var(--text);542+}543+544+.category-row input {545+ position: absolute;546+ width: 1px;547+ height: 1px;548+ opacity: 0;549+}550+551+.check {552+ display: grid;553+ width: 15px;554+ height: 15px;555+ place-items: center;556+ border: 1px solid #54594e;557+ border-radius: 3px;558+}559+560+.check::after {561+ width: 7px;562+ height: 4px;563+ border-bottom: 1.7px solid #172007;564+ border-left: 1.7px solid #172007;565+ content: "";566+ opacity: 0;567+ transform: translateY(-1px) rotate(-45deg);568+}569+570+.category-row input:checked + .check {571+ border-color: var(--accent);572+ background: var(--accent);573+}574+575+.category-row input:checked + .check::after {576+ opacity: 1;577+}578+579+.category-icon {580+ display: grid;581+ width: 27px;582+ height: 27px;583+ place-items: center;584+ border: 1px solid color-mix(in srgb, var(--pin-color) 38%, transparent);585+ border-radius: 50%;586+ background: color-mix(in srgb, var(--pin-color) 12%, #151814);587+ color: var(--pin-color);588+ font-size: 9px;589+ font-weight: 900;590+ text-transform: uppercase;591+}592+593+.category-icon.has-source-icon::before,594+.result-dot.has-source-icon::before,595+#detail-dot.has-source-icon::before,596+.pin.has-source-icon::after {597+ display: block;598+ background: var(--pin-icon) center / contain no-repeat;599+ content: "";600+ filter: var(--icon-outset);601+}602+603+.category-icon.has-source-icon::before {604+ width: 17px;605+ height: 17px;606+}607+608+.result-dot.has-source-icon,609+#detail-dot.has-source-icon {610+ border-radius: 0;611+ background: transparent;612+ box-shadow: none;613+}614+615+.result-dot.has-source-icon::before {616+ width: 16px;617+ height: 16px;618+}619+620+.category-name {621+ overflow: hidden;622+ font-size: 12px;623+ font-weight: 550;624+ text-overflow: ellipsis;625+ white-space: nowrap;626+}627+628+.category-count {629+ color: #747a6d;630+ font-size: 10px;631+ font-variant-numeric: tabular-nums;632+}633+634+.sidebar-footer {635+ display: flex;636+ align-items: center;637+ gap: 7px;638+ min-height: 44px;639+ padding: 0 18px;640+ border-top: 1px solid var(--line);641+ color: var(--muted);642+ font-size: 9px;643+ letter-spacing: 0.06em;644+ text-transform: uppercase;645+}646+647+.live-dot {648+ width: 6px;649+ height: 6px;650+ border-radius: 50%;651+ background: var(--accent);652+ box-shadow: 0 0 7px rgba(214, 243, 107, 0.6);653+}654+655+.map-panel {656+ position: relative;657+ grid-column: 2;658+ grid-row: 2;659+ min-width: 0;660+ min-height: 0;661+ background: #0c0e0c;662+}663+664+.map {665+ position: absolute;666+ inset: 0;667+ overflow: hidden;668+ outline: none;669+ background:670+ radial-gradient(circle at 50% 50%, rgba(102, 111, 86, 0.13), transparent 56%),671+ #0b0d0b;672+ cursor: grab;673+ touch-action: none;674+ user-select: none;675+}676+677+.map.is-dragging {678+ cursor: grabbing;679+}680+681+.map::after {682+ position: absolute;683+ z-index: 2;684+ inset: 0;685+ background:686+ linear-gradient(90deg, rgba(5, 7, 5, 0.36), transparent 7%, transparent 93%, rgba(5, 7, 5, 0.25)),687+ linear-gradient(180deg, rgba(5, 7, 5, 0.2), transparent 10%, transparent 92%, rgba(5, 7, 5, 0.35));688+ content: "";689+ pointer-events: none;690+}691+692+.world {693+ position: absolute;694+ top: 0;695+ left: 0;696+ width: 8192px;697+ height: 8192px;698+ transform-origin: 0 0;699+ will-change: transform;700+}701+702+.world > img {703+ display: block;704+ width: 8192px;705+ height: 8192px;706+ pointer-events: none;707+}708+709+.markers {710+ position: absolute;711+ z-index: 2;712+ inset: 0;713+}714+715+.zones,716+.zone-labels {717+ position: absolute;718+ z-index: 1;719+ inset: 0;720+ width: 8192px;721+ height: 8192px;722+ pointer-events: none;723+}724+725+.zone-shape {726+ fill: color-mix(in srgb, var(--zone-color) 8%, transparent);727+ fill-rule: evenodd;728+ stroke: color-mix(in srgb, var(--zone-color) 68%, transparent);729+ stroke-dasharray: 7 5;730+ stroke-width: 1.8;731+ vector-effect: non-scaling-stroke;732+}733+734+.zone-shape.is-subregion {735+ fill: color-mix(in srgb, var(--zone-color) 4%, transparent);736+ stroke-dasharray: 3 4;737+ stroke-opacity: 0.68;738+}739+740+.zone-label {741+ position: absolute;742+ padding: 5px 8px;743+ border: 1px solid color-mix(in srgb, var(--zone-color) 45%, transparent);744+ border-radius: 4px;745+ background: rgba(11, 14, 10, 0.72);746+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.38);747+ color: #f1f4e7;748+ font-family: "Arial Narrow", "Roboto Condensed", sans-serif;749+ font-size: var(--zone-overlay-size);750+ font-weight: 800;751+ letter-spacing: 0.13em;752+ line-height: 1;753+ text-shadow: 0 1px 3px #000;754+ text-transform: uppercase;755+ transform: translate(-50%, -50%) scale(var(--viewport-overlay-scale));756+ white-space: nowrap;757+}758+759+.zone-label.is-subregion {760+ color: #c7ccbd;761+ font-size: 12px;762+ font-weight: 700;763+}764+765+.zone-label[hidden] {766+ display: none;767+}768+769+.pin {770+ position: absolute;771+ z-index: 3;772+ display: grid;773+ width: var(--pin-overlay-size);774+ height: var(--pin-overlay-size);775+ padding: 0;776+ place-items: center;777+ border: 0;778+ border-radius: 50%;779+ background: transparent;780+ color: #fff;781+ cursor: pointer;782+ font-size: 8px;783+ font-weight: 900;784+ text-transform: uppercase;785+ transform: translate(-50%, -75%) scale(var(--viewport-overlay-scale));786+ transform-origin: 50% 75%;787+}788+789+.pin:not(.has-source-icon) {790+ border: 1px solid rgba(255, 255, 255, 0.75);791+ background: var(--pin-color);792+ box-shadow: 0 2px 7px rgba(0, 0, 0, 0.65);793+}794+795+.pin.has-source-icon::after {796+ width: 21px;797+ height: 21px;798+}799+800+.pin:hover,801+.pin:focus-visible,802+.pin.is-selected {803+ z-index: 6;804+ outline: none;805+ background: rgba(10, 13, 9, 0.72);806+ box-shadow:807+ 0 0 0 4px color-mix(in srgb, var(--pin-color) 28%, transparent),808+ 0 3px 9px rgba(0, 0, 0, 0.65);809+ transform: translate(-50%, -75%) scale(calc(var(--viewport-overlay-scale) * 1.22));810+}811+812+.pin[hidden] {813+ display: none;814+}815+816+.map-label {817+ position: absolute;818+ z-index: 4;819+ max-width: 180px;820+ padding: 3px 6px;821+ border: 0;822+ border-radius: 3px;823+ background: rgba(10, 13, 9, 0.5);824+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.48);825+ color: #f3f5e9;826+ font-family: "Arial Narrow", "Roboto Condensed", sans-serif;827+ font-size: var(--text-overlay-size);828+ font-weight: 900;829+ letter-spacing: 0.14em;830+ line-height: 1.15;831+ text-align: center;832+ text-shadow: 0 1px 2px #000, 0 0 7px #000;833+ transform: translate(-50%, -50%) scale(var(--viewport-overlay-scale));834+ white-space: nowrap;835+ cursor: pointer;836+}837+838+.map-label:hover,839+.map-label:focus-visible,840+.map-label.is-selected {841+ outline: 1px solid var(--accent);842+ background: rgba(17, 21, 14, 0.86);843+ color: var(--accent);844+}845+846+.map-label[hidden] {847+ display: none;848+}849+850+.map-loading {851+ position: absolute;852+ z-index: 10;853+ top: 50%;854+ left: 50%;855+ display: grid;856+ min-width: 235px;857+ padding: 24px;858+ place-items: center;859+ border: 1px solid var(--line);860+ border-radius: 12px;861+ background: rgba(20, 23, 19, 0.92);862+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);863+ text-align: center;864+ transform: translate(-50%, -50%);865+ transition: opacity 180ms ease;866+}867+868+.map-loading[hidden] {869+ display: none;870+}871+872+.map-loading strong {873+ margin-top: 12px;874+ font-size: 13px;875+}876+877+.map-loading small {878+ margin-top: 5px;879+ color: var(--muted);880+ font-size: 10px;881+}882+883+.spinner {884+ width: 25px;885+ height: 25px;886+ border: 2px solid rgba(214, 243, 107, 0.18);887+ border-top-color: var(--accent);888+ border-radius: 50%;889+ animation: spin 0.75s linear infinite;890+}891+892+@keyframes spin {893+ to { transform: rotate(360deg); }894+}895+896+.zoom-controls {897+ position: absolute;898+ z-index: 7;899+ top: 17px;900+ right: 17px;901+ display: grid;902+ overflow: hidden;903+ width: 38px;904+ border: 1px solid var(--line-strong);905+ border-radius: 8px;906+ background: rgba(22, 25, 21, 0.9);907+ box-shadow: 0 6px 25px rgba(0, 0, 0, 0.22);908+}909+910+.zoom-controls button {911+ display: grid;912+ width: 38px;913+ height: 37px;914+ padding: 0;915+ place-items: center;916+ border: 0;917+ border-bottom: 1px solid var(--line);918+ background: transparent;919+ font-size: 20px;920+ cursor: pointer;921+}922+923+.zoom-controls button:last-child {924+ border-bottom: 0;925+}926+927+.zoom-controls button:hover {928+ background: rgba(255, 255, 255, 0.06);929+ color: var(--accent);930+}931+932+.zoom-controls svg {933+ width: 16px;934+ fill: none;935+ stroke: currentColor;936+ stroke-linecap: round;937+ stroke-linejoin: round;938+ stroke-width: 1.7;939+}940+941+.map-hint {942+ position: absolute;943+ z-index: 5;944+ bottom: 17px;945+ left: 50%;946+ display: flex;947+ align-items: center;948+ gap: 8px;949+ padding: 7px 10px;950+ border: 1px solid var(--line);951+ border-radius: 6px;952+ background: rgba(15, 17, 14, 0.76);953+ color: rgba(238, 240, 228, 0.62);954+ font-size: 9px;955+ letter-spacing: 0.08em;956+ text-transform: uppercase;957+ transform: translateX(-50%);958+ backdrop-filter: blur(8px);959+}960+961+.map-hint i {962+ width: 2px;963+ height: 2px;964+ border-radius: 50%;965+ background: currentColor;966+}967+968+.pin-detail {969+ position: absolute;970+ z-index: 9;971+ right: 68px;972+ bottom: 17px;973+ width: min(360px, calc(100% - 100px));974+ max-height: min(500px, calc(100% - 40px));975+ overflow: auto;976+ padding: 20px;977+ border: 1px solid var(--line-strong);978+ border-radius: 11px;979+ background: rgba(21, 24, 20, 0.96);980+ box-shadow: 0 18px 55px rgba(0, 0, 0, 0.45);981+ -webkit-user-select: text;982+ user-select: text;983+ backdrop-filter: blur(14px);984+}985+986+.pin-detail[hidden] {987+ display: none;988+}989+990+.detail-close {991+ position: absolute;992+ top: 10px;993+ right: 11px;994+ width: 29px;995+ height: 29px;996+ padding: 0;997+ border: 0;998+ border-radius: 6px;999+ background: rgba(255, 255, 255, 0.04);1000+ color: var(--muted);1001+ font-size: 21px;1002+ cursor: pointer;1003+}1004+1005+.detail-close:hover {1006+ color: var(--text);1007+}1008+1009+.detail-category {1010+ display: flex;1011+ align-items: center;1012+ gap: 7px;1013+ padding-right: 30px;1014+ color: var(--muted);1015+ font-size: 9px;1016+ font-weight: 800;1017+ letter-spacing: 0.12em;1018+ text-transform: uppercase;1019+}1020+1021+.detail-category #detail-dot {1022+ display: grid;1023+ width: 20px;1024+ height: 20px;1025+ flex: 0 0 20px;1026+ place-items: center;1027+ border-radius: 50%;1028+ background: var(--pin-color);1029+ color: #fff;1030+ font-size: 7px;1031+ letter-spacing: 0;1032+}1033+1034+#detail-dot.has-source-icon::before {1035+ width: 16px;1036+ height: 16px;1037+}1038+1039+.pin-detail h2 {1040+ margin: 10px 0 11px;1041+ font-family: "Arial Narrow", "Roboto Condensed", sans-serif;1042+ font-size: 22px;1043+ line-height: 1.12;1044+}1045+1046+.pin-detail > p {1047+ margin: 0;1048+ color: #b9bdb0;1049+ font-size: 12px;1050+ line-height: 1.55;1051+ white-space: pre-line;1052+}1053+1054+.pin-detail dl {1055+ display: grid;1056+ grid-template-columns: 1fr 1fr;1057+ gap: 8px;1058+ margin: 17px 0 0;1059+ padding-top: 13px;1060+ border-top: 1px solid var(--line);1061+}1062+1063+.pin-detail dl div {1064+ min-width: 0;1065+}1066+1067+.pin-detail dt {1068+ color: #71766a;1069+ font-size: 8px;1070+ font-weight: 800;1071+ letter-spacing: 0.1em;1072+ text-transform: uppercase;1073+}1074+1075+.pin-detail dd {1076+ overflow: hidden;1077+ margin: 4px 0 0;1078+ color: #c6cabd;1079+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;1080+ font-size: 9px;1081+ text-overflow: ellipsis;1082+ white-space: nowrap;1083+}1084+1085+.fatal-error {1086+ position: fixed;1087+ z-index: 100;1088+ inset: 50% auto auto 50%;1089+ width: min(460px, calc(100% - 40px));1090+ padding: 24px;1091+ border: 1px solid #7e3f3f;1092+ border-radius: 12px;1093+ background: #261919;1094+ box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5);1095+ transform: translate(-50%, -50%);1096+ -webkit-user-select: text;1097+ user-select: text;1098+}1099+1100+.fatal-error[hidden] {1101+ display: none;1102+}1103+1104+.fatal-error p {1105+ margin: 8px 0 0;1106+ color: #c8aaaa;1107+ font-size: 13px;1108+}1109+1110+@media (max-width: 980px) {1111+ :root {1112+ --sidebar: 300px;1113+ }1114+1115+ .map-pickers label > span {1116+ display: none;1117+ }1118+1119+ .map-pickers select {1120+ max-width: 190px;1121+ }1122+}1123+1124+@media (max-width: 760px) {1125+ :root {1126+ --topbar: 64px;1127+ }1128+1129+ .app-shell {1130+ grid-template-columns: 1fr;1131+ }1132+1133+ .topbar {1134+ grid-template-columns: auto minmax(0, 1fr) auto;1135+ }1136+1137+ .brand {1138+ padding: 0 15px;1139+ border-right: 0;1140+ }1141+1142+ .brand small {1143+ display: none;1144+ }1145+1146+ .map-pickers {1147+ justify-content: center;1148+ padding: 0 7px;1149+ }1150+1151+ .map-pickers select {1152+ max-width: 135px;1153+ height: 35px;1154+ padding-left: 8px;1155+ font-size: 11px;1156+ }1157+1158+ .mobile-legend {1159+ display: grid;1160+ }1161+1162+ .sidebar {1163+ position: fixed;1164+ z-index: 30;1165+ top: var(--topbar);1166+ bottom: 0;1167+ left: 0;1168+ width: min(330px, calc(100% - 42px));1169+ box-shadow: 18px 0 50px rgba(0, 0, 0, 0.45);1170+ transform: translateX(-102%);1171+ transition: transform 180ms ease;1172+ }1173+1174+ .sidebar.is-open {1175+ transform: translateX(0);1176+ }1177+1178+ .map-panel {1179+ grid-column: 1;1180+ }1181+1182+ .pin-detail {1183+ right: 14px;1184+ bottom: 55px;1185+ width: calc(100% - 28px);1186+ }1187+1188+}@@ -0,0 +1,872 @@
1+(() => {2+ "use strict";3+4+ const $ = (selector) => document.querySelector(selector);5+ const elements = {6+ game: $("#game-select"),7+ map: $("#map-select"),8+ variant: $("#variant-select"),9+ variantField: $("#variant-field"),10+ title: $("#map-title"),11+ meta: $("#map-meta"),12+ legend: $("#legend"),13+ search: $("#pin-search"),14+ searchResults: $("#search-results"),15+ visibleCount: $("#visible-count"),16+ viewport: $("#map"),17+ world: $("#world"),18+ image: $("#map-image"),19+ zones: $("#zones"),20+ zoneLabels: $("#zone-labels"),21+ overlayControls: $("#overlay-controls"),22+ textOverlayControls: $("#text-overlay-controls"),23+ zoneControl: $("#zone-control"),24+ zoneToggle: $("#zone-toggle"),25+ zoneCount: $("#zone-count"),26+ markers: $("#markers"),27+ loading: $("#map-loading"),28+ detail: $("#pin-detail"),29+ detailTitle: $("#detail-title"),30+ detailCategory: $("#detail-category"),31+ detailDescription: $("#detail-description"),32+ detailID: $("#detail-id"),33+ detailCoordinates: $("#detail-coordinates"),34+ detailDot: $("#detail-dot"),35+ sidebar: $("#sidebar"),36+ mobileLegend: $("#mobile-legend"),37+ fatal: $("#fatal-error"),38+ fatalMessage: $("#fatal-message"),39+ };40+41+ const state = {42+ catalog: null,43+ game: null,44+ map: null,45+ variant: null,46+ hiddenCategories: new Set(),47+ pins: [],48+ markerPins: [],49+ textPins: [],50+ eligibleMarkerPins: [],51+ eligibleTextPins: [],52+ pinByID: new Map(),53+ selectedPin: null,54+ zoneLabels: [],55+ zonesVisible: true,56+ annotationGrid: new Map(),57+ eligibleLocations: 0,58+ renderedMarkers: 0,59+ renderedText: 0,60+ view: { x: 0, y: 0, scale: 0.1, fitScale: 0.1, minScale: 0.05, maxScale: 4 },61+ drag: null,62+ search: "",63+ };64+65+ const palette = [66+ "#d6f36b", "#72d5f4", "#ff9e64", "#df83ff", "#62e6ae",67+ "#ff6f91", "#f4d35e", "#8aa9ff", "#e7a56d", "#83d483",68+ ];69+70+ async function start() {71+ bindEvents();72+ try {73+ const response = await fetch("/static/catalog.json");74+ if (!response.ok) {75+ throw new Error(`catalog request returned ${response.status}`);76+ }77+ state.catalog = await response.json();78+ if (!state.catalog.games.length) {79+ throw new Error("the embedded catalog contains no maps");80+ }81+ populateSelect(elements.game, state.catalog.games, "title");82+ selectGame(state.catalog.games[0].slug);83+ } catch (error) {84+ elements.loading.hidden = true;85+ elements.fatalMessage.textContent = error instanceof Error ? error.message : String(error);86+ elements.fatal.hidden = false;87+ }88+ }89+90+ function bindEvents() {91+ elements.game.addEventListener("change", () => selectGame(elements.game.value));92+ elements.map.addEventListener("change", () => selectMap(Number(elements.map.value)));93+ elements.variant.addEventListener("change", () => selectVariant(Number(elements.variant.value)));94+95+ const changeCategoryVisibility = (event) => {96+ const checkbox = event.target.closest("[data-category]");97+ if (!checkbox) return;98+ const categoryID = Number(checkbox.dataset.category);99+ if (checkbox.checked) state.hiddenCategories.delete(categoryID);100+ else state.hiddenCategories.add(categoryID);101+ applyPinFilters();102+ updateGroupHeadings();103+ };104+ elements.legend.addEventListener("change", changeCategoryVisibility);105+ elements.textOverlayControls.addEventListener("change", changeCategoryVisibility);106+107+ elements.legend.addEventListener("click", (event) => {108+ const button = event.target.closest("[data-group]");109+ if (!button) return;110+ const group = state.map.groups.find((item) => item.id === Number(button.dataset.group));111+ if (!group) return;112+ const categories = markerCategories(group);113+ const hasVisible = categories.some((category) => !state.hiddenCategories.has(category.id));114+ for (const category of categories) {115+ if (hasVisible) state.hiddenCategories.add(category.id);116+ else state.hiddenCategories.delete(category.id);117+ }118+ syncLegendCheckboxes();119+ applyPinFilters();120+ updateGroupHeadings();121+ });122+123+ $("#show-all").addEventListener("click", () => setAllCategories(true));124+ $("#hide-all").addEventListener("click", () => setAllCategories(false));125+ elements.zoneToggle.addEventListener("change", () => {126+ state.zonesVisible = elements.zoneToggle.checked;127+ layoutAnnotations();128+ });129+ elements.search.addEventListener("input", () => {130+ state.search = elements.search.value.trim().toLocaleLowerCase();131+ applyPinFilters();132+ renderSearchResults();133+ });134+135+ elements.searchResults.addEventListener("click", (event) => {136+ const result = event.target.closest("[data-location]");137+ if (!result) return;138+ const pin = state.pinByID.get(Number(result.dataset.location));139+ if (pin) revealPin(pin);140+ });141+142+ elements.markers.addEventListener("click", (event) => {143+ const marker = event.target.closest("[data-location]");144+ if (!marker) return;145+ event.stopPropagation();146+ const pin = state.pinByID.get(Number(marker.dataset.location));147+ if (pin) showPin(pin);148+ });149+150+ $("#close-detail").addEventListener("click", closeDetail);151+ $("#zoom-in").addEventListener("click", () => zoomAt(1.35));152+ $("#zoom-out").addEventListener("click", () => zoomAt(1 / 1.35));153+ $("#reset-view").addEventListener("click", fitMap);154+155+ elements.viewport.addEventListener("wheel", (event) => {156+ event.preventDefault();157+ const rect = elements.viewport.getBoundingClientRect();158+ zoomAt(Math.exp(-event.deltaY * 0.0012), event.clientX - rect.left, event.clientY - rect.top);159+ }, { passive: false });160+161+ elements.viewport.addEventListener("pointerdown", beginDrag);162+ elements.viewport.addEventListener("pointermove", moveDrag);163+ elements.viewport.addEventListener("pointerup", endDrag);164+ elements.viewport.addEventListener("pointercancel", endDrag);165+ elements.viewport.addEventListener("dblclick", (event) => {166+ if (event.target.closest(".pin, .map-label")) return;167+ const rect = elements.viewport.getBoundingClientRect();168+ zoomAt(1.6, event.clientX - rect.left, event.clientY - rect.top);169+ });170+171+ elements.viewport.addEventListener("keydown", (event) => {172+ if (event.key === "+" || event.key === "=") zoomAt(1.3);173+ else if (event.key === "-") zoomAt(1 / 1.3);174+ else if (event.key === "0") fitMap();175+ else if (event.key === "Escape") closeDetail();176+ else return;177+ event.preventDefault();178+ });179+180+ window.addEventListener("keydown", (event) => {181+ if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "k") {182+ event.preventDefault();183+ elements.search.focus();184+ elements.search.select();185+ }186+ });187+ window.addEventListener("resize", () => constrainView());188+189+ elements.mobileLegend.addEventListener("click", () => {190+ const open = elements.sidebar.classList.toggle("is-open");191+ elements.mobileLegend.setAttribute("aria-expanded", String(open));192+ });193+ }194+195+ function selectGame(slug) {196+ state.game = state.catalog.games.find((game) => game.slug === slug) || state.catalog.games[0];197+ elements.game.value = state.game.slug;198+ populateSelect(elements.map, state.game.maps, "title", "id");199+ selectMap(state.game.maps[0].id);200+ }201+202+ function selectMap(id) {203+ state.map = state.game.maps.find((map) => map.id === id) || state.game.maps[0];204+ elements.map.value = String(state.map.id);205+ populateSelect(elements.variant, state.map.variants, "name");206+ elements.variantField.hidden = state.map.variants.length < 2;207+ state.hiddenCategories.clear();208+ for (const group of state.map.groups) {209+ for (const category of group.categories) {210+ if (!category.visible) state.hiddenCategories.add(category.id);211+ }212+ }213+ state.search = "";214+ elements.search.value = "";215+ elements.searchResults.hidden = true;216+ closeDetail();217+ renderLegend();218+ renderZones();219+ buildPins();220+ selectVariant(0, true);221+ elements.title.textContent = state.map.title;222+ elements.meta.textContent = `${state.game.title} · ${formatNumber(state.map.pinCount)} archived locations`;223+ elements.sidebar.classList.remove("is-open");224+ elements.mobileLegend.setAttribute("aria-expanded", "false");225+ }226+227+ function selectVariant(index, resetView = false) {228+ state.variant = state.map.variants[index] || state.map.variants[0];229+ elements.variant.value = String(state.map.variants.indexOf(state.variant));230+ const bounds = activeBounds();231+ const size = state.catalog.tileGrid.size;232+ elements.image.style.clipPath = `inset(${bounds.y}px ${size - bounds.x - bounds.width}px ${size - bounds.y - bounds.height}px ${bounds.x}px)`;233+ elements.loading.hidden = false;234+ elements.image.alt = `${state.game.title} — ${state.map.title}, ${state.variant.name} map`;235+ elements.image.onload = () => {236+ elements.loading.hidden = true;237+ if (resetView) fitMap();238+ else applyTransform();239+ };240+ elements.image.onerror = () => {241+ elements.loading.hidden = true;242+ elements.fatalMessage.textContent = `The embedded map image “${state.variant.image}” could not be decoded.`;243+ elements.fatal.hidden = false;244+ };245+ elements.image.src = `/static/maps/${encodeURIComponent(state.variant.image)}`;246+ if (elements.image.complete && elements.image.naturalWidth) {247+ elements.loading.hidden = true;248+ if (resetView) fitMap();249+ else applyTransform();250+ }251+ }252+253+ function populateSelect(select, items, labelKey, valueKey) {254+ select.replaceChildren();255+ items.forEach((item, index) => {256+ const option = document.createElement("option");257+ option.value = valueKey ? String(item[valueKey]) : (item.slug || String(index));258+ option.textContent = item[labelKey];259+ select.append(option);260+ });261+ }262+263+ function renderLegend() {264+ const fragment = document.createDocumentFragment();265+ const overlayFragment = document.createDocumentFragment();266+ let textOverlayCount = 0;267+ for (const group of state.map.groups) {268+ for (const category of textCategories(group)) {269+ overlayFragment.append(categoryToggle(category, true));270+ textOverlayCount++;271+ }272+273+ const categories = markerCategories(group);274+ if (!categories.length) continue;275+ const section = document.createElement("section");276+ section.className = "legend-group";277+ section.dataset.groupSection = String(group.id);278+279+ const heading = document.createElement("button");280+ heading.type = "button";281+ heading.className = "group-heading";282+ heading.dataset.group = String(group.id);283+ heading.title = `Toggle every category in ${group.title}`;284+ const title = document.createElement("span");285+ title.textContent = group.title;286+ const count = document.createElement("span");287+ count.dataset.groupCount = String(group.id);288+ heading.append(title, count);289+ section.append(heading);290+291+ for (const category of categories) section.append(categoryToggle(category, false));292+ fragment.append(section);293+ }294+ elements.textOverlayControls.replaceChildren(overlayFragment);295+ elements.legend.replaceChildren(fragment);296+ elements.overlayControls.hidden = !textOverlayCount && !(state.map.zones || []).length;297+ updateGroupHeadings();298+ }299+300+ function categoryToggle(category, promoted) {301+ const row = document.createElement("label");302+ row.className = promoted ? "overlay-control" : "category-row";303+ applyCategoryVisual(row, category);304+305+ const checkbox = document.createElement("input");306+ checkbox.type = "checkbox";307+ checkbox.dataset.category = String(category.id);308+ checkbox.checked = !state.hiddenCategories.has(category.id);309+310+ const check = document.createElement("span");311+ check.className = "check";312+ check.setAttribute("aria-hidden", "true");313+314+ const icon = document.createElement("span");315+ icon.className = promoted ? "text-symbol" : "category-icon";316+ if (promoted) icon.textContent = "Tt";317+ else applyCategoryGlyph(icon, category, initials(category.title));318+ icon.title = promoted ? "Text overlay" : (category.icon || category.title);319+320+ const name = document.createElement("span");321+ name.className = "category-name";322+ name.textContent = promoted ? `${category.title} titles` : category.title;323+324+ const locations = document.createElement("span");325+ locations.className = promoted ? "overlay-count" : "category-count";326+ locations.textContent = formatNumber(category.locations.length);327+328+ row.append(checkbox, check, icon, name, locations);329+ return row;330+ }331+332+ function textCategories(group) {333+ return group.categories.filter((category) => category.displayType === "text");334+ }335+336+ function markerCategories(group) {337+ return group.categories.filter((category) => category.displayType !== "text");338+ }339+340+ function buildPins() {341+ const fragment = document.createDocumentFragment();342+ state.pins = [];343+ state.markerPins = [];344+ state.textPins = [];345+ state.pinByID.clear();346+ for (const group of state.map.groups) {347+ for (const category of group.categories) {348+ for (const location of category.locations) {349+ const point = project(location.lat, location.lng);350+ const marker = document.createElement("button");351+ marker.type = "button";352+ marker.className = category.displayType === "text" ? "map-label" : "pin";353+ marker.dataset.location = String(location.id);354+ marker.style.left = `${point.x}px`;355+ marker.style.top = `${point.y}px`;356+ applyCategoryVisual(marker, category);357+ marker.setAttribute("aria-label", `${location.title}, ${category.title}`);358+ marker.title = location.title;359+ if (category.displayType === "text") marker.textContent = location.title;360+ else applyCategoryGlyph(marker, category, initials(category.title));361+362+ const pin = {363+ location,364+ category,365+ group,366+ point,367+ marker,368+ detailRatio: category.displayType === "text" ? textDetailRatio(category) : 1,369+ filteredHidden: false,370+ };371+ state.pins.push(pin);372+ if (category.displayType === "text") state.textPins.push(pin);373+ else state.markerPins.push(pin);374+ state.pinByID.set(location.id, pin);375+ fragment.append(marker);376+ }377+ }378+ }379+ state.markerPins.sort((a, b) =>380+ a.category.locations.length - b.category.locations.length ||381+ stableRank(a.location.id) - stableRank(b.location.id));382+ elements.markers.replaceChildren(fragment);383+ applyPinFilters();384+ }385+386+ function renderZones() {387+ const zones = state.map.zones || [];388+ state.zonesVisible = true;389+ elements.zoneToggle.checked = true;390+ elements.zoneControl.hidden = zones.length === 0;391+ elements.overlayControls.hidden = !zones.length &&392+ !state.map.groups.some((group) => textCategories(group).length);393+ elements.zoneCount.textContent = formatNumber(zones.length);394+ elements.zones.replaceChildren();395+ elements.zoneLabels.replaceChildren();396+ state.zoneLabels = [];397+ if (!zones.length) return;398+399+ const paths = document.createDocumentFragment();400+ const labels = document.createDocumentFragment();401+ for (const zone of zones) {402+ const projected = projectZone(zone);403+ if (!projected.path) continue;404+ const color = colorFor(zone.id);405+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");406+ path.setAttribute("d", projected.path);407+ path.classList.add("zone-shape");408+ if (zone.parentRegionId != null) path.classList.add("is-subregion");409+ path.style.setProperty("--zone-color", color);410+ paths.append(path);411+412+ const center = zone.center ? project(zone.center.lat, zone.center.lng) : projected.center;413+ const label = document.createElement("span");414+ label.className = "zone-label";415+ if (zone.parentRegionId != null) label.classList.add("is-subregion");416+ label.style.left = `${center.x}px`;417+ label.style.top = `${center.y}px`;418+ label.style.setProperty("--zone-color", color);419+ label.textContent = zone.title;420+ labels.append(label);421+ state.zoneLabels.push({422+ element: label,423+ child: zone.parentRegionId != null,424+ width: projected.bounds.maxX - projected.bounds.minX,425+ height: projected.bounds.maxY - projected.bounds.minY,426+ });427+ }428+ elements.zones.append(paths);429+ elements.zoneLabels.append(labels);430+ layoutAnnotations();431+ }432+433+ function projectZone(zone) {434+ let path = "";435+ const points = [];436+ for (const feature of zone.features || []) {437+ const polygons = feature.type === "MultiPolygon" ? feature.coordinates : [feature.coordinates];438+ for (const polygon of polygons) {439+ for (const ring of polygon || []) {440+ const projectedRing = ring.map(([longitude, latitude]) => project(latitude, longitude));441+ if (!projectedRing.length) continue;442+ path += `M${projectedRing.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join("L")}Z`;443+ points.push(...projectedRing);444+ }445+ }446+ }447+ if (!points.length) {448+ return {449+ path: "",450+ center: { x: 0, y: 0 },451+ bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0 },452+ };453+ }454+ const bounds = points.reduce((result, point) => ({455+ minX: Math.min(result.minX, point.x),456+ maxX: Math.max(result.maxX, point.x),457+ minY: Math.min(result.minY, point.y),458+ maxY: Math.max(result.maxY, point.y),459+ }), { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });460+ return {461+ path,462+ center: { x: (bounds.minX + bounds.maxX) / 2, y: (bounds.minY + bounds.maxY) / 2 },463+ bounds,464+ };465+ }466+467+ function project(latitude, longitude) {468+ const grid = state.catalog.tileGrid;469+ const worldTiles = 2 ** grid.zoom;470+ const xTile = ((longitude + 180) / 360) * worldTiles;471+ const latitudeRadians = latitude * Math.PI / 180;472+ const yTile = (1 - Math.asinh(Math.tan(latitudeRadians)) / Math.PI) / 2 * worldTiles;473+ return {474+ x: (xTile - grid.firstTile) * grid.tileSize,475+ y: (yTile - grid.firstTile) * grid.tileSize,476+ };477+ }478+479+ function applyPinFilters() {480+ for (const pin of state.pins) {481+ const categoryHidden = state.hiddenCategories.has(pin.category.id);482+ const searchHidden = state.search && !pin.location.title.toLocaleLowerCase().includes(state.search);483+ pin.filteredHidden = Boolean(categoryHidden || searchHidden);484+ pin.marker.hidden = pin.filteredHidden;485+ }486+ state.eligibleLocations = state.pins.filter((pin) => !pin.filteredHidden).length;487+ state.eligibleMarkerPins = state.markerPins.filter((pin) => !pin.filteredHidden);488+ state.eligibleTextPins = state.textPins.filter((pin) => !pin.filteredHidden);489+ layoutAnnotations();490+ if (state.selectedPin?.filteredHidden) closeDetail();491+ }492+493+ function renderSearchResults() {494+ if (!state.search) {495+ elements.searchResults.hidden = true;496+ elements.searchResults.replaceChildren();497+ return;498+ }499+ const matches = state.pins500+ .filter((pin) => !state.hiddenCategories.has(pin.category.id) &&501+ pin.location.title.toLocaleLowerCase().includes(state.search))502+ .slice(0, 20);503+ const fragment = document.createDocumentFragment();504+ if (!matches.length) {505+ const empty = document.createElement("p");506+ empty.className = "search-empty";507+ empty.textContent = "No visible locations match.";508+ fragment.append(empty);509+ }510+ for (const pin of matches) {511+ const button = document.createElement("button");512+ button.type = "button";513+ button.className = "search-result";514+ button.dataset.location = String(pin.location.id);515+ applyCategoryVisual(button, pin.category);516+ const dot = document.createElement("span");517+ dot.className = "result-dot";518+ applyCategoryGlyph(dot, pin.category, initials(pin.category.title));519+ const copy = document.createElement("span");520+ const title = document.createElement("strong");521+ title.textContent = pin.location.title;522+ const category = document.createElement("small");523+ category.textContent = `${pin.category.title} · ${pin.group.title}`;524+ copy.append(title, category);525+ button.append(dot, copy);526+ fragment.append(button);527+ }528+ elements.searchResults.replaceChildren(fragment);529+ elements.searchResults.hidden = false;530+ }531+532+ function setAllCategories(visible) {533+ state.hiddenCategories.clear();534+ if (!visible) {535+ for (const group of state.map.groups) {536+ for (const category of group.categories) state.hiddenCategories.add(category.id);537+ }538+ }539+ syncLegendCheckboxes();540+ applyPinFilters();541+ renderSearchResults();542+ updateGroupHeadings();543+ }544+545+ function syncLegendCheckboxes() {546+ for (const checkbox of document.querySelectorAll("[data-category]")) {547+ checkbox.checked = !state.hiddenCategories.has(Number(checkbox.dataset.category));548+ }549+ }550+551+ function updateGroupHeadings() {552+ if (!state.map) return;553+ for (const group of state.map.groups) {554+ const categories = markerCategories(group);555+ const visible = categories.filter((category) => !state.hiddenCategories.has(category.id)).length;556+ const count = elements.legend.querySelector(`[data-group-count="${group.id}"]`);557+ if (count) count.textContent = `${visible}/${categories.length}`;558+ }559+ }560+561+ function revealPin(pin) {562+ state.hiddenCategories.delete(pin.category.id);563+ state.search = "";564+ elements.search.value = "";565+ elements.searchResults.hidden = true;566+ syncLegendCheckboxes();567+ applyPinFilters();568+ updateGroupHeadings();569+ showPin(pin, true);570+ }571+572+ function showPin(pin, focus = false) {573+ if (state.selectedPin) state.selectedPin.marker.classList.remove("is-selected");574+ state.selectedPin = pin;575+ layoutAnnotations();576+ pin.marker.classList.add("is-selected");577+ elements.detailTitle.textContent = pin.location.title;578+ elements.detailCategory.textContent = `${pin.group.title} / ${pin.category.title}`;579+ elements.detailDescription.textContent = cleanDescription(pin.location.description) || "No description is included in the archive.";580+ elements.detailID.textContent = String(pin.location.id);581+ elements.detailCoordinates.textContent = `${pin.location.lat.toFixed(6)}, ${pin.location.lng.toFixed(6)}`;582+ applyCategoryVisual(elements.detailDot, pin.category);583+ applyCategoryGlyph(elements.detailDot, pin.category, initials(pin.category.title));584+ elements.detail.hidden = false;585+586+ if (focus) {587+ const nextScale = Math.max(state.view.scale, 0.85);588+ state.view.scale = Math.min(nextScale, state.view.maxScale);589+ state.view.x = elements.viewport.clientWidth / 2 - pin.point.x * state.view.scale;590+ state.view.y = elements.viewport.clientHeight / 2 - pin.point.y * state.view.scale;591+ constrainView();592+ }593+ }594+595+ function closeDetail() {596+ if (state.selectedPin) state.selectedPin.marker.classList.remove("is-selected");597+ state.selectedPin = null;598+ elements.detail.hidden = true;599+ }600+601+ function fitMap() {602+ const bounds = activeBounds();603+ const width = elements.viewport.clientWidth;604+ const height = elements.viewport.clientHeight;605+ if (!width || !height) return;606+ const scale = Math.min(width / bounds.width, height / bounds.height);607+ state.view.fitScale = scale;608+ state.view.minScale = scale * 0.72;609+ state.view.scale = scale;610+ state.view.x = (width - bounds.width * scale) / 2 - bounds.x * scale;611+ state.view.y = (height - bounds.height * scale) / 2 - bounds.y * scale;612+ applyTransform();613+ }614+615+ function zoomAt(factor, screenX = elements.viewport.clientWidth / 2, screenY = elements.viewport.clientHeight / 2) {616+ const previous = state.view.scale;617+ const next = clamp(previous * factor, state.view.minScale, state.view.maxScale);618+ const worldX = (screenX - state.view.x) / previous;619+ const worldY = (screenY - state.view.y) / previous;620+ state.view.scale = next;621+ state.view.x = screenX - worldX * next;622+ state.view.y = screenY - worldY * next;623+ constrainView();624+ }625+626+ function beginDrag(event) {627+ if (event.button !== 0 || event.target.closest(".pin, .map-label, .zoom-controls, .pin-detail")) return;628+ elements.viewport.setPointerCapture(event.pointerId);629+ state.drag = {630+ id: event.pointerId,631+ startX: event.clientX,632+ startY: event.clientY,633+ viewX: state.view.x,634+ viewY: state.view.y,635+ };636+ elements.viewport.classList.add("is-dragging");637+ }638+639+ function moveDrag(event) {640+ if (!state.drag || state.drag.id !== event.pointerId) return;641+ state.view.x = state.drag.viewX + event.clientX - state.drag.startX;642+ state.view.y = state.drag.viewY + event.clientY - state.drag.startY;643+ constrainView();644+ }645+646+ function endDrag(event) {647+ if (!state.drag || state.drag.id !== event.pointerId) return;648+ state.drag = null;649+ elements.viewport.classList.remove("is-dragging");650+ }651+652+ function constrainView() {653+ const bounds = activeBounds();654+ const scaledWidth = bounds.width * state.view.scale;655+ const scaledHeight = bounds.height * state.view.scale;656+ const width = elements.viewport.clientWidth;657+ const height = elements.viewport.clientHeight;658+ const marginX = Math.min(width * 0.38, scaledWidth * 0.38);659+ const marginY = Math.min(height * 0.38, scaledHeight * 0.38);660+ const offsetX = bounds.x * state.view.scale;661+ const offsetY = bounds.y * state.view.scale;662+ const contentX = state.view.x + offsetX;663+ const contentY = state.view.y + offsetY;664+665+ if (scaledWidth <= width) state.view.x = (width - scaledWidth) / 2 - offsetX;666+ else state.view.x = clamp(contentX, width - scaledWidth - marginX, marginX) - offsetX;667+ if (scaledHeight <= height) state.view.y = (height - scaledHeight) / 2 - offsetY;668+ else state.view.y = clamp(contentY, height - scaledHeight - marginY, marginY) - offsetY;669+ applyTransform();670+ }671+672+ function activeBounds() {673+ const size = state.catalog?.tileGrid.size || 8192;674+ return state.variant?.bounds || { x: 0, y: 0, width: size, height: size };675+ }676+677+ function applyTransform() {678+ const { x, y, scale } = state.view;679+ elements.world.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;680+ // Annotations live in map coordinates but counter-scale into fixed CSS-pixel overlays.681+ elements.world.style.setProperty("--viewport-overlay-scale", String(1 / scale));682+ layoutAnnotations();683+ }684+685+ function layoutAnnotations() {686+ state.annotationGrid.clear();687+ updateTextLabelVisibility();688+ updateZoneVisibility();689+ updateMarkerVisibility();690+ updateVisibleCount();691+ }692+693+ function updateTextLabelVisibility() {694+ const fitScale = state.view.fitScale || state.view.scale;695+ const candidates = [...state.eligibleTextPins]696+ .sort((a, b) =>697+ a.category.locations.length - b.category.locations.length ||698+ stableRank(a.location.id) - stableRank(b.location.id));699+ let rendered = 0;700+ for (const pin of state.textPins) pin.marker.hidden = true;701+ for (const pin of candidates) {702+ const lacksDetail = state.view.scale < fitScale * pin.detailRatio;703+ const selected = pin === state.selectedPin;704+ if (lacksDetail && !selected) continue;705+ const screen = screenPoint(pin.point);706+ const width = Math.min(196, pin.location.title.length * 8.4 + 18);707+ const shown = reserveAnnotation(screen.x, screen.y, width, 30, selected);708+ pin.marker.hidden = !shown;709+ if (shown) rendered++;710+ }711+ state.renderedText = rendered;712+ }713+714+ function updateZoneVisibility() {715+ elements.zones.hidden = !state.zonesVisible;716+ elements.zoneLabels.hidden = !state.zonesVisible;717+ const showSubregions = state.view.scale >= state.view.fitScale * 3;718+ const minimumSpan = state.zoneLabels.length > 100 ? 76 :719+ state.zoneLabels.length > 40 ? 52 : 0;720+ const candidates = [...state.zoneLabels].sort((a, b) =>721+ Number(a.child) - Number(b.child) ||722+ Math.max(b.width, b.height) - Math.max(a.width, a.height) ||723+ a.element.textContent.localeCompare(b.element.textContent));724+ for (const label of state.zoneLabels) label.element.hidden = true;725+ for (const label of candidates) {726+ const screenSpan = Math.max(label.width, label.height) * state.view.scale;727+ const lacksDetail = minimumSpan > 0 && screenSpan < minimumSpan;728+ if (!state.zonesVisible || lacksDetail || (label.child && !showSubregions)) continue;729+ const point = {730+ x: Number.parseFloat(label.element.style.left),731+ y: Number.parseFloat(label.element.style.top),732+ };733+ const screen = screenPoint(point);734+ const width = Math.min(220, label.element.textContent.length * 8.7 + 20);735+ label.element.hidden = !reserveAnnotation(screen.x, screen.y, width, 30);736+ }737+ }738+739+ function updateMarkerVisibility() {740+ let rendered = 0;741+ for (const pin of state.eligibleMarkerPins) {742+ const selected = pin === state.selectedPin;743+ const screen = screenPoint(pin.point);744+ const shown = Boolean(state.search) || selected ||745+ reserveAnnotation(screen.x, screen.y, 34, 34, selected);746+ pin.marker.hidden = !shown;747+ if (shown) rendered++;748+ }749+ state.renderedMarkers = rendered;750+ }751+752+ function screenPoint(point) {753+ return {754+ x: state.view.x + point.x * state.view.scale,755+ y: state.view.y + point.y * state.view.scale,756+ };757+ }758+759+ function reserveAnnotation(centerX, centerY, width, height, force = false) {760+ const viewportWidth = elements.viewport.clientWidth;761+ const viewportHeight = elements.viewport.clientHeight;762+ const box = {763+ left: centerX - width / 2 - 3,764+ right: centerX + width / 2 + 3,765+ top: centerY - height / 2 - 3,766+ bottom: centerY + height / 2 + 3,767+ };768+ if (box.right < 0 || box.left > viewportWidth || box.bottom < 0 || box.top > viewportHeight) {769+ return false;770+ }771+772+ const cellSize = 48;773+ const minX = Math.floor(box.left / cellSize);774+ const maxX = Math.floor(box.right / cellSize);775+ const minY = Math.floor(box.top / cellSize);776+ const maxY = Math.floor(box.bottom / cellSize);777+ if (!force) {778+ const checked = new Set();779+ for (let x = minX; x <= maxX; x++) {780+ for (let y = minY; y <= maxY; y++) {781+ for (const existing of state.annotationGrid.get(`${x}:${y}`) || []) {782+ if (checked.has(existing)) continue;783+ checked.add(existing);784+ if (box.left < existing.right && box.right > existing.left &&785+ box.top < existing.bottom && box.bottom > existing.top) {786+ return false;787+ }788+ }789+ }790+ }791+ }792+ for (let x = minX; x <= maxX; x++) {793+ for (let y = minY; y <= maxY; y++) {794+ const key = `${x}:${y}`;795+ const entries = state.annotationGrid.get(key) || [];796+ entries.push(box);797+ state.annotationGrid.set(key, entries);798+ }799+ }800+ return true;801+ }802+803+ function updateVisibleCount() {804+ const rendered = state.renderedMarkers + state.renderedText;805+ const eligible = state.eligibleLocations;806+ if (rendered < eligible) {807+ elements.visibleCount.textContent =808+ `${formatNumber(eligible)} enabled · ${formatNumber(rendered)} drawn`;809+ return;810+ }811+ elements.visibleCount.textContent =812+ `${formatNumber(eligible)} ${eligible === 1 ? "location" : "locations"} visible`;813+ }814+815+ function textDetailRatio(category) {816+ if (category.locations.length > 200) return 4;817+ if (category.locations.length > 75) return 2.5;818+ return 1;819+ }820+821+ function stableRank(value) {822+ return (Math.imul(Number(value) || 0, 2654435761) >>> 0);823+ }824+825+ function colorFor(id) {826+ const value = Math.abs(Number(id) || 0);827+ return palette[(value * 2654435761 >>> 0) % palette.length];828+ }829+830+ function applyCategoryVisual(element, category) {831+ element.style.setProperty("--pin-color", category.color || colorFor(category.id));832+ }833+834+ function applyCategoryGlyph(element, category, fallback) {835+ element.classList.remove("has-source-icon");836+ element.style.removeProperty("--pin-icon");837+ element.textContent = "";838+ if (!category.iconAsset) {839+ element.textContent = fallback;840+ return;841+ }842+ const path = category.iconAsset843+ .split("/")844+ .map((segment) => encodeURIComponent(segment))845+ .join("/");846+ element.style.setProperty("--pin-icon", `url("/static/icons/${path}")`);847+ element.classList.add("has-source-icon");848+ }849+850+ function initials(value) {851+ return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("");852+ }853+854+ function cleanDescription(value) {855+ return String(value || "")856+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")857+ .replace(/[*_`>#]/g, "")858+ .replace(/\r/g, "")859+ .replace(/\n{3,}/g, "\n\n")860+ .trim();861+ }862+863+ function formatNumber(value) {864+ return new Intl.NumberFormat().format(value);865+ }866+867+ function clamp(value, minimum, maximum) {868+ return Math.min(maximum, Math.max(minimum, value));869+ }870+871+ start();872+})();@@ -0,0 +1,137 @@
1+2+<html lang="en">3+<head>4+ <meta charset="utf-8">5+ <meta name="viewport" content="width=device-width, initial-scale=1">6+ <meta name="color-scheme" content="dark">7+ <title>Atlas — Game Map Explorer</title>8+ <link rel="stylesheet" href="/static/app.css">9+ <script src="/static/app.js" defer></script>10+</head>11+<body>12+ <div class="app-shell">13+ <header class="topbar">14+ <div class="brand" aria-label="Atlas">15+ <span class="brand-mark" aria-hidden="true">16+ <svg viewBox="0 0 32 32"><path d="M16 2 27 8.5v15L16 30 5 23.5v-15L16 2Z"/><path d="m10 20 4-10 3 7 2-4 3 7"/></svg>17+ </span>18+ <span>19+ <strong>ATLAS</strong>20+ <small>Game map explorer</small>21+ </span>22+ </div>23+24+ <div class="map-pickers">25+ <label>26+ <span>Game</span>27+ <select id="game-select" aria-label="Game"></select>28+ </label>29+ <label>30+ <span>Map</span>31+ <select id="map-select" aria-label="Map"></select>32+ </label>33+ <label id="variant-field">34+ <span>Layer</span>35+ <select id="variant-select" aria-label="Map layer"></select>36+ </label>37+ </div>38+39+ <button class="icon-button mobile-legend" id="mobile-legend" type="button" aria-label="Toggle legend" aria-expanded="false">40+ <svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg>41+ </button>42+ </header>43+44+ <aside class="sidebar" id="sidebar">45+ <section class="sidebar-heading">46+ <p class="eyebrow">EXPLORE</p>47+ <h1 id="map-title">Loading maps…</h1>48+ <p id="map-meta">Preparing embedded archive</p>49+ </section>50+51+ <div class="search-wrap">52+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/></svg>53+ <input id="pin-search" type="search" placeholder="Search locations" autocomplete="off" aria-label="Search locations">54+ <kbd>⌘K</kbd>55+ </div>56+ <div id="search-results" class="search-results" hidden></div>57+58+ <div class="legend-toolbar">59+ <span>Map legend</span>60+ <div>61+ <button id="show-all" type="button">Show all</button>62+ <span aria-hidden="true">·</span>63+ <button id="hide-all" type="button">Hide all</button>64+ </div>65+ </div>66+67+ <div id="overlay-controls" class="overlay-controls" hidden>68+ <label id="zone-control" class="zone-control" hidden>69+ <input id="zone-toggle" type="checkbox" checked>70+ <span class="check" aria-hidden="true"></span>71+ <span class="zone-symbol" aria-hidden="true">72+ <svg viewBox="0 0 24 24"><path d="m4 7 5-3 6 3 5-2v12l-5 3-6-3-5 2V7Z"/><path d="M9 4v13M15 7v13"/></svg>73+ </span>74+ <span>Zone boundaries</span>75+ <span id="zone-count"></span>76+ </label>77+ <div id="text-overlay-controls"></div>78+ </div>79+80+ <div id="legend" class="legend" aria-live="polite"></div>81+82+ <footer class="sidebar-footer">83+ <span class="live-dot"></span>84+ <span id="visible-count">0 locations visible</span>85+ </footer>86+ </aside>87+88+ <main class="map-panel">89+ <div id="map" class="map" tabindex="0" aria-label="Interactive game map">90+ <div id="world" class="world">91+ <img id="map-image" alt="" draggable="false">92+ <svg id="zones" class="zones" viewBox="0 0 8192 8192" aria-hidden="true"></svg>93+ <div id="zone-labels" class="zone-labels" aria-hidden="true"></div>94+ <div id="markers" class="markers"></div>95+ </div>96+97+ <div class="map-loading" id="map-loading">98+ <span class="spinner"></span>99+ <strong>Unfolding map</strong>100+ <small>Loading the embedded z13 image…</small>101+ </div>102+103+ <div class="zoom-controls" aria-label="Map controls">104+ <button id="zoom-in" type="button" aria-label="Zoom in">+</button>105+ <button id="zoom-out" type="button" aria-label="Zoom out">−</button>106+ <button id="reset-view" type="button" aria-label="Fit map to window">107+ <svg viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M21 16v5h-5M3 16v5h5"/></svg>108+ </button>109+ </div>110+111+ <div class="map-hint" id="map-hint">112+ <span>Drag to pan</span><i></i><span>Scroll to zoom</span>113+ </div>114+115+ <article id="pin-detail" class="pin-detail" hidden>116+ <button id="close-detail" class="detail-close" type="button" aria-label="Close location details">×</button>117+ <div class="detail-category">118+ <span id="detail-dot"></span>119+ <span id="detail-category"></span>120+ </div>121+ <h2 id="detail-title"></h2>122+ <p id="detail-description"></p>123+ <dl>124+ <div><dt>Location ID</dt><dd id="detail-id"></dd></div>125+ <div><dt>Coordinates</dt><dd id="detail-coordinates"></dd></div>126+ </dl>127+ </article>128+ </div>129+ </main>130+ </div>131+132+ <div id="fatal-error" class="fatal-error" hidden>133+ <strong>Atlas could not open the embedded catalog.</strong>134+ <p id="fatal-message"></p>135+ </div>136+</body>137+</html>Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
@@ -0,0 +1,50 @@
1+module github.com/FelineStateMachine/atlas2+3+go 1.26.04+5+require (6+ github.com/FelineStateMachine/allons v0.0.07+ github.com/wailsapp/wails/v2 v2.13.08+)9+10+require (11+ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect12+ github.com/bep/debounce v1.2.1 // indirect13+ github.com/dustin/go-humanize v1.0.1 // indirect14+ github.com/go-ole/go-ole v1.3.0 // indirect15+ github.com/godbus/dbus/v5 v5.1.0 // indirect16+ github.com/google/uuid v1.6.0 // indirect17+ github.com/gorilla/websocket v1.5.3 // indirect18+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect19+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect20+ github.com/labstack/echo/v4 v4.13.3 // indirect21+ github.com/labstack/gommon v0.4.2 // indirect22+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect23+ github.com/leaanthony/gosod v1.0.4 // indirect24+ github.com/leaanthony/slicer v1.6.0 // indirect25+ github.com/leaanthony/u v1.1.1 // indirect26+ github.com/mattn/go-colorable v0.1.13 // indirect27+ github.com/mattn/go-isatty v0.0.20 // indirect28+ github.com/ncruces/go-strftime v1.0.0 // indirect29+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect30+ github.com/pkg/errors v0.9.1 // indirect31+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect32+ github.com/rivo/uniseg v0.4.7 // indirect33+ github.com/samber/lo v1.49.1 // indirect34+ github.com/tkrajina/go-reflector v0.5.8 // indirect35+ github.com/valyala/bytebufferpool v1.0.0 // indirect36+ github.com/valyala/fasttemplate v1.2.2 // indirect37+ github.com/wailsapp/go-webview2 v1.0.22 // indirect38+ github.com/wailsapp/mimetype v1.4.1 // indirect39+ golang.org/x/crypto v0.51.0 // indirect40+ golang.org/x/net v0.54.0 // indirect41+ golang.org/x/sys v0.46.0 // indirect42+ golang.org/x/text v0.37.0 // indirect43+ lukechampine.com/blake3 v1.4.1 // indirect44+ modernc.org/libc v1.74.1 // indirect45+ modernc.org/mathutil v1.7.1 // indirect46+ modernc.org/memory v1.11.0 // indirect47+ modernc.org/sqlite v1.55.0 // indirect48+)49+50+replace github.com/FelineStateMachine/allons => ../allons@@ -0,0 +1,133 @@
1+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=2+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=3+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=4+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=5+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=6+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=7+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=8+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=9+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=10+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=11+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=12+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=13+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=14+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=15+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=16+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=17+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=18+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=19+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=20+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=21+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=22+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=23+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=24+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=25+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=26+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=27+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=28+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=29+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=30+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=31+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=32+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=33+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=34+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=35+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=36+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=37+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=38+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=39+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=40+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=41+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=42+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=43+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=44+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=45+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=46+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=47+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=48+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=49+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=50+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=51+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=52+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=53+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=54+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=55+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=56+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=57+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=58+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=59+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=60+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=61+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=62+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=63+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=64+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=65+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=66+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=67+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=68+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=69+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=70+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=71+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=72+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=73+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=74+github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg=75+github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc=76+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=77+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=78+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=79+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=80+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=81+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=82+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=83+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=84+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=85+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=86+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=87+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=88+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=89+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=90+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=91+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=92+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=93+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=94+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=95+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=96+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=97+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=98+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=99+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=100+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=101+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=102+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=103+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=104+lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=105+lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=106+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=107+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=108+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=109+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=110+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=111+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=112+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=113+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=114+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=115+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=116+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=117+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=118+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=119+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=120+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=121+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=122+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=123+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=124+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=125+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=126+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=127+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=128+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=129+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=130+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=131+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=132+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=133+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=@@ -0,0 +1,47 @@
1+// Command atlas is a self-contained interactive map explorer built with2+// Allons. Its FMG catalog and full-resolution z13 maps are embedded in the3+// executable, so the application needs no network connection or sidecar data.4+package main5+6+import (7+ "embed"8+ "fmt"9+ "net/http"10+ "os"11+12+ "github.com/FelineStateMachine/allons/local"13+ "github.com/FelineStateMachine/allons/wailsapp"14+ "github.com/wailsapp/wails/v2/pkg/options"15+)16+17+//go:generate go run ./tools/stitch -source ../gamemap/fmg-archive -output assets/maps18+//go:generate go run ./tools/generate -source ../gamemap -output assets/catalog.json19+20+//go:embed all:assets21+var assets embed.FS22+23+func main() {24+ err := wailsapp.Run(25+ local.Config{26+ AppID: "dev.felinestatemachine.atlas",27+ Schema: local.Schema{AppSchemaVersion: 1, AppSchemaMinReadable: 1},28+ Assets: assets,29+ Bootstrap: local.CustomBootstrap("/"),30+ Sync: local.SyncConfig{Autostart: local.AutostartOff},31+ Routes: func(*local.App) http.Handler {32+ return routes(assets)33+ },34+ },35+ &options.App{36+ Title: "Atlas — Game Map Explorer",37+ Width: 1440,38+ Height: 920,39+ MinWidth: 900,40+ MinHeight: 600,41+ },42+ )43+ if err != nil {44+ fmt.Fprintln(os.Stderr, "atlas:", err)45+ os.Exit(1)46+ }47+}@@ -0,0 +1,24 @@
1+package main2+3+import (4+ "io/fs"5+ "net/http"6+)7+8+func routes(files fs.FS) http.Handler {9+ mux := http.NewServeMux()10+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {11+ if r.URL.Path != "/" {12+ http.NotFound(w, r)13+ return14+ }15+ data, err := fs.ReadFile(files, "assets/index.html")16+ if err != nil {17+ http.Error(w, "embedded application shell is unavailable", http.StatusInternalServerError)18+ return19+ }20+ w.Header().Set("Content-Type", "text/html; charset=utf-8")21+ _, _ = w.Write(data)22+ })23+ return mux24+}@@ -0,0 +1,238 @@
1+package main2+3+import (4+ "encoding/json"5+ "io/fs"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+)11+12+func TestRoutesServeEmbeddedExplorer(t *testing.T) {13+ req := httptest.NewRequest(http.MethodGet, "/", nil)14+ rec := httptest.NewRecorder()15+16+ routes(assets).ServeHTTP(rec, req)17+18+ if rec.Code != http.StatusOK {19+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)20+ }21+ if body := rec.Body.String(); !strings.Contains(body, `id="map"`) {22+ t.Fatal("root response does not contain the map explorer")23+ }24+ if body := rec.Body.String(); strings.Contains(body, "OFFLINE") {25+ t.Fatal("root response contains the redundant offline status chip")26+ }27+ if body := rec.Body.String(); strings.Contains(body, "Z13 EMBEDDED ASSETS") {28+ t.Fatal("root response contains the redundant asset credit")29+ }30+ if body := rec.Body.String(); !strings.Contains(body, `id="text-overlay-controls"`) {31+ t.Fatal("root response does not contain the promoted text-overlay controls")32+ }33+}34+35+func TestRoutesReturnNotFoundForUnknownPage(t *testing.T) {36+ req := httptest.NewRequest(http.MethodGet, "/missing", nil)37+ rec := httptest.NewRecorder()38+39+ routes(assets).ServeHTTP(rec, req)40+41+ if rec.Code != http.StatusNotFound {42+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)43+ }44+}45+46+func TestEmbeddedCatalogCarriesTextLabelsAndZones(t *testing.T) {47+ data, err := fs.ReadFile(assets, "assets/catalog.json")48+ if err != nil {49+ t.Fatal(err)50+ }51+ var catalog struct {52+ Games []struct {53+ Title string `json:"title"`54+ Maps []struct {55+ Title string `json:"title"`56+ Zones []any `json:"zones"`57+ Variants []struct {58+ Name string `json:"name"`59+ Bounds *struct {60+ X int `json:"x"`61+ Y int `json:"y"`62+ Width int `json:"width"`63+ Height int `json:"height"`64+ } `json:"bounds"`65+ } `json:"variants"`66+ Groups []struct {67+ Categories []struct {68+ Title string `json:"title"`69+ DisplayType string `json:"displayType"`70+ Locations []struct {71+ Title string `json:"title"`72+ RegionID *int64 `json:"regionId"`73+ } `json:"locations"`74+ } `json:"categories"`75+ } `json:"groups"`76+ } `json:"maps"`77+ } `json:"games"`78+ }79+ if err := json.Unmarshal(data, &catalog); err != nil {80+ t.Fatal(err)81+ }82+83+ var foundPanopticon bool84+ var forzaZones, forzaPinsWithRegion, boundedForzaVariants int85+ for _, game := range catalog.Games {86+ for _, gameMap := range game.Maps {87+ if game.Title == "Forza Horizon 6" && gameMap.Title == "Japan" {88+ forzaZones = len(gameMap.Zones)89+ for _, variant := range gameMap.Variants {90+ if variant.Bounds != nil &&91+ variant.Bounds.X == 2048 && variant.Bounds.Y == 2048 &&92+ variant.Bounds.Width == 4096 && variant.Bounds.Height == 4096 {93+ boundedForzaVariants++94+ }95+ }96+ }97+ for _, group := range gameMap.Groups {98+ for _, category := range group.Categories {99+ for _, location := range category.Locations {100+ if game.Title == "Marathon" && gameMap.Title == "Cryo Archive" &&101+ category.Title == "Area" && category.DisplayType == "text" &&102+ location.Title == "PANOPTICON" {103+ foundPanopticon = true104+ }105+ if game.Title == "Forza Horizon 6" && gameMap.Title == "Japan" &&106+ location.RegionID != nil {107+ forzaPinsWithRegion++108+ }109+ }110+ }111+ }112+ }113+ }114+ if !foundPanopticon {115+ t.Fatal("PANOPTICON is not preserved as a text-display location")116+ }117+ if forzaZones != 10 {118+ t.Fatalf("Forza Japan zones = %d, want 10", forzaZones)119+ }120+ if forzaPinsWithRegion != 806 {121+ t.Fatalf("Forza Japan pins with a region = %d, want 806", forzaPinsWithRegion)122+ }123+ if boundedForzaVariants != 4 {124+ t.Fatalf("bounded Forza Japan variants = %d, want 4", boundedForzaVariants)125+ }126+}127+128+func TestEmbeddedCatalogIncludesOnlyCompleteNewMaps(t *testing.T) {129+ data, err := fs.ReadFile(assets, "assets/catalog.json")130+ if err != nil {131+ t.Fatal(err)132+ }133+ var catalog struct {134+ Games []struct {135+ Title string `json:"title"`136+ Maps []struct {137+ Title string `json:"title"`138+ Variants []struct {139+ Image string `json:"image"`140+ } `json:"variants"`141+ } `json:"maps"`142+ } `json:"games"`143+ }144+ if err := json.Unmarshal(data, &catalog); err != nil {145+ t.Fatal(err)146+ }147+148+ got := make(map[string]bool)149+ for _, game := range catalog.Games {150+ for _, gameMap := range game.Maps {151+ got[game.Title+" / "+gameMap.Title] = true152+ for _, variant := range gameMap.Variants {153+ if _, err := fs.Stat(assets, "assets/maps/"+variant.Image); err != nil {154+ t.Errorf("%s / %s references missing image %q: %v", game.Title, gameMap.Title, variant.Image, err)155+ }156+ }157+ }158+ }159+ for _, name := range []string{160+ "Skyrim / Skyrim",161+ "Skyrim / Solstheim",162+ "Temtem / Airborne Archipelago",163+ "Zelda: Breath of the Wild / Hyrule",164+ "Pokémon Red/Blue/Yellow / Yellow",165+ } {166+ if !got[name] {167+ t.Errorf("complete map %q is missing", name)168+ }169+ }170+ for _, name := range []string{171+ "Grand Theft Auto 5 / Los Santos",172+ "Pokémon Red/Blue/Yellow / Red/Blue",173+ "Red Dead Redemption 2 / Red Dead Redemption 2",174+ } {175+ if got[name] {176+ t.Errorf("incomplete map %q was included", name)177+ }178+ }179+}180+181+func TestEmbeddedCatalogCarriesCategoryIconsAndColors(t *testing.T) {182+ data, err := fs.ReadFile(assets, "assets/catalog.json")183+ if err != nil {184+ t.Fatal(err)185+ }186+ var catalog struct {187+ Games []struct {188+ Title string `json:"title"`189+ Maps []struct {190+ Title string `json:"title"`191+ Groups []struct {192+ Categories []struct {193+ Title string `json:"title"`194+ Color string `json:"color"`195+ IconAsset string `json:"iconAsset"`196+ } `json:"categories"`197+ } `json:"groups"`198+ } `json:"maps"`199+ } `json:"games"`200+ }201+ if err := json.Unmarshal(data, &catalog); err != nil {202+ t.Fatal(err)203+ }204+205+ var foundPokemonCenter bool206+ var iconAssets int207+ for _, game := range catalog.Games {208+ for _, gameMap := range game.Maps {209+ for _, group := range gameMap.Groups {210+ for _, category := range group.Categories {211+ if category.IconAsset != "" {212+ iconAssets++213+ if _, err := fs.Stat(assets, "assets/icons/"+category.IconAsset); err != nil {214+ t.Errorf("embedded icon %q: %v", category.IconAsset, err)215+ }216+ }217+ if game.Title != "Pokémon Red/Blue/Yellow" ||218+ gameMap.Title != "Yellow" || category.Title != "Pokémon Center" {219+ continue220+ }221+ foundPokemonCenter = true222+ if category.Color != "#38344C" {223+ t.Errorf("Pokémon Center color = %q, want #38344C", category.Color)224+ }225+ if category.IconAsset == "" {226+ t.Error("Pokémon Center has no icon asset")227+ }228+ }229+ }230+ }231+ }232+ if !foundPokemonCenter {233+ t.Error("Yellow Pokémon Center category not found")234+ }235+ if iconAssets < 200 {236+ t.Errorf("catalog icon assets = %d, want at least 200", iconAssets)237+ }238+}@@ -0,0 +1,647 @@
1+// Command generate turns an FMG archive into the compact catalog consumed by2+// the demo. It intentionally keeps the source archive outside the Go module;3+// only the browser-ready catalog and stitched maps are embedded in the app.4+package main5+6+import (7+ "encoding/json"8+ "errors"9+ "flag"10+ "fmt"11+ "os"12+ "path/filepath"13+ "sort"14+ "strconv"15+ "strings"16+)17+18+type archive struct {19+ Games []archiveGame `json:"games"`20+}21+22+type archiveGame struct {23+ Directory string `json:"directory"`24+ ID int64 `json:"id"`25+ Title string `json:"title"`26+}27+28+type snapshotIndex struct {29+ CapturedAt string `json:"capturedAt"`30+ ContentHash string `json:"contentHash"`31+}32+33+type rawMap struct {34+ ID int64 `json:"id"`35+ Title string `json:"title"`36+ Slug string `json:"slug"`37+ InitialLatitude float64 `json:"initial_latitude"`38+ InitialLongitude float64 `json:"initial_longitude"`39+ InitialZoom float64 `json:"initial_zoom"`40+ Config rawConfig `json:"config"`41+ Game rawGame `json:"game"`42+ Groups []rawGroup `json:"groups"`43+ Regions []rawRegion `json:"regions"`44+}45+46+type rawGame struct {47+ ID int64 `json:"id"`48+ Title string `json:"title"`49+ Slug string `json:"slug"`50+}51+52+type rawConfig struct {53+ TileSets []rawTileSet `json:"tile_sets"`54+}55+56+type rawTileSet struct {57+ Name string `json:"name"`58+ Path string `json:"path"`59+}60+61+type rawGroup struct {62+ ID int64 `json:"id"`63+ Title string `json:"title"`64+ Color string `json:"color"`65+ IconColor string `json:"icon_color"`66+ Categories []rawCategory `json:"categories"`67+}68+69+type rawCategory struct {70+ ID int64 `json:"id"`71+ Title string `json:"title"`72+ Icon string `json:"icon"`73+ Color string `json:"color"`74+ IconColor string `json:"icon_color"`75+ DisplayType string `json:"display_type"`76+ Visible bool `json:"visible"`77+ Locations []rawLocation `json:"locations"`78+}79+80+type rawLocation struct {81+ ID int64 `json:"id"`82+ Title string `json:"title"`83+ Description string `json:"description"`84+ Latitude json.RawMessage `json:"latitude"`85+ Longitude json.RawMessage `json:"longitude"`86+ RegionID *int64 `json:"region_id"`87+}88+89+type rawRegion struct {90+ ID int64 `json:"id"`91+ Title string `json:"title"`92+ Subtitle string `json:"subtitle"`93+ ParentRegionID *int64 `json:"parent_region_id"`94+ CenterX json.RawMessage `json:"center_x"`95+ CenterY json.RawMessage `json:"center_y"`96+ Features []rawFeature `json:"features"`97+}98+99+type rawFeature struct {100+ Geometry geometry `json:"geometry"`101+}102+103+type geometry struct {104+ Type string `json:"type"`105+ Coordinates json.RawMessage `json:"coordinates"`106+}107+108+type catalog struct {109+ Source string `json:"source"`110+ TileGrid tileGrid `json:"tileGrid"`111+ Games []catalogGame `json:"games"`112+}113+114+type tileGrid struct {115+ Zoom int `json:"zoom"`116+ FirstTile int `json:"firstTile"`117+ TileSize int `json:"tileSize"`118+ Size int `json:"size"`119+}120+121+type catalogGame struct {122+ ID int64 `json:"id"`123+ Title string `json:"title"`124+ Slug string `json:"slug"`125+ Maps []catalogMap `json:"maps"`126+}127+128+type catalogMap struct {129+ ID int64 `json:"id"`130+ Title string `json:"title"`131+ Slug string `json:"slug"`132+ Center coordinate `json:"center"`133+ Variants []variant `json:"variants"`134+ Groups []catalogGroup `json:"groups"`135+ Zones []zone `json:"zones,omitempty"`136+ PinCount int `json:"pinCount"`137+ UpdatedAt string `json:"updatedAt"`138+}139+140+type coordinate struct {141+ Latitude float64 `json:"lat"`142+ Longitude float64 `json:"lng"`143+}144+145+type variant struct {146+ Name string `json:"name"`147+ Image string `json:"image"`148+ Bounds *contentBounds `json:"bounds,omitempty"`149+}150+151+type contentBounds struct {152+ X int `json:"x"`153+ Y int `json:"y"`154+ Width int `json:"width"`155+ Height int `json:"height"`156+}157+158+type tileRecord struct {159+ ContentHash string `json:"contentHash"`160+ URL string `json:"url"`161+ X int `json:"x"`162+ Y int `json:"y"`163+ Zoom int `json:"zoom"`164+}165+166+type catalogGroup struct {167+ ID int64 `json:"id"`168+ Title string `json:"title"`169+ Categories []catalogCategory `json:"categories"`170+}171+172+type catalogCategory struct {173+ ID int64 `json:"id"`174+ Title string `json:"title"`175+ Icon string `json:"icon,omitempty"`176+ IconAsset string `json:"iconAsset,omitempty"`177+ Color string `json:"color,omitempty"`178+ IconColor string `json:"iconColor,omitempty"`179+ DisplayType string `json:"displayType"`180+ Visible bool `json:"visible"`181+ Locations []catalogLocation `json:"locations"`182+}183+184+type catalogLocation struct {185+ ID int64 `json:"id"`186+ Title string `json:"title"`187+ Description string `json:"description,omitempty"`188+ Latitude float64 `json:"lat"`189+ Longitude float64 `json:"lng"`190+ RegionID *int64 `json:"regionId,omitempty"`191+}192+193+type zone struct {194+ ID int64 `json:"id"`195+ Title string `json:"title"`196+ Subtitle string `json:"subtitle,omitempty"`197+ ParentRegionID *int64 `json:"parentRegionId,omitempty"`198+ Center *coordinate `json:"center,omitempty"`199+ Features []geometry `json:"features"`200+}201+202+var errMapNotReady = errors.New("map is not ready for embedding")203+204+func main() {205+ source := flag.String("source", "", "path containing fmg-archive and z13-stitched-maps")206+ output := flag.String("output", "", "catalog JSON destination")207+ flag.Parse()208+209+ if *source == "" || *output == "" {210+ fmt.Fprintln(os.Stderr, "generate: -source and -output are required")211+ os.Exit(2)212+ }213+ if err := run(*source, *output); err != nil {214+ fmt.Fprintln(os.Stderr, "generate:", err)215+ os.Exit(1)216+ }217+}218+219+func run(source, output string) error {220+ archiveRoot := filepath.Join(source, "fmg-archive")221+ var index archive222+ if err := readJSON(filepath.Join(archiveRoot, "archive.json"), &index); err != nil {223+ return err224+ }225+226+ out := catalog{227+ Source: "FMG archive",228+ TileGrid: tileGrid{Zoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192},229+ }230+ imageRoot := filepath.Join(filepath.Dir(output), "maps")231+ iconRoot := filepath.Join(filepath.Dir(output), "icons")232+ for _, gameRef := range index.Games {233+ game, err := buildGame(archiveRoot, imageRoot, iconRoot, gameRef)234+ if err != nil {235+ return fmt.Errorf("%s: %w", gameRef.Title, err)236+ }237+ if len(game.Maps) > 0 {238+ out.Games = append(out.Games, game)239+ }240+ }241+242+ sort.Slice(out.Games, func(i, j int) bool { return out.Games[i].Title < out.Games[j].Title })243+ data, err := json.Marshal(out)244+ if err != nil {245+ return fmt.Errorf("marshal catalog: %w", err)246+ }247+ if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {248+ return err249+ }250+ if err := os.WriteFile(output, append(data, '\n'), 0o644); err != nil {251+ return fmt.Errorf("write %s: %w", output, err)252+ }253+ return nil254+}255+256+func buildGame(archiveRoot, imageRoot, iconRoot string, ref archiveGame) (catalogGame, error) {257+ gamePath := filepath.Join(archiveRoot, ref.Directory)258+ mapDirs, err := filepath.Glob(filepath.Join(gamePath, "maps", "*"))259+ if err != nil {260+ return catalogGame{}, err261+ }262+ game := catalogGame{ID: ref.ID, Title: ref.Title}263+ for _, mapDir := range mapDirs {264+ info, err := os.Stat(mapDir)265+ if err != nil || !info.IsDir() {266+ continue267+ }268+ m, gameSlug, err := buildMap(mapDir, imageRoot)269+ if err != nil {270+ if errors.Is(err, errMapNotReady) {271+ continue272+ }273+ return catalogGame{}, err274+ }275+ if game.Slug == "" {276+ game.Slug = gameSlug277+ }278+ game.Maps = append(game.Maps, m)279+ }280+ sort.Slice(game.Maps, func(i, j int) bool { return game.Maps[i].Title < game.Maps[j].Title })281+ if err := attachGameIcons(gamePath, iconRoot, &game); err != nil {282+ return catalogGame{}, err283+ }284+ return game, nil285+}286+287+func buildMap(mapDir, imageRoot string) (catalogMap, string, error) {288+ var snapshots []snapshotIndex289+ indexPath := filepath.Join(mapDir, "snapshots", "index.json")290+ if err := readJSON(indexPath, &snapshots); err != nil {291+ if errors.Is(err, os.ErrNotExist) {292+ return catalogMap{}, "", fmt.Errorf("%w: snapshot index is missing", errMapNotReady)293+ }294+ return catalogMap{}, "", err295+ }296+ if len(snapshots) == 0 {297+ return catalogMap{}, "", fmt.Errorf("%w: snapshot index is empty", errMapNotReady)298+ }299+ sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].CapturedAt < snapshots[j].CapturedAt })300+ latest := snapshots[len(snapshots)-1]301+302+ var raw rawMap303+ snapshotPath := filepath.Join(mapDir, "snapshots", "map", latest.ContentHash+".json")304+ if err := readJSON(snapshotPath, &raw); err != nil {305+ return catalogMap{}, "", err306+ }307+308+ m := catalogMap{309+ ID: raw.ID,310+ Title: raw.Title,311+ Slug: raw.Slug,312+ Center: coordinate{Latitude: raw.InitialLatitude, Longitude: raw.InitialLongitude},313+ UpdatedAt: latest.CapturedAt,314+ }315+ boundsByPath, err := tileContentBounds(mapDir)316+ if err != nil {317+ return catalogMap{}, "", err318+ }319+ if len(raw.Config.TileSets) == 0 {320+ m.Variants = []variant{{321+ Name: "Default",322+ Image: imageName(raw.Game.Slug, raw.Slug, ""),323+ }}324+ } else {325+ for _, set := range raw.Config.TileSets {326+ suffix := ""327+ if len(raw.Config.TileSets) > 1 {328+ suffix = slugify(set.Name)329+ }330+ m.Variants = append(m.Variants, variant{331+ Name: set.Name,332+ Image: imageName(raw.Game.Slug, raw.Slug, suffix),333+ Bounds: boundsByPath[set.Path],334+ })335+ }336+ }337+ for _, variant := range m.Variants {338+ info, err := os.Stat(filepath.Join(imageRoot, variant.Image))339+ if errors.Is(err, os.ErrNotExist) {340+ return catalogMap{}, "", fmt.Errorf("%w: image %s is missing", errMapNotReady, variant.Image)341+ }342+ if err != nil {343+ return catalogMap{}, "", err344+ }345+ if !info.Mode().IsRegular() {346+ return catalogMap{}, "", fmt.Errorf("%w: image %s is not a file", errMapNotReady, variant.Image)347+ }348+ }349+ for _, rawGroup := range raw.Groups {350+ group := catalogGroup{ID: rawGroup.ID, Title: rawGroup.Title}351+ for _, rawCategory := range rawGroup.Categories {352+ category := catalogCategory{353+ ID: rawCategory.ID,354+ Title: rawCategory.Title,355+ Icon: rawCategory.Icon,356+ Color: resolvedCategoryColor(rawGroup, rawCategory),357+ IconColor: resolvedIconColor(rawGroup, rawCategory),358+ DisplayType: rawCategory.DisplayType,359+ Visible: rawCategory.Visible,360+ }361+ for _, rawLocation := range rawCategory.Locations {362+ lat, err := number(rawLocation.Latitude)363+ if err != nil {364+ return catalogMap{}, "", fmt.Errorf("location %d latitude: %w", rawLocation.ID, err)365+ }366+ lng, err := number(rawLocation.Longitude)367+ if err != nil {368+ return catalogMap{}, "", fmt.Errorf("location %d longitude: %w", rawLocation.ID, err)369+ }370+ category.Locations = append(category.Locations, catalogLocation{371+ ID: rawLocation.ID,372+ Title: rawLocation.Title,373+ Description: rawLocation.Description,374+ Latitude: lat,375+ Longitude: lng,376+ RegionID: rawLocation.RegionID,377+ })378+ m.PinCount++379+ }380+ group.Categories = append(group.Categories, category)381+ }382+ m.Groups = append(m.Groups, group)383+ }384+ for _, rawRegion := range raw.Regions {385+ z := zone{386+ ID: rawRegion.ID,387+ Title: rawRegion.Title,388+ Subtitle: rawRegion.Subtitle,389+ ParentRegionID: rawRegion.ParentRegionID,390+ }391+ centerX, hasX, err := optionalNumber(rawRegion.CenterX)392+ if err != nil {393+ return catalogMap{}, "", fmt.Errorf("region %d center_x: %w", rawRegion.ID, err)394+ }395+ centerY, hasY, err := optionalNumber(rawRegion.CenterY)396+ if err != nil {397+ return catalogMap{}, "", fmt.Errorf("region %d center_y: %w", rawRegion.ID, err)398+ }399+ if hasX && hasY {400+ z.Center = &coordinate{Latitude: centerY, Longitude: centerX}401+ }402+ for _, feature := range rawRegion.Features {403+ if feature.Geometry.Type != "" && len(feature.Geometry.Coordinates) > 0 {404+ z.Features = append(z.Features, feature.Geometry)405+ }406+ }407+ if len(z.Features) > 0 {408+ m.Zones = append(m.Zones, z)409+ }410+ }411+ return m, raw.Game.Slug, nil412+}413+414+func attachGameIcons(gamePath, iconRoot string, game *catalogGame) error {415+ if game.Slug == "" {416+ return nil417+ }418+ copied := make(map[string]string)419+ for mapIndex := range game.Maps {420+ for groupIndex := range game.Maps[mapIndex].Groups {421+ categories := game.Maps[mapIndex].Groups[groupIndex].Categories422+ for categoryIndex := range categories {423+ category := &categories[categoryIndex]424+ if !validIconKey(category.Icon) {425+ continue426+ }427+ asset, found := copied[category.Icon]428+ if !found {429+ var err error430+ asset, err = copyGameIcon(gamePath, iconRoot, game.Slug, category.Icon)431+ if err != nil {432+ return err433+ }434+ copied[category.Icon] = asset435+ }436+ category.IconAsset = asset437+ }438+ }439+ }440+ return nil441+}442+443+func copyGameIcon(gamePath, iconRoot, gameSlug, icon string) (string, error) {444+ source := filepath.Join(gamePath, "icons", icon+".svg")445+ data, err := os.ReadFile(source)446+ if errors.Is(err, os.ErrNotExist) {447+ return "", nil448+ }449+ if err != nil {450+ return "", fmt.Errorf("read icon %s: %w", source, err)451+ }452+ asset := filepath.ToSlash(filepath.Join(gameSlug, icon+".svg"))453+ destination := filepath.Join(iconRoot, filepath.FromSlash(asset))454+ if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {455+ return "", fmt.Errorf("create icon directory: %w", err)456+ }457+ if err := os.WriteFile(destination, data, 0o644); err != nil {458+ return "", fmt.Errorf("write icon %s: %w", destination, err)459+ }460+ return asset, nil461+}462+463+func validIconKey(value string) bool {464+ if value == "" {465+ return false466+ }467+ for _, r := range value {468+ if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' ||469+ r >= '0' && r <= '9' || r == '_' || r == '-' {470+ continue471+ }472+ return false473+ }474+ return true475+}476+477+func resolvedCategoryColor(group rawGroup, category rawCategory) string {478+ if color := normalizeHexColor(category.Color); color != "" {479+ return color480+ }481+ return normalizeHexColor(group.Color)482+}483+484+func resolvedIconColor(group rawGroup, category rawCategory) string {485+ if color := normalizeHexColor(category.IconColor); color != "" {486+ return color487+ }488+ return normalizeHexColor(group.IconColor)489+}490+491+func normalizeHexColor(value string) string {492+ value = strings.TrimPrefix(strings.TrimSpace(value), "#")493+ switch len(value) {494+ case 3, 4, 6, 8:495+ default:496+ return ""497+ }498+ for _, r := range value {499+ if r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F' {500+ continue501+ }502+ return ""503+ }504+ return "#" + strings.ToUpper(value)505+}506+507+func tileContentBounds(mapDir string) (map[string]*contentBounds, error) {508+ const (509+ zoom = 13510+ firstTile = 4064511+ tileSize = 256512+ gridSize = 8192513+ )514+515+ indexPath := filepath.Join(mapDir, "tiles", "index.json")516+ var records []tileRecord517+ if err := readJSON(indexPath, &records); err != nil {518+ if errors.Is(err, os.ErrNotExist) {519+ return nil, nil520+ }521+ return nil, err522+ }523+524+ byPath := make(map[string][]tileRecord)525+ for _, record := range records {526+ if record.Zoom != zoom || record.ContentHash == "" {527+ continue528+ }529+ path := tileSetPath(record.URL, zoom)530+ if path != "" {531+ byPath[path] = append(byPath[path], record)532+ }533+ }534+535+ result := make(map[string]*contentBounds)536+ for path, tiles := range byPath {537+ counts := make(map[string]int)538+ var placeholder string539+ minX, minY := int(^uint(0)>>1), int(^uint(0)>>1)540+ maxX, maxY := -1, -1541+ for _, tile := range tiles {542+ counts[tile.ContentHash]++543+ if counts[tile.ContentHash] > counts[placeholder] {544+ placeholder = tile.ContentHash545+ }546+ minX = min(minX, tile.X)547+ minY = min(minY, tile.Y)548+ maxX = max(maxX, tile.X)549+ maxY = max(maxY, tile.Y)550+ }551+ if counts[placeholder] > len(tiles)/2 {552+ minX, minY = int(^uint(0)>>1), int(^uint(0)>>1)553+ maxX, maxY = -1, -1554+ for _, tile := range tiles {555+ if tile.ContentHash == placeholder {556+ continue557+ }558+ minX = min(minX, tile.X)559+ minY = min(minY, tile.Y)560+ maxX = max(maxX, tile.X)561+ maxY = max(maxY, tile.Y)562+ }563+ }564+ if maxX < minX || maxY < minY {565+ continue566+ }567+ if minX == firstTile && minY == firstTile &&568+ (maxX-minX+1)*tileSize == gridSize &&569+ (maxY-minY+1)*tileSize == gridSize {570+ continue571+ }572+ result[path] = &contentBounds{573+ X: (minX - firstTile) * tileSize,574+ Y: (minY - firstTile) * tileSize,575+ Width: (maxX - minX + 1) * tileSize,576+ Height: (maxY - minY + 1) * tileSize,577+ }578+ }579+ return result, nil580+}581+582+func tileSetPath(rawURL string, zoom int) string {583+ const marker = "/games/"584+ start := strings.Index(rawURL, marker)585+ if start < 0 {586+ return ""587+ }588+ path := rawURL[start+len(marker):]589+ end := strings.Index(path, "/"+strconv.Itoa(zoom)+"/")590+ if end < 0 {591+ return ""592+ }593+ return path[:end]594+}595+596+func imageName(game, mapSlug, suffix string) string {597+ name := game + "__" + mapSlug598+ if suffix != "" {599+ name += "__" + suffix600+ }601+ return name + "__z13.png"602+}603+604+func slugify(value string) string {605+ return strings.Trim(strings.Map(func(r rune) rune {606+ switch {607+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':608+ return r609+ case r >= 'A' && r <= 'Z':610+ return r + ('a' - 'A')611+ default:612+ return '-'613+ }614+ }, value), "-")615+}616+617+func number(raw json.RawMessage) (float64, error) {618+ value := strings.TrimSpace(string(raw))619+ if len(value) >= 2 && value[0] == '"' {620+ var text string621+ if err := json.Unmarshal(raw, &text); err != nil {622+ return 0, err623+ }624+ value = text625+ }626+ return strconv.ParseFloat(value, 64)627+}628+629+func optionalNumber(raw json.RawMessage) (float64, bool, error) {630+ value := strings.TrimSpace(string(raw))631+ if value == "" || value == "null" {632+ return 0, false, nil633+ }634+ n, err := number(raw)635+ return n, err == nil, err636+}637+638+func readJSON(path string, dst any) error {639+ data, err := os.ReadFile(path)640+ if err != nil {641+ return fmt.Errorf("read %s: %w", path, err)642+ }643+ if err := json.Unmarshal(data, dst); err != nil {644+ return fmt.Errorf("decode %s: %w", path, err)645+ }646+ return nil647+}@@ -0,0 +1,110 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+func TestResolvedCategoryColor(t *testing.T) {10+ tests := []struct {11+ name string12+ group rawGroup13+ category rawCategory14+ want string15+ }{16+ {17+ name: "category overrides group",18+ group: rawGroup{Color: "38344C"},19+ category: rawCategory{Color: "aaacae"},20+ want: "#AAACAE",21+ },22+ {23+ name: "group fallback",24+ group: rawGroup{Color: "38344C"},25+ category: rawCategory{},26+ want: "#38344C",27+ },28+ {29+ name: "invalid color omitted",30+ group: rawGroup{Color: "not-a-color"},31+ category: rawCategory{},32+ want: "",33+ },34+ }35+36+ for _, tt := range tests {37+ t.Run(tt.name, func(t *testing.T) {38+ if got := resolvedCategoryColor(tt.group, tt.category); got != tt.want {39+ t.Fatalf("resolvedCategoryColor() = %q, want %q", got, tt.want)40+ }41+ })42+ }43+}44+45+func TestAttachGameIcons(t *testing.T) {46+ source := t.TempDir()47+ output := t.TempDir()48+ if err := os.MkdirAll(filepath.Join(source, "icons"), 0o755); err != nil {49+ t.Fatal(err)50+ }51+ const svg = `<svg viewBox="0 0 24 24"><path fill="currentColor" d="M0 0h24v24H0z"/></svg>`52+ if err := os.WriteFile(filepath.Join(source, "icons", "pokemon_center.svg"), []byte(svg), 0o644); err != nil {53+ t.Fatal(err)54+ }55+56+ game := catalogGame{57+ Slug: "pokemon-red-blue-yellow",58+ Maps: []catalogMap{{59+ Groups: []catalogGroup{{60+ Categories: []catalogCategory{61+ {Icon: "pokemon_center"},62+ {Icon: "missing"},63+ },64+ }},65+ }},66+ }67+ if err := attachGameIcons(source, output, &game); err != nil {68+ t.Fatal(err)69+ }70+71+ categories := game.Maps[0].Groups[0].Categories72+ if got, want := categories[0].IconAsset, "pokemon-red-blue-yellow/pokemon_center.svg"; got != want {73+ t.Fatalf("icon asset = %q, want %q", got, want)74+ }75+ if categories[1].IconAsset != "" {76+ t.Fatalf("missing icon asset = %q, want empty", categories[1].IconAsset)77+ }78+ data, err := os.ReadFile(filepath.Join(output, categories[0].IconAsset))79+ if err != nil {80+ t.Fatal(err)81+ }82+ if got := string(data); got != svg {83+ t.Fatalf("copied SVG = %q, want %q", got, svg)84+ }85+}86+87+func TestBuildGameSkipsMapWithoutSnapshotIndex(t *testing.T) {88+ archiveRoot := t.TempDir()89+ gamePath := filepath.Join(archiveRoot, "games", "pokemon-red-blue-yellow-246")90+ if err := os.MkdirAll(filepath.Join(gamePath, "maps", "red-blue-847"), 0o755); err != nil {91+ t.Fatal(err)92+ }93+94+ game, err := buildGame(95+ archiveRoot,96+ t.TempDir(),97+ t.TempDir(),98+ archiveGame{99+ Directory: "games/pokemon-red-blue-yellow-246",100+ ID: 246,101+ Title: "Pokémon Red/Blue/Yellow",102+ },103+ )104+ if err != nil {105+ t.Fatal(err)106+ }107+ if len(game.Maps) != 0 {108+ t.Fatalf("maps = %d, want 0", len(game.Maps))109+ }110+}@@ -0,0 +1,343 @@
1+// Command stitch builds the z13 canvases embedded by the gamemap demo.2+// It only emits maps with a snapshot and a complete tile set for every layer.3+package main4+5+import (6+ "encoding/json"7+ "errors"8+ "flag"9+ "fmt"10+ "image"11+ "image/draw"12+ _ "image/jpeg"13+ "image/png"14+ _ "image/png"15+ "os"16+ "path/filepath"17+ "sort"18+ "strconv"19+ "strings"20+)21+22+const (23+ zoom = 1324+ firstTile = 406425+ tileSize = 25626+ gridSize = 819227+)28+29+type archive struct {30+ Games []archiveGame `json:"games"`31+}32+33+type archiveGame struct {34+ Directory string `json:"directory"`35+ Title string `json:"title"`36+}37+38+type snapshotIndex struct {39+ CapturedAt string `json:"capturedAt"`40+ ContentHash string `json:"contentHash"`41+}42+43+type rawMap struct {44+ Title string `json:"title"`45+ Slug string `json:"slug"`46+ Config rawConfig `json:"config"`47+ Game rawGame `json:"game"`48+}49+50+type rawGame struct {51+ Slug string `json:"slug"`52+}53+54+type rawConfig struct {55+ TileSets []rawTileSet `json:"tile_sets"`56+}57+58+type rawTileSet struct {59+ Name string `json:"name"`60+ Path string `json:"path"`61+ Bounds map[string]rawTileSetBound `json:"bounds"`62+}63+64+type rawTileSetBound struct {65+ X rawRange `json:"x"`66+ Y rawRange `json:"y"`67+}68+69+type rawRange struct {70+ Min int `json:"min"`71+ Max int `json:"max"`72+}73+74+type tileRecord struct {75+ ContentHash string `json:"contentHash"`76+ Status string `json:"status"`77+ TileSetID int64 `json:"tileSetId"`78+ URL string `json:"url"`79+ X int `json:"x"`80+ Y int `json:"y"`81+ Zoom int `json:"zoom"`82+}83+84+type readyMap struct {85+ raw rawMap86+ mapDir string87+ byPath map[string][]tileRecord88+ tileSets []rawTileSet89+}90+91+func main() {92+ source := flag.String("source", "", "FMG archive root")93+ output := flag.String("output", "", "stitched PNG destination")94+ flag.Parse()95+96+ if *source == "" || *output == "" {97+ fmt.Fprintln(os.Stderr, "stitch: -source and -output are required")98+ os.Exit(2)99+ }100+ if err := run(*source, *output); err != nil {101+ fmt.Fprintln(os.Stderr, "stitch:", err)102+ os.Exit(1)103+ }104+}105+106+func run(source, output string) error {107+ var index archive108+ if err := readJSON(filepath.Join(source, "archive.json"), &index); err != nil {109+ return err110+ }111+ if err := os.MkdirAll(output, 0o755); err != nil {112+ return fmt.Errorf("create output: %w", err)113+ }114+115+ for _, game := range index.Games {116+ mapDirs, err := filepath.Glob(filepath.Join(source, game.Directory, "maps", "*"))117+ if err != nil {118+ return err119+ }120+ sort.Strings(mapDirs)121+ for _, mapDir := range mapDirs {122+ info, err := os.Stat(mapDir)123+ if err != nil {124+ return err125+ }126+ if !info.IsDir() {127+ continue128+ }129+ ready, reason, err := inspectMap(mapDir)130+ if err != nil {131+ return err132+ }133+ if ready == nil {134+ fmt.Printf("skip %s / %s: %s\n", game.Title, filepath.Base(mapDir), reason)135+ continue136+ }137+ for _, set := range ready.tileSets {138+ suffix := ""139+ if len(ready.tileSets) > 1 {140+ suffix = slugify(set.Name)141+ }142+ name := imageName(ready.raw.Game.Slug, ready.raw.Slug, suffix)143+ destination := filepath.Join(output, name)144+ if _, err := os.Stat(destination); err == nil {145+ fmt.Printf("keep %s / %s / %s\n", game.Title, ready.raw.Title, set.Name)146+ continue147+ } else if !errors.Is(err, os.ErrNotExist) {148+ return err149+ }150+ fmt.Printf("stitch %s / %s / %s\n", game.Title, ready.raw.Title, set.Name)151+ if err := stitch(destination, ready.mapDir, ready.byPath[set.Path]); err != nil {152+ return fmt.Errorf("%s: %w", name, err)153+ }154+ }155+ }156+ }157+ return nil158+}159+160+func inspectMap(mapDir string) (*readyMap, string, error) {161+ var snapshots []snapshotIndex162+ if err := readJSON(filepath.Join(mapDir, "snapshots", "index.json"), &snapshots); err != nil {163+ if errors.Is(err, os.ErrNotExist) {164+ return nil, "no snapshot index", nil165+ }166+ return nil, "", err167+ }168+ if len(snapshots) == 0 {169+ return nil, "no captured snapshot", nil170+ }171+ sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].CapturedAt < snapshots[j].CapturedAt })172+173+ var raw rawMap174+ latest := snapshots[len(snapshots)-1]175+ if err := readJSON(filepath.Join(mapDir, "snapshots", "map", latest.ContentHash+".json"), &raw); err != nil {176+ return nil, "", err177+ }178+ if len(raw.Config.TileSets) == 0 {179+ return nil, "no configured tile sets", nil180+ }181+182+ var records []tileRecord183+ if err := readJSON(filepath.Join(mapDir, "tiles", "index.json"), &records); err != nil {184+ if errors.Is(err, os.ErrNotExist) {185+ return nil, "no tile index", nil186+ }187+ return nil, "", err188+ }189+ byPath := make(map[string][]tileRecord)190+ for _, record := range records {191+ if record.Zoom != zoom || record.Status != "cached" || record.ContentHash == "" {192+ continue193+ }194+ path := tileSetPath(record.URL)195+ if path != "" {196+ byPath[path] = append(byPath[path], record)197+ }198+ }199+200+ for _, set := range raw.Config.TileSets {201+ tiles := byPath[set.Path]202+ if len(tiles) == 0 {203+ return nil, fmt.Sprintf("layer %q has no z13 tiles", set.Name), nil204+ }205+ expected := requiredTileCount(set)206+ if len(tiles) != expected {207+ return nil, fmt.Sprintf("layer %q has %d/%d z13 tiles", set.Name, len(tiles), expected), nil208+ }209+ for _, tile := range tiles {210+ if _, err := tilePath(mapDir, tile); err != nil {211+ if errors.Is(err, os.ErrNotExist) {212+ return nil, fmt.Sprintf("layer %q is missing tile %d/%d", set.Name, tile.X, tile.Y), nil213+ }214+ return nil, "", err215+ }216+ }217+ }218+219+ return &readyMap{220+ raw: raw,221+ mapDir: mapDir,222+ byPath: byPath,223+ tileSets: raw.Config.TileSets,224+ }, "", nil225+}226+227+func requiredTileCount(set rawTileSet) int {228+ if bounds, ok := set.Bounds[strconv.Itoa(zoom)]; ok {229+ return (bounds.X.Max - bounds.X.Min + 1) * (bounds.Y.Max - bounds.Y.Min + 1)230+ }231+ return (gridSize / tileSize) * (gridSize / tileSize)232+}233+234+func stitch(destination, mapDir string, records []tileRecord) error {235+ canvas := image.NewRGBA(image.Rect(0, 0, gridSize, gridSize))236+ for _, record := range records {237+ x := (record.X - firstTile) * tileSize238+ y := (record.Y - firstTile) * tileSize239+ if x < 0 || y < 0 || x+tileSize > gridSize || y+tileSize > gridSize {240+ return fmt.Errorf("tile %d/%d falls outside the z13 canvas", record.X, record.Y)241+ }242+ path, err := tilePath(mapDir, record)243+ if err != nil {244+ return err245+ }246+ file, err := os.Open(path)247+ if err != nil {248+ return err249+ }250+ tile, _, decodeErr := image.Decode(file)251+ closeErr := file.Close()252+ if decodeErr != nil {253+ return fmt.Errorf("decode %s: %w", path, decodeErr)254+ }255+ if closeErr != nil {256+ return closeErr257+ }258+ draw.Draw(canvas, image.Rect(x, y, x+tileSize, y+tileSize), tile, tile.Bounds().Min, draw.Src)259+ }260+261+ temp, err := os.CreateTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".*")262+ if err != nil {263+ return err264+ }265+ tempPath := temp.Name()266+ defer os.Remove(tempPath)267+ if err := png.Encode(temp, canvas); err != nil {268+ temp.Close()269+ return err270+ }271+ if err := temp.Close(); err != nil {272+ return err273+ }274+ if err := os.Chmod(tempPath, 0o644); err != nil {275+ return err276+ }277+ return os.Rename(tempPath, destination)278+}279+280+func tilePath(mapDir string, record tileRecord) (string, error) {281+ pattern := filepath.Join(282+ mapDir,283+ "tiles",284+ "set-"+strconv.FormatInt(record.TileSetID, 10),285+ strconv.Itoa(zoom),286+ strconv.Itoa(record.X),287+ strconv.Itoa(record.Y)+".*",288+ )289+ matches, err := filepath.Glob(pattern)290+ if err != nil {291+ return "", err292+ }293+ if len(matches) == 0 {294+ return "", os.ErrNotExist295+ }296+ return matches[0], nil297+}298+299+func tileSetPath(rawURL string) string {300+ const marker = "/games/"301+ start := strings.Index(rawURL, marker)302+ if start < 0 {303+ return ""304+ }305+ path := rawURL[start+len(marker):]306+ end := strings.Index(path, "/"+strconv.Itoa(zoom)+"/")307+ if end < 0 {308+ return ""309+ }310+ return path[:end]311+}312+313+func imageName(game, mapSlug, suffix string) string {314+ name := game + "__" + mapSlug315+ if suffix != "" {316+ name += "__" + suffix317+ }318+ return name + "__z13.png"319+}320+321+func slugify(value string) string {322+ return strings.Trim(strings.Map(func(r rune) rune {323+ switch {324+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':325+ return r326+ case r >= 'A' && r <= 'Z':327+ return r + ('a' - 'A')328+ default:329+ return '-'330+ }331+ }, value), "-")332+}333+334+func readJSON(path string, dst any) error {335+ data, err := os.ReadFile(path)336+ if err != nil {337+ return fmt.Errorf("read %s: %w", path, err)338+ }339+ if err := json.Unmarshal(data, dst); err != nil {340+ return fmt.Errorf("decode %s: %w", path, err)341+ }342+ return nil343+}@@ -0,0 +1,45 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+func TestTilePathUsesXThenY(t *testing.T) {10+ mapDir := t.TempDir()11+ want := filepath.Join(mapDir, "tiles", "set-42", "13", "4076", "4064.jpg")12+ if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil {13+ t.Fatal(err)14+ }15+ if err := os.WriteFile(want, nil, 0o644); err != nil {16+ t.Fatal(err)17+ }18+19+ got, err := tilePath(mapDir, tileRecord{TileSetID: 42, X: 4076, Y: 4064})20+ if err != nil {21+ t.Fatal(err)22+ }23+ if got != want {24+ t.Fatalf("tilePath() = %q, want %q", got, want)25+ }26+}27+28+func TestTileSetPath(t *testing.T) {29+ const rawURL = "https://tiles.mapgenie.io/games/skyrim/skyrim/default-v1/13/4076/4064.jpg"30+ if got, want := tileSetPath(rawURL), "skyrim/skyrim/default-v1"; got != want {31+ t.Fatalf("tileSetPath() = %q, want %q", got, want)32+ }33+}34+35+func TestRequiredTileCount(t *testing.T) {36+ if got, want := requiredTileCount(rawTileSet{}), 1024; got != want {37+ t.Fatalf("unbounded requiredTileCount() = %d, want %d", got, want)38+ }39+ set := rawTileSet{Bounds: map[string]rawTileSetBound{40+ "13": {X: rawRange{Min: 4064, Max: 4078}, Y: rawRange{Min: 4064, Max: 4075}},41+ }}42+ if got, want := requiredTileCount(set), 180; got != want {43+ t.Fatalf("bounded requiredTileCount() = %d, want %d", got, want)44+ }45+}