clone

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

feat: resolve names spelled inside each other, and adopt what maps

Dami committed 79c9044
The source-named group was quietly promising that everything under it
was content only that source knew, and the promise did not hold: two
sources spell one apartment "Northside Apartment" and "Northside,
Watson Apartment", the names disagreed literally, the pins sat farther
apart than the nameless-neighbour radius reached, and the same place
was drawn twice -- once dressed as a discovery.

Resolution now hears a name spelled inside another as the same name
when the pins share a category and both spellings carry at least two
words, and the neighbour radius listens to the alignment's own
residuals instead of assuming precision. What still stays distinct
joins the serving category its own category maps onto -- recorded as an
adoption in the ledger, where provenance belongs -- and the source
group keeps only concepts the serving map does not have at all. Night
City settles at 272 matched, 37 added of which 7 adopted, 59 held; the
IGN Wiki group holds exactly the five things only the wiki knows.
commit
79c9044671ec14adffdad2be97992540d1c7f737
parent
fec215ae1b69d11e07611189e52b3f77794ca12e
author
Dami <dami.etoile@gmail.com>
1 file changed, +156 -64
Mtools/generate/merge.go220+++++++++++++++++++++---------
M tools/generate/merge.go +156 -64
@@ -31,7 +31,7 @@ import (
3131 const (3232 matchRadiusPx = 1603333 separateRadiusPx = 32034- nearbyRadiusPx = 4834+ nearbyFloorPx = 483535 )3636 3737 // categoryEquivalents maps one source's category slugs onto another's where
@@ -59,6 +59,7 @@ type mergedSource struct {
5959 DonorPins int `json:"donorPins"`6060 Matched []mergedPair `json:"matched,omitempty"`6161 Added int `json:"added"`62+ Adopted []adoptedPin `json:"adopted,omitempty"`6263 Held []heldPin `json:"held,omitempty"`6364 Rejected []heldPin `json:"rejected,omitempty"`6465 Alignment string `json:"alignment"`
@@ -75,6 +76,13 @@ type mergedPair struct {
7576 Enriched bool `json:"e,omitempty"`7677 }7778 79+// adoptedPin records a donor-only pin that joined one of the serving map's80+// own categories: provenance for a pin the legend does not single out.81+type adoptedPin struct {82+ Donor int64 `json:"d"`83+ Into string `json:"into"`84+}85+7886 // heldPin is a donor pin the merge did not carry, with the reason.7987 type heldPin struct {8088 Donor int64 `json:"d"`
@@ -174,34 +182,20 @@ func mergeMap(
174182 sourceLabel := sourceLabelOf(donor, donorGame)175183 merge := &mergedSource{Source: sourceLabel, Alignment: report.String()}176184 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- }185+ // A nameless neighbour matters out to where the alignment's own noise186+ // reaches: two sources place the same shop apart by their residuals, so187+ // the radius listens to the fit rather than assuming precision.188+ nearbyRadius := math.Max(nearbyFloorPx, 2*report.P90Px)189+190+ // What the serving map already holds, by name, token, and place.191+ index := indexWinner(winner, winnerGrid)200192 201193 equivalents := categoryEquivalents[winnerGame.Slug]202194 203- // Donor categories survive with the pins that stay distinct; everything204- // else lands in the ledger.195+ // A donor pin that stays distinct joins the serving category its own196+ // category maps onto, and the ledger records the adoption; only concepts197+ // the serving map does not have at all keep their donor categories, so198+ // the source-named group holds nothing but what is truly source-specific.205199 var keptCategories []catalogCategory206200 for _, group := range donor.Groups {207201 for _, category := range group.Categories {
@@ -212,13 +206,14 @@ func mergeMap(
212206 if equivalent, ok := equivalents[donorKey]; ok {213207 mappedKey = equivalent214208 }209+ adoptive := index.categories[mappedKey]215210 for _, location := range category.Locations {216211 merge.DonorPins++217212 x, y := affine.Apply(218213 projectX(location.Longitude, donorGrid),219214 projectY(location.Latitude, donorGrid),220215 )221- outcome := resolvePin(location, x, y, winnerByName, winnerPlaced, mappedKey, winnerCategoryByKey[mappedKey])216+ outcome := resolvePin(location, x, y, index, mappedKey, nearbyRadius)222217 switch outcome.kind {223218 case pinMatched:224219 pair := mergedPair{
@@ -245,18 +240,25 @@ func mergeMap(
245240 })246241 continue247242 }248- if holder, taken := winnerIDs[location.ID]; taken {243+ if holder, taken := index.ids[location.ID]; taken {249244 return fmt.Errorf("pin id %d (%s) collides with serving pin %q",250245 location.ID, location.Title, holder)251246 }252- winnerIDs[location.ID] = location.Title247+ index.ids[location.ID] = location.Title253248 moved := location254249 moved.Longitude = unprojectLongitude(x, winnerGrid)255250 moved.Latitude = unprojectLatitude(y, winnerGrid)256251 moved.RegionID = nil257252 moved.Shard = 0258- kept.Locations = append(kept.Locations, moved)259253 merge.Added++254+ if adoptive != nil {255+ adoptive.Locations = append(adoptive.Locations, moved)256+ merge.Adopted = append(merge.Adopted, adoptedPin{257+ Donor: location.ID, Into: mappedKey,258+ })259+ } else {260+ kept.Locations = append(kept.Locations, moved)261+ }260262 }261263 }262264 if len(kept.Locations) == 0 {
@@ -294,10 +296,51 @@ func mergeMap(
294296 return nil295297 }296298 299+// winnerIndex is everything the serving map holds, arranged for resolution:300+// each pin with its place, name, and name tokens; the categories by key; and301+// the identifiers already spoken for.302+type winnerIndex struct {303+ pins []placedPin304+ byName map[string][]int305+ categories map[string]*catalogCategory306+ ids map[int64]string307+}308+297309 type placedPin struct {298310 location *catalogLocation299311 category string300312 x, y float64313+ tokens map[string]bool314+}315+316+func indexWinner(winner *catalogMap, grid tileGrid) *winnerIndex {317+ index := &winnerIndex{318+ byName: make(map[string][]int),319+ categories: make(map[string]*catalogCategory),320+ ids: make(map[int64]string),321+ }322+ for groupIndex := range winner.Groups {323+ for categoryIndex := range winner.Groups[groupIndex].Categories {324+ category := &winner.Groups[groupIndex].Categories[categoryIndex]325+ if _, held := index.categories[category.Icon]; !held {326+ index.categories[category.Icon] = category327+ }328+ for locationIndex := range category.Locations {329+ location := &category.Locations[locationIndex]330+ name := blend.NormalizeTitle(location.Title)331+ index.byName[name] = append(index.byName[name], len(index.pins))332+ index.pins = append(index.pins, placedPin{333+ location: location,334+ category: category.Icon,335+ x: projectX(location.Longitude, grid),336+ y: projectY(location.Latitude, grid),337+ tokens: tokensOf(name),338+ })339+ index.ids[location.ID] = location.Title340+ }341+ }342+ }343+ return index301344 }302345 303346 type pinOutcome struct {
@@ -315,62 +358,111 @@ const (
315358 316359 // resolvePin decides what one donor pin is, against everything the serving317360 // 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.361+// place, and so is a name one source spells inside the other's -- "Northside362+// Apartment" inside "Northside, Watson Apartment" -- when the pins share a363+// category. The same name far beyond the radius is a different place bearing364+// it; the stretch between is left undecided. A nameless neighbour inside the365+// same mapped category is likewise held rather than guessed -- proximity366+// alone never merges -- and only a pin resembling nothing at all is added.322367 func resolvePin(323368 donor catalogLocation,324369 x, y float64,325- byName map[string][]*catalogLocation,326- placed []placedPin,370+ index *winnerIndex,327371 mappedKey string,328- categoryShared bool,372+ nearbyRadius float64,329373 ) pinOutcome {330374 name := blend.NormalizeTitle(donor.Title)375+ donorTokens := tokensOf(name)376+377+ var nearest *placedPin378+ nearestDistance := math.Inf(1)379+ consider := func(pin *placedPin, distance float64) {380+ if distance < nearestDistance {381+ nearest, nearestDistance = pin, distance382+ }383+ }331384 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- }385+ for _, at := range index.byName[name] {386+ pin := &index.pins[at]387+ consider(pin, math.Hypot(pin.x-x, pin.y-y))339388 }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)}389+ // One source's name written inside the other's counts only with the390+ // category agreeing: a bare "Apartment" must not roam the map for a391+ // long-named cousin.392+ if nearest == nil && len(donorTokens) >= 2 {393+ for at := range index.pins {394+ pin := &index.pins[at]395+ if pin.category != mappedKey || !tokenSubset(donorTokens, pin.tokens) {396+ continue397+ }398+ consider(pin, math.Hypot(pin.x-x, pin.y-y))347399 }348400 }349401 }350- if categoryShared {351- for _, candidate := range placed {352- if candidate.category != mappedKey {402+ if nearest != nil {403+ switch {404+ case nearestDistance <= matchRadiusPx:405+ return pinOutcome{kind: pinMatched, match: nearest.location, distance: nearestDistance}406+ case nearestDistance <= separateRadiusPx:407+ return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(408+ "named like %q %.0fpx away; too far to merge, too near to double",409+ nearest.location.Title, nearestDistance)}410+ }411+ }412+ if _, shared := index.categories[mappedKey]; shared {413+ for at := range index.pins {414+ pin := &index.pins[at]415+ if pin.category != mappedKey {353416 continue354417 }355- dx, dy := candidate.x-x, candidate.y-y356- if dx*dx+dy*dy <= nearbyRadiusPx*nearbyRadiusPx {418+ if math.Hypot(pin.x-x, pin.y-y) <= nearbyRadius {357419 return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(358- "beside %q in the same category; names disagree", candidate.location.Title)}420+ "beside %q in the same category; names disagree", pin.location.Title)}359421 }360422 }361423 }362424 return pinOutcome{kind: pinDistinct}363425 }364426 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)427+// tokenSubset reports whether one name is spelled entirely inside the other,428+// either way round. The shorter side must carry at least two words: a single429+// word inside a longer name -- every "Apartment" inside every apartment --430+// says nothing.431+func tokenSubset(a, b map[string]bool) bool {432+ small, big := a, b433+ if len(small) > len(big) {434+ small, big = big, small435+ }436+ if len(small) < 2 {437+ return false438+ }439+ for token := range small {440+ if !big[token] {441+ return false371442 }372443 }373- return math.Inf(1)444+ return true445+}446+447+func tokensOf(normalized string) map[string]bool {448+ tokens := make(map[string]bool)449+ start := -1450+ for at, r := range normalized {451+ if r != ' ' {452+ if start < 0 {453+ start = at454+ }455+ continue456+ }457+ if start >= 0 {458+ tokens[normalized[start:at]] = true459+ start = -1460+ }461+ }462+ if start >= 0 {463+ tokens[normalized[start:]] = true464+ }465+ return tokens374466 }375467 376468 // carryIcon brings a merged category's icon across from the donor's archive