clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: fold every source's pins into the serving map

Dami committed fec215a
The serving capture's bundle now absorbs the other sources' pins
instead of shadowing them. Each donor map aligns into the serving map's
world by the same shared-name affine the raster variants stand on, and
every donor pin is resolved against what the map already holds: the
same name near where the alignment predicts is the same place, recorded
as a matched pair; a pin only the donor knows arrives under its own
source-named group with its own icons, carried under prefixed names so
they displace nothing; and a pin the resolution cannot be sure about --
the same name suspiciously far, a nameless neighbour in the same
category -- is held back with its reason written down, because
proximity alone never merges. A matched serving pin with nothing to say
takes the donor's description.

The payload carries the whole account: alignment quality, matched
pairs, additions, and the held ledger, so the merge is a documented
derivation over immutable captures. A gate audits every donor pin as
accounted for and every identifier as unique before the bundle writes,
and the merged version is as fresh as its freshest ingredient, so a
newer donor capture re-versions the merged bundle on its own. Night
City: 368 wiki pins resolve to 231 matched, 62 added, 75 held -- the
serving map now draws 885 places under 47 dressed categories.
commit
fec215ae1b69d11e07611189e52b3f77794ca12e
parent
58fe99834d99b705e93c911f4064477aab1250d5
author
Dami <dami.etoile@gmail.com>
6 files changed, +535 -9
Minternal/blend/blend.go10+-
Minternal/blend/blend_test.go4+-
Mtools/generate/main.go10+
Atools/generate/merge.go504++++++++++++++++++++++++++++++
Mtools/generate/split.go6+-
Mtools/maturity/main.go10+-
M internal/blend/blend.go +6 -4
M internal/blend/blend_test.go +2 -2
M tools/generate/main.go +10 -0
A tools/generate/merge.go +504 -0
@@ -0,0 +1,504 @@
1+package main2+3+// Merging keeps every source's pins. When two sources capture one game, the4+// newest capture's bundle is the one the registry serves, and until now the5+// other source's pins simply rode along unseen in a shadowed bundle. Now the6+// serving game absorbs them: each other source's map is aligned into the7+// serving map's world by the transformation their shared named places8+// determine -- the same alignment the raster variants stand on -- and every9+// pin is resolved against what the serving map already holds. A pin both10+// sources place is recorded as the same place, never drawn twice; a pin only11+// the other source knows is added under its own source-named group, icons12+// and all; a pin the resolution cannot be sure about is held back and said13+// so. The payload carries the whole account -- what matched, what was added,14+// what was held -- so the merge is a documented derivation over immutable15+// captures, not an edit.16+17+import (18+ "fmt"19+ "math"20+ "sort"21+22+ "github.com/FelineStateMachine/atlas/internal/blend"23+)24+25+// Resolution distances, in world pixels of an 8192-pixel square. A confirmed26+// match needs the same name near where the alignment predicts; farther than27+// twice the radius the same name is treated as a different place bearing it.28+// Between the two, and for nameless proximity inside a shared category, the29+// resolution declines to decide: a pin held back with its reason recorded30+// costs a curiosity, where a wrong merge costs a place.31+const (32+ matchRadiusPx = 16033+ separateRadiusPx = 32034+ nearbyRadiusPx = 4835+)36+37+// categoryEquivalents maps one source's category slugs onto another's where38+// the concepts are known to be the same thing. Slugs equal after39+// normalization pair automatically; everything else stays source-specific40+// rather than being guessed together.41+var categoryEquivalents = map[string]map[string]string{42+ "cyberpunk-2077": {43+ "ripper-doc": "ripperdoc",44+ "tarot-card": "tarot-graffiti",45+ "gun-shop": "weapon-shop",46+ "clothes-shop": "clothing-vendor",47+ "medicine-shop": "medpoint",48+ "melee-shop": "melee-vendor",49+ "netrunner-shop": "netrunner",50+ },51+}52+53+// mergedSource is one source's account of its merge, carried in the map's54+// payload: the alignment it stood on, what became of every donor pin, and55+// the ledger a later pass -- or a curious reader -- can audit the decisions56+// by.57+type mergedSource struct {58+ Source string `json:"source"`59+ DonorPins int `json:"donorPins"`60+ Matched []mergedPair `json:"matched,omitempty"`61+ Added int `json:"added"`62+ Held []heldPin `json:"held,omitempty"`63+ Rejected []heldPin `json:"rejected,omitempty"`64+ Alignment string `json:"alignment"`65+}66+67+// mergedPair records one place both sources pin: the donor pin, the serving68+// pin it resolved to, and how far apart the alignment put them.69+type mergedPair struct {70+ Donor int64 `json:"d"`71+ Winner int64 `json:"w"`72+ DistancePx int `json:"px"`73+ // Enriched marks a pair whose serving pin had nothing to say and took74+ // the donor's description.75+ Enriched bool `json:"e,omitempty"`76+}77+78+// heldPin is a donor pin the merge did not carry, with the reason.79+type heldPin struct {80+ Donor int64 `json:"d"`81+ Title string `json:"t"`82+ Reason string `json:"why"`83+}84+85+// mergeAcrossSources folds every game slug's catalog entries into one served86+// account: the newest capture absorbs the others' pins, and every source87+// still emits its own bundle untouched, so nothing is lost however the merge88+// policy evolves.89+func mergeAcrossSources(games []catalogGame, shared tileGrid) ([]catalogGame, error) {90+ bySlug := make(map[string][]int)91+ var order []string92+ for index, game := range games {93+ if _, seen := bySlug[game.Slug]; !seen {94+ order = append(order, game.Slug)95+ }96+ bySlug[game.Slug] = append(bySlug[game.Slug], index)97+ }98+ sort.Strings(order)99+100+ for _, slug := range order {101+ group := bySlug[slug]102+ if len(group) < 2 {103+ continue104+ }105+ winner := group[0]106+ for _, candidate := range group[1:] {107+ if newestCapture(games[candidate]) > newestCapture(games[winner]) {108+ winner = candidate109+ }110+ }111+ for _, donor := range group {112+ if donor == winner {113+ continue114+ }115+ if err := mergeGame(&games[winner], &games[donor], shared); err != nil {116+ return nil, fmt.Errorf("merge %s: %w", slug, err)117+ }118+ }119+ }120+ return games, nil121+}122+123+func newestCapture(game catalogGame) string {124+ newest := ""125+ for _, m := range game.Maps {126+ if m.UpdatedAt > newest {127+ newest = m.UpdatedAt128+ }129+ }130+ return newest131+}132+133+func mergeGame(winner, donor *catalogGame, shared tileGrid) error {134+ winnerMaps := make(map[string]*catalogMap, len(winner.Maps))135+ for index := range winner.Maps {136+ winnerMaps[winner.Maps[index].Slug] = &winner.Maps[index]137+ }138+ for index := range donor.Maps {139+ donorMap := &donor.Maps[index]140+ target := winnerMaps[donorMap.Slug]141+ if target == nil {142+ fmt.Printf("merge %s: %s has no counterpart in the serving capture; its pins stay in their own bundle\n",143+ winner.Slug, donorMap.Slug)144+ continue145+ }146+ if target.Parent != "" || donorMap.Parent != "" {147+ fmt.Printf("merge %s: %s is a split sheet; merging across shards is not attempted\n",148+ winner.Slug, donorMap.Slug)149+ continue150+ }151+ if err := mergeMap(winner, target, donor, donorMap, shared); err != nil {152+ return err153+ }154+ }155+ return nil156+}157+158+func mergeMap(159+ winnerGame *catalogGame,160+ winner *catalogMap,161+ donorGame *catalogGame,162+ donor *catalogMap,163+ shared tileGrid,164+) error {165+ winnerGrid := gridOf(winner, shared)166+ donorGrid := gridOf(donor, shared)167+168+ affine, report, err := blend.Fit(anchorsOfMap(donor, donorGrid), anchorsOfMap(winner, winnerGrid))169+ if err != nil {170+ fmt.Printf("merge %s/%s: pins stay apart: %v\n", winnerGame.Slug, winner.Slug, err)171+ return nil172+ }173+174+ sourceLabel := sourceLabelOf(donor, donorGame)175+ merge := &mergedSource{Source: sourceLabel, Alignment: report.String()}176+177+ // What the serving map already holds, by name and by place.178+ winnerByName := make(map[string][]*catalogLocation)179+ var winnerPlaced []placedPin180+ winnerIDs := make(map[int64]string)181+ winnerCategoryByKey := make(map[string]bool)182+ for groupIndex := range winner.Groups {183+ for categoryIndex := range winner.Groups[groupIndex].Categories {184+ category := &winner.Groups[groupIndex].Categories[categoryIndex]185+ winnerCategoryByKey[category.Icon] = true186+ for locationIndex := range category.Locations {187+ location := &category.Locations[locationIndex]188+ name := blend.NormalizeTitle(location.Title)189+ winnerByName[name] = append(winnerByName[name], location)190+ winnerPlaced = append(winnerPlaced, placedPin{191+ location: location,192+ category: category.Icon,193+ x: projectX(location.Longitude, winnerGrid),194+ y: projectY(location.Latitude, winnerGrid),195+ })196+ winnerIDs[location.ID] = location.Title197+ }198+ }199+ }200+201+ equivalents := categoryEquivalents[winnerGame.Slug]202+203+ // Donor categories survive with the pins that stay distinct; everything204+ // else lands in the ledger.205+ var keptCategories []catalogCategory206+ for _, group := range donor.Groups {207+ for _, category := range group.Categories {208+ kept := category209+ kept.Locations = nil210+ donorKey := category.Icon211+ mappedKey := donorKey212+ if equivalent, ok := equivalents[donorKey]; ok {213+ mappedKey = equivalent214+ }215+ for _, location := range category.Locations {216+ merge.DonorPins++217+ x, y := affine.Apply(218+ projectX(location.Longitude, donorGrid),219+ projectY(location.Latitude, donorGrid),220+ )221+ outcome := resolvePin(location, x, y, winnerByName, winnerPlaced, mappedKey, winnerCategoryByKey[mappedKey])222+ switch outcome.kind {223+ case pinMatched:224+ pair := mergedPair{225+ Donor: location.ID,226+ Winner: outcome.match.ID,227+ DistancePx: int(outcome.distance + 0.5),228+ }229+ // Field-level: a serving pin with nothing to say takes230+ // the donor's words rather than silencing them.231+ if outcome.match.Description == "" && location.Description != "" {232+ outcome.match.Description = location.Description233+ pair.Enriched = true234+ }235+ merge.Matched = append(merge.Matched, pair)236+ case pinHeld:237+ merge.Held = append(merge.Held, heldPin{238+ Donor: location.ID, Title: location.Title, Reason: outcome.reason,239+ })240+ case pinDistinct:241+ if x < 0 || y < 0 || x > float64(shared.Size) || y > float64(shared.Size) {242+ merge.Rejected = append(merge.Rejected, heldPin{243+ Donor: location.ID, Title: location.Title,244+ Reason: "outside the shared world",245+ })246+ continue247+ }248+ if holder, taken := winnerIDs[location.ID]; taken {249+ return fmt.Errorf("pin id %d (%s) collides with serving pin %q",250+ location.ID, location.Title, holder)251+ }252+ winnerIDs[location.ID] = location.Title253+ moved := location254+ moved.Longitude = unprojectLongitude(x, winnerGrid)255+ moved.Latitude = unprojectLatitude(y, winnerGrid)256+ moved.RegionID = nil257+ moved.Shard = 0258+ kept.Locations = append(kept.Locations, moved)259+ merge.Added++260+ }261+ }262+ if len(kept.Locations) == 0 {263+ continue264+ }265+ if err := carryIcon(winnerGame, donorGame, &kept, sourceLabel); err != nil {266+ return err267+ }268+ keptCategories = append(keptCategories, kept)269+ }270+ }271+272+ if len(keptCategories) > 0 {273+ winner.Groups = append(winner.Groups, catalogGroup{274+ ID: mergedGroupID(winner),275+ Title: sourceLabel,276+ Categories: keptCategories,277+ })278+ }279+ winner.Merged = append(winner.Merged, *merge)280+ winner.PinCount += merge.Added281+ // The merged account is as fresh as its freshest ingredient, so a newer282+ // donor capture re-versions the merged bundle even when the serving283+ // capture stood still.284+ if donor.UpdatedAt > winner.UpdatedAt {285+ winner.UpdatedAt = donor.UpdatedAt286+ }287+288+ if err := mergeGate(merge, winner); err != nil {289+ return err290+ }291+ fmt.Printf("merge %s/%s: %s: %d donor pins → %d matched (%d enriched) · %d added · %d held · %d outside\n",292+ winnerGame.Slug, winner.Slug, sourceLabel, merge.DonorPins, len(merge.Matched),293+ enrichedCount(merge), merge.Added, len(merge.Held), len(merge.Rejected))294+ return nil295+}296+297+type placedPin struct {298+ location *catalogLocation299+ category string300+ x, y float64301+}302+303+type pinOutcome struct {304+ kind int305+ match *catalogLocation306+ distance float64307+ reason string308+}309+310+const (311+ pinDistinct = iota312+ pinMatched313+ pinHeld314+)315+316+// resolvePin decides what one donor pin is, against everything the serving317+// map holds. The same name near where the alignment predicts is the same318+// place; the same name far beyond it is a different place bearing it; the319+// stretch between is left undecided. A nameless neighbour inside the same320+// mapped category is likewise held rather than guessed -- proximity alone321+// never merges -- and only a pin resembling nothing at all is added.322+func resolvePin(323+ donor catalogLocation,324+ x, y float64,325+ byName map[string][]*catalogLocation,326+ placed []placedPin,327+ mappedKey string,328+ categoryShared bool,329+) pinOutcome {330+ name := blend.NormalizeTitle(donor.Title)331+ if name != "" {332+ var nearest *catalogLocation333+ nearestDistance := 0.0334+ for _, candidate := range byName[name] {335+ distance := pinDistance(x, y, candidate, placed)336+ if nearest == nil || distance < nearestDistance {337+ nearest, nearestDistance = candidate, distance338+ }339+ }340+ if nearest != nil {341+ switch {342+ case nearestDistance <= matchRadiusPx:343+ return pinOutcome{kind: pinMatched, match: nearest, distance: nearestDistance}344+ case nearestDistance <= separateRadiusPx:345+ return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(346+ "same name %.0fpx away; too far to merge, too near to double", nearestDistance)}347+ }348+ }349+ }350+ if categoryShared {351+ for _, candidate := range placed {352+ if candidate.category != mappedKey {353+ continue354+ }355+ dx, dy := candidate.x-x, candidate.y-y356+ if dx*dx+dy*dy <= nearbyRadiusPx*nearbyRadiusPx {357+ return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(358+ "beside %q in the same category; names disagree", candidate.location.Title)}359+ }360+ }361+ }362+ return pinOutcome{kind: pinDistinct}363+}364+365+// pinDistance finds how far a point sits from a serving pin. The placed list366+// is small enough to scan; identity is by pointer.367+func pinDistance(x, y float64, location *catalogLocation, placed []placedPin) float64 {368+ for _, candidate := range placed {369+ if candidate.location == location {370+ return math.Hypot(candidate.x-x, candidate.y-y)371+ }372+ }373+ return math.Inf(1)374+}375+376+// carryIcon brings a merged category's icon across from the donor's archive377+// under a source-prefixed name, so it cannot displace a serving icon that378+// shares its key.379+func carryIcon(winnerGame, donorGame *catalogGame, category *catalogCategory, sourceLabel string) error {380+ if category.IconAsset == "" {381+ return nil382+ }383+ data, held := donorGame.Icons[category.IconAsset]384+ if !held {385+ category.IconAsset = ""386+ category.IconPicture = false387+ return nil388+ }389+ carried := slugifyLabel(sourceLabel) + "--" + category.IconAsset390+ if winnerGame.Icons == nil {391+ winnerGame.Icons = make(map[string][]byte)392+ }393+ winnerGame.Icons[carried] = data394+ category.IconAsset = carried395+ return nil396+}397+398+// mergedGroupID numbers the source's group away from every id the map399+// already uses.400+func mergedGroupID(winner *catalogMap) int64 {401+ used := make(map[int64]bool)402+ for _, group := range winner.Groups {403+ used[group.ID] = true404+ }405+ id := int64(1)406+ for used[id] {407+ id++408+ }409+ return id410+}411+412+func sourceLabelOf(donor *catalogMap, donorGame *catalogGame) string {413+ for _, v := range donor.Variants {414+ if v.Name != "" && v.Name != "Default" {415+ return v.Name416+ }417+ }418+ return donorGame.Title419+}420+421+func slugifyLabel(label string) string {422+ out := make([]rune, 0, len(label))423+ for _, r := range label {424+ switch {425+ case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':426+ out = append(out, r)427+ case r >= 'A' && r <= 'Z':428+ out = append(out, r+'a'-'A')429+ default:430+ if len(out) > 0 && out[len(out)-1] != '-' {431+ out = append(out, '-')432+ }433+ }434+ }435+ for len(out) > 0 && out[len(out)-1] == '-' {436+ out = out[:len(out)-1]437+ }438+ return string(out)439+}440+441+func gridOf(m *catalogMap, shared tileGrid) tileGrid {442+ if m.Grid == nil {443+ return shared444+ }445+ return tileGrid{446+ SourceZoom: m.Grid.SourceZoom,447+ FirstTile: m.Grid.FirstTile,448+ TileSize: shared.TileSize,449+ Size: shared.Size,450+ }451+}452+453+func anchorsOfMap(m *catalogMap, grid tileGrid) []blend.Anchor {454+ var anchors []blend.Anchor455+ for _, group := range m.Groups {456+ for _, category := range group.Categories {457+ for _, location := range category.Locations {458+ anchors = append(anchors, blend.Anchor{459+ Title: location.Title,460+ X: projectX(location.Longitude, grid),461+ Y: projectY(location.Latitude, grid),462+ })463+ }464+ }465+ }466+ return anchors467+}468+469+func enrichedCount(merge *mergedSource) int {470+ count := 0471+ for _, pair := range merge.Matched {472+ if pair.Enriched {473+ count++474+ }475+ }476+ return count477+}478+479+// mergeGate is the merge's own audit: every donor pin accounted for, no480+// identifier doubled, nothing the serving map held made worse. It fails the481+// build rather than writing a bundle that quietly lost something.482+func mergeGate(merge *mergedSource, winner *catalogMap) error {483+ accounted := len(merge.Matched) + merge.Added + len(merge.Held) + len(merge.Rejected)484+ if accounted != merge.DonorPins {485+ return fmt.Errorf("merge accounts for %d of %d donor pins", accounted, merge.DonorPins)486+ }487+ seen := make(map[int64]string)488+ counted := 0489+ for _, group := range winner.Groups {490+ for _, category := range group.Categories {491+ for _, location := range category.Locations {492+ if holder, taken := seen[location.ID]; taken {493+ return fmt.Errorf("pin id %d held by both %q and %q", location.ID, holder, location.Title)494+ }495+ seen[location.ID] = location.Title496+ counted++497+ }498+ }499+ }500+ if counted != winner.PinCount {501+ return fmt.Errorf("map holds %d pins but claims %d", counted, winner.PinCount)502+ }503+ return nil504+}
M tools/generate/split.go +5 -1
M tools/maturity/main.go +8 -2