clone

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

archive/golden-reference
atlas / tools / generate / merge.go
merge.go 31.3K
1package main23// 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.1617import (18 "bytes"19 "fmt"20 "math"21 "sort"2223 "github.com/FelineStateMachine/atlas/internal/blend"24 "github.com/FelineStateMachine/atlas/internal/semconv"25)2627// Resolution distances, in world pixels of an 8192-pixel square. A confirmed28// match needs the same name near where the alignment predicts; farther than29// twice the radius the same name is treated as a different place bearing it.30// Between the two, and for nameless proximity inside a shared category, the31// resolution declines to decide: a pin held back with its reason recorded32// costs a curiosity, where a wrong merge costs a place.33const (34 matchRadiusPx = 16035 separateRadiusPx = 32036 nearbyFloorPx = 4837)3839// categoryEquivalents names the shared concept where two sources spell one40// category differently, per game. It is applied by speakConventions, which41// writes the shared name onto each category as atlas.collection.key -- so the42// payloads themselves carry the merge identity, and the merge below reads43// only the attribute. Slugs equal after normalization pair automatically;44// everything else stays source-specific rather than being guessed together.45var categoryEquivalents = map[string]map[string]string{46 "cyberpunk-2077": {47 "ripper-doc": "ripperdoc",48 "tarot-card": "tarot-graffiti",49 "gun-shop": "weapon-shop",50 "clothes-shop": "clothing-vendor",51 "medicine-shop": "medpoint",52 "melee-shop": "melee-vendor",53 "netrunner-shop": "netrunner",54 },55}5657// categoryKey is a category's merge identity: the declared shared name when58// the payload carries one, its icon key otherwise -- which is today's59// behavior, named.60func categoryKey(icon string, attrs map[string]string) string {61 if key := attrs[semconv.KeyCollectionKey]; key != "" {62 return key63 }64 return icon65}6667// attributeMergePolicy says, attribute by attribute, which side of a matched68// pin wins. servingWins is the default for anything unlisted; donorFillsEmpty69// is the enrichment rule the description pioneered -- the serving side keeps70// what it has and takes only what it lacks. Every take is ledgered by key,71// so the payload accounts for its own composition.72type attrRule int7374const (75 servingWins attrRule = iota76 donorFillsEmpty77)7879var attributeMergePolicy = map[string]attrRule{80 semconv.KeyNoteText: donorFillsEmpty,81 semconv.KeyGeoLat: donorFillsEmpty,82 semconv.KeyGeoLon: donorFillsEmpty,83 semconv.KeyIconStd: donorFillsEmpty,84}8586// mergedSource is one source's account of its merge, carried in the map's87// payload: the alignment it stood on, what became of every donor pin, and88// the ledger a later pass -- or a curious reader -- can audit the decisions89// by.90type mergedSource struct {91 Source string `json:"source"`92 // Slug is the source's canonical name, the one the workbench's registry93 // speaks, so ledgers and plugin cards agree without translation.94 Slug string `json:"slug,omitempty"`95 // Origin marks the account of the source the map itself came from, so a96 // single-source map still says where it is from and a composed map's97 // unledgered pins have somewhere to answer to. DonorFeatures on an98 // origin account is simply the map's own tally at composition.99 Origin bool `json:"origin,omitempty"`100 // DonorFeatures is the donor's whole offering counted per kind. The101 // donorPins spelling it stood beside died with the v2 wire.102 DonorFeatures featureCounts `json:"donorFeatures"`103 Matched []mergedPair `json:"matched,omitempty"`104 Added int `json:"added"`105 // AddedShapes is reserved: no shape feature merges yet, so it is always106 // zero, but the key holds the ledger's place for the day one does.107 AddedShapes int `json:"addedShapes,omitempty"`108 Adopted []adoptedPin `json:"adopted,omitempty"`109 Held []heldPin `json:"held,omitempty"`110 Rejected []heldPin `json:"rejected,omitempty"`111 Enriched []categoryTake `json:"enrichedCategories,omitempty"`112 Alignment string `json:"alignment,omitempty"`113}114115// featureCounts counts features by kind: the three dimensionalities a116// collection may declare, as the ledger speaks of them.117type featureCounts struct {118 Point int `json:"point"`119 Path int `json:"path"`120 Area int `json:"area"`121}122123// heldShapeReason is the one reason a donor shape feature is held: matching124// is point-only for now, so every donor path and area goes on the record125// instead of vanishing. The gate reads the reason back off the ledger to126// tell shape holds from point holds.127const heldShapeReason = "shape features do not merge yet"128129// categoryTake records one attribute a serving category took from the130// donor's counterpart: which category, which key.131type categoryTake struct {132 Category string `json:"cat"`133 Key string `json:"k"`134}135136// canonicalSourceSlug maps what the archive records -- crawler tags, with137// emptiness meaning the founding source -- onto the slugs the workbench138// registry declares, which are the one vocabulary sources are named in.139func canonicalSourceSlug(source string) string {140 switch source {141 case "", "mapgenie":142 return "mapgenie"143 case "ign":144 return "ign-wiki"145 }146 return source147}148149// sourceDisplayLabel is how a source's canonical slug reads on a card or a150// ledger.151func sourceDisplayLabel(source string) string {152 switch canonicalSourceSlug(source) {153 case "mapgenie":154 return "MapGenie"155 case "ign-wiki":156 return "IGN Wiki"157 case "piggyback":158 return "Piggyback"159 case "nasa-trek":160 return "NASA Trek"161 case "arcgis-hub":162 return "ArcGIS Open Data"163 }164 return source165}166167// mergedPair records one place both sources pin: the donor pin, the serving168// pin it resolved to, how far apart the alignment put them, and every169// attribute the serving pin took from the donor, by key.170type mergedPair struct {171 Donor int64 `json:"d"`172 Winner int64 `json:"w"`173 DistancePx int `json:"px"`174 // Enriched marks a pair whose serving pin had nothing to say and took175 // the donor's description: derived from Took, kept for readers of the176 // older spelling.177 Enriched bool `json:"e,omitempty"`178 Took []string `json:"took,omitempty"`179}180181// adoptedPin records a donor-only pin that joined one of the serving map's182// own categories: provenance for a pin the legend does not single out.183type adoptedPin struct {184 Donor int64 `json:"d"`185 Into string `json:"into"`186}187188// heldPin is a donor pin the merge did not carry, with the reason.189type heldPin struct {190 Donor int64 `json:"d"`191 Title string `json:"t"`192 Reason string `json:"why"`193}194195// mergeAcrossSources composes every game slug's catalog entries into one196// bundle. Each source contributed what it had -- a map the game already197// draws, a map the game has never seen, icons, pins -- and the composition198// takes each contribution on its own terms: pins of a shared map resolve199// into it, a map without a counterpart joins the game whole, and the one200// bundle written per slug is the game as every source together knows it.201// The captures stay untouched in the archive, so recomposing under a better202// policy never needs anything recrawled.203func mergeAcrossSources(games []catalogVolume, shared tileGrid) ([]catalogVolume, error) {204 bySlug := make(map[string][]int)205 var order []string206 for index, game := range games {207 if _, seen := bySlug[game.Slug]; !seen {208 order = append(order, game.Slug)209 }210 bySlug[game.Slug] = append(bySlug[game.Slug], index)211 }212 sort.Strings(order)213214 absorbed := make(map[int]bool)215 for _, slug := range order {216 group := bySlug[slug]217 if len(group) < 2 {218 continue219 }220 winner := group[0]221 for _, candidate := range group[1:] {222 if newestCapture(games[candidate]) > newestCapture(games[winner]) {223 winner = candidate224 }225 }226 for _, donor := range group {227 if donor == winner {228 continue229 }230 if err := mergeVolume(&games[winner], &games[donor], shared); err != nil {231 return nil, fmt.Errorf("merge %s: %w", slug, err)232 }233 absorbed[donor] = true234 }235 sortVolumeWorlds(games[winner].Slug, games[winner].Worlds)236 }237238 composed := make([]catalogVolume, 0, len(games))239 for index, game := range games {240 if !absorbed[index] {241 composed = append(composed, game)242 }243 }244 return composed, nil245}246247func newestCapture(game catalogVolume) string {248 newest := ""249 for _, m := range game.Worlds {250 if m.UpdatedAt > newest {251 newest = m.UpdatedAt252 }253 }254 return newest255}256257func mergeVolume(winner, donor *catalogVolume, shared tileGrid) error {258 winnerMaps := make(map[string]*catalogWorld, len(winner.Worlds))259 for index := range winner.Worlds {260 winnerMaps[winner.Worlds[index].Slug] = &winner.Worlds[index]261 }262 for index := range donor.Worlds {263 donorMap := &donor.Worlds[index]264 target := winnerMaps[donorMap.Slug]265 if target == nil {266 // Sources do not divide the world the same way: one source's267 // separate map may be ground another source draws inside a268 // larger sheet, in its own projection. A shared slug is only269 // the cheapest evidence of shared ground -- the real test is270 // whether the places both name determine a transformation, so271 // before a map is taken as new, it is tried against every map272 // the game already draws.273 target = overlappingWorld(winner, donorMap, shared)274 }275 if target == nil {276 // Nothing the game draws pictures this ground: the map joins277 // whole, as this source captured it, icons and all.278 if err := contributeWorld(winner, donor, donorMap); err != nil {279 return err280 }281 fmt.Printf("merge %s: %s joins whole from %s\n",282 winner.Slug, donorMap.Slug, sourceLabelOf(donorMap, donor))283 continue284 }285 if target.Parent != "" || donorMap.Parent != "" {286 fmt.Printf("merge %s: %s is a split sheet; merging across shards is not attempted\n",287 winner.Slug, donorMap.Slug)288 continue289 }290 if err := mergeWorld(winner, target, donor, donorMap, shared); err != nil {291 return err292 }293 }294 return nil295}296297// overlappingWorld finds the winner map that pictures the same ground as a298// donor map under another slug, if any does: the one whose named places fit299// the donor's through an affine that closes. The fit is the compatibility300// test the differing projections make necessary -- coordinates cannot be301// compared, but the places can.302func overlappingWorld(winner *catalogVolume, donorMap *catalogWorld, shared tileGrid) *catalogWorld {303 donorAnchors := anchorsOfWorld(donorMap, gridOf(donorMap, shared))304 var best *catalogWorld305 bestAnchors := 0306 for index := range winner.Worlds {307 candidate := &winner.Worlds[index]308 if candidate.Parent != "" {309 continue310 }311 _, report, err := blend.Fit(donorAnchors, anchorsOfWorld(candidate, gridOf(candidate, shared)))312 if err != nil {313 continue314 }315 if report.Anchors > bestAnchors {316 best, bestAnchors = candidate, report.Anchors317 }318 }319 if best != nil {320 fmt.Printf("merge %s: %s pictures ground %s already draws (%d shared names)\n",321 winner.Slug, donorMap.Slug, best.Slug, bestAnchors)322 }323 return best324}325326// contributeWorld carries one source's map into the composed game untouched:327// its layers, pins, and zones as captured, and its icons brought across --328// under their own names where those are free or already hold the same bytes,329// under source-prefixed names where the composed game spells something else330// with them.331func contributeWorld(winner, donor *catalogVolume, donorMap *catalogWorld) error {332 contributed := *donorMap333 sourceTag := slugifyLabel(sourceLabelOf(donorMap, donor))334 renamed := make(map[string]string)335 for collectionIndex := range contributed.Collections {336 collection := &contributed.Collections[collectionIndex]337 if collection.IconAsset == "" {338 continue339 }340 data, held := donor.Icons[collection.IconAsset]341 if !held {342 collection.IconAsset = ""343 collection.IconPicture = false344 continue345 }346 name := collection.IconAsset347 if replacement, seen := renamed[name]; seen {348 collection.IconAsset = replacement349 continue350 }351 if winner.Icons == nil {352 winner.Icons = make(map[string][]byte)353 }354 if existing, taken := winner.Icons[name]; taken && !bytes.Equal(existing, data) {355 name = sourceTag + "--" + name356 }357 winner.Icons[name] = data358 renamed[collection.IconAsset] = name359 collection.IconAsset = name360 }361 winner.Worlds = append(winner.Worlds, contributed)362 return nil363}364365func mergeWorld(366 winnerGame *catalogVolume,367 winner *catalogWorld,368 donorGame *catalogVolume,369 donor *catalogWorld,370 shared tileGrid,371) error {372 winnerGrid := gridOf(winner, shared)373 donorGrid := gridOf(donor, shared)374375 affine, report, err := blend.Fit(anchorsOfWorld(donor, donorGrid), anchorsOfWorld(winner, winnerGrid))376 if err != nil {377 fmt.Printf("merge %s/%s: pins stay apart: %v\n", winnerGame.Slug, winner.Slug, err)378 return nil379 }380381 sourceLabel := sourceLabelOf(donor, donorGame)382 merge := &mergedSource{383 Source: sourceLabel,384 Slug: sourceSlugOf(donor),385 Alignment: report.String(),386 }387388 // A nameless neighbour matters out to where the alignment's own noise389 // reaches: two sources place the same shop apart by their residuals, so390 // the radius listens to the fit rather than assuming precision.391 nearbyRadius := math.Max(nearbyFloorPx, 2*report.P90Px)392393 // What the serving map already holds, by name, token, and place.394 index := indexWinner(winner, winnerGrid)395396 // A donor pin that stays distinct joins the serving category its own397 // category maps onto, and the ledger records the adoption; only concepts398 // the serving map does not have at all keep their donor categories, so399 // the source-named group holds nothing but what is truly source-specific.400 // Categories meet under their declared merge identity -- the401 // atlas.collection.key their payloads carry, their icon key otherwise --402 // so the equivalence curation lives in the payloads, not here.403 var keptCollections []worldCollection404 for _, donorCollection := range donor.Collections {405 if donorCollection.Kind != kindPoint {406 // Matching is point-only, but the ledger is not: every donor407 // path and area feature is held on the record, one line each408 // with its title, rather than dropped without a word.409 for _, shape := range donorCollection.Features {410 if donorCollection.Kind == kindPath {411 merge.DonorFeatures.Path++412 } else {413 merge.DonorFeatures.Area++414 }415 merge.Held = append(merge.Held, heldPin{416 Donor: shape.ID, Title: shape.Title, Reason: heldShapeReason,417 })418 }419 continue420 }421 kept := donorCollection422 kept.Features = nil423 mappedKey := categoryKey(donorCollection.Icon, donorCollection.Attrs)424 adoptive := index.categories[mappedKey]425 // Attribute-level resolution at the category: a serving category426 // with no artwork of any kind takes the donor's standard-icon427 // declaration, and the ledger says so.428 if adoptive != nil && attributeMergePolicy[semconv.KeyIconStd] == donorFillsEmpty &&429 adoptive.IconAsset == "" && adoptive.Attrs[semconv.KeyIconStd] == "" &&430 donorCollection.Attrs[semconv.KeyIconStd] != "" {431 adoptive.Attrs = withAttr(adoptive.Attrs, semconv.KeyIconStd,432 donorCollection.Attrs[semconv.KeyIconStd])433 merge.Enriched = append(merge.Enriched, categoryTake{434 Category: mappedKey, Key: semconv.KeyIconStd,435 })436 }437 for _, pin := range donorCollection.Features {438 merge.DonorFeatures.Point++439 x, y := affine.Apply(440 projectX(pin.Lng, donorGrid),441 projectY(pin.Lat, donorGrid),442 )443 outcome := resolvePin(pin, x, y, index, mappedKey, nearbyRadius)444 switch outcome.kind {445 case pinMatched:446 index.claimed[outcome.match] = pin.ID447 pair := mergedPair{448 Donor: pin.ID,449 Winner: outcome.match.ID,450 DistancePx: int(outcome.distance + 0.5),451 }452 // Attribute-level: the policy table says, key by key,453 // what a serving pin takes from its donor, and every454 // take is ledgered. The description travels under its455 // policy name, and a pin's true coordinates fill in the456 // same way words do: only where the serving side has457 // none of its own.458 if attributeMergePolicy[semconv.KeyNoteText] == donorFillsEmpty &&459 outcome.match.Description == "" && pin.Description != "" {460 outcome.match.Description = pin.Description461 pair.Enriched = true462 pair.Took = append(pair.Took, semconv.KeyNoteText)463 }464 for _, key := range []string{semconv.KeyGeoLat, semconv.KeyGeoLon} {465 if attributeMergePolicy[key] != donorFillsEmpty {466 continue467 }468 if pin.Attrs[key] == "" || outcome.match.Attrs[key] != "" {469 continue470 }471 outcome.match.Attrs = withAttr(outcome.match.Attrs, key, pin.Attrs[key])472 pair.Took = append(pair.Took, key)473 }474 merge.Matched = append(merge.Matched, pair)475 case pinHeld:476 merge.Held = append(merge.Held, heldPin{477 Donor: pin.ID, Title: pin.Title, Reason: outcome.reason,478 })479 case pinDistinct:480 if x < 0 || y < 0 || x > float64(shared.Size) || y > float64(shared.Size) {481 merge.Rejected = append(merge.Rejected, heldPin{482 Donor: pin.ID, Title: pin.Title,483 Reason: "outside the shared world",484 })485 continue486 }487 if holder, taken := index.ids[pin.ID]; taken {488 return fmt.Errorf("pin id %d (%s) collides with serving pin %q",489 pin.ID, pin.Title, holder)490 }491 index.ids[pin.ID] = pin.Title492 moved := pin493 moved.Lng = unprojectLongitude(x, winnerGrid)494 moved.Lat = unprojectLatitude(y, winnerGrid)495 moved.Member = nil496 moved.Shard = 0497 merge.Added++498 if adoptive != nil {499 adoptive.Features = append(adoptive.Features, moved)500 merge.Adopted = append(merge.Adopted, adoptedPin{501 Donor: pin.ID, Into: mappedKey,502 })503 } else {504 kept.Features = append(kept.Features, moved)505 }506 }507 }508 if len(kept.Features) == 0 {509 continue510 }511 if err := carryIcon(winnerGame, donorGame, &kept, sourceLabel); err != nil {512 return err513 }514 keptCollections = append(keptCollections, kept)515 }516517 if len(keptCollections) > 0 {518 // The kept collections file under a group named for their source, so519 // the legend says where they came from without a section of its own520 // machinery.521 for index := range keptCollections {522 keptCollections[index].Group = sourceLabel523 }524 winner.Collections = append(winner.Collections, keptCollections...)525 }526 winner.Merged = append(winner.Merged, *merge)527 // The merged account is as fresh as its freshest ingredient, so a newer528 // donor capture re-versions the merged bundle even when the serving529 // capture stood still.530 if donor.UpdatedAt > winner.UpdatedAt {531 winner.UpdatedAt = donor.UpdatedAt532 }533534 if err := mergeGate(merge, winner); err != nil {535 return err536 }537 donorTotal := merge.DonorFeatures.Point + merge.DonorFeatures.Path + merge.DonorFeatures.Area538 fmt.Printf("merge %s/%s: %s: %d donor features → %d matched (%d enriched) · %d added · %d held · %d outside\n",539 winnerGame.Slug, winner.Slug, sourceLabel, donorTotal, len(merge.Matched),540 enrichedCount(merge), merge.Added, len(merge.Held), len(merge.Rejected))541 return nil542}543544// winnerIndex is everything the serving map holds, arranged for resolution:545// each pin with its place, name, and name tokens; the categories by key; and546// the identifiers already spoken for.547type winnerIndex struct {548 pins []placedPin549 byName map[string][]int550 categories map[string]*worldCollection551 ids map[int64]string552 // claimed maps each serving pin already matched to the donor that553 // matched it. A place is one place: the next donor bearing the same name554 // must find its own, or say why it cannot.555 claimed map[*feature]int64556}557558type placedPin struct {559 feature *feature560 category string561 x, y float64562 tokens map[string]bool563}564565func indexWinner(winner *catalogWorld, grid tileGrid) *winnerIndex {566 index := &winnerIndex{567 byName: make(map[string][]int),568 categories: make(map[string]*worldCollection),569 ids: make(map[int64]string),570 claimed: make(map[*feature]int64),571 }572 for collectionIndex := range winner.Collections {573 collection := &winner.Collections[collectionIndex]574 if collection.Kind != kindPoint {575 continue576 }577 key := categoryKey(collection.Icon, collection.Attrs)578 if _, held := index.categories[key]; !held {579 index.categories[key] = collection580 }581 for pinIndex := range collection.Features {582 pin := &collection.Features[pinIndex]583 name := blend.NormalizeTitle(pin.Title)584 index.byName[name] = append(index.byName[name], len(index.pins))585 index.pins = append(index.pins, placedPin{586 feature: pin,587 category: key,588 x: projectX(pin.Lng, grid),589 y: projectY(pin.Lat, grid),590 tokens: tokensOf(name),591 })592 index.ids[pin.ID] = pin.Title593 }594 }595 return index596}597598type pinOutcome struct {599 kind int600 match *feature601 distance float64602 reason string603}604605const (606 pinDistinct = iota607 pinMatched608 pinHeld609)610611// resolvePin decides what one donor pin is, against everything the serving612// map holds. The same name near where the alignment predicts is the same613// place, and so is a name one source spells inside the other's -- "Northside614// Apartment" inside "Northside, Watson Apartment" -- when the pins share a615// category. The same name far beyond the radius is a different place bearing616// it; the stretch between is left undecided. A nameless neighbour inside the617// same mapped category is likewise held rather than guessed -- proximity618// alone never merges -- and only a pin resembling nothing at all is added.619func resolvePin(620 donor feature,621 x, y float64,622 index *winnerIndex,623 mappedKey string,624 nearbyRadius float64,625) pinOutcome {626 name := blend.NormalizeTitle(donor.Title)627 donorTokens := tokensOf(name)628629 var nearest *placedPin630 nearestDistance := math.Inf(1)631 alreadyClaimed := false632 consider := func(pin *placedPin, distance float64) {633 // A serving pin another donor already resolved to is one place, not634 // two: it cannot be matched again, but its nearness is remembered so635 // the refusal can say what stood in the way.636 if _, taken := index.claimed[pin.feature]; taken {637 if distance <= matchRadiusPx {638 alreadyClaimed = true639 }640 return641 }642 if distance < nearestDistance {643 nearest, nearestDistance = pin, distance644 }645 }646 if name != "" {647 for _, at := range index.byName[name] {648 pin := &index.pins[at]649 consider(pin, math.Hypot(pin.x-x, pin.y-y))650 }651 // One source's name written inside the other's counts only with the652 // category agreeing: a bare "Apartment" must not roam the map for a653 // long-named cousin.654 if nearest == nil && len(donorTokens) >= 2 {655 for at := range index.pins {656 pin := &index.pins[at]657 if pin.category != mappedKey || !tokenSubset(donorTokens, pin.tokens) {658 continue659 }660 consider(pin, math.Hypot(pin.x-x, pin.y-y))661 }662 }663 }664 if nearest != nil {665 switch {666 case nearestDistance <= matchRadiusPx:667 return pinOutcome{kind: pinMatched, match: nearest.feature, distance: nearestDistance}668 case nearestDistance <= separateRadiusPx:669 return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(670 "named like %q %.0fpx away; too far to merge, too near to double",671 nearest.feature.Title, nearestDistance)}672 }673 }674 if alreadyClaimed {675 return pinOutcome{kind: pinHeld, reason: "every nearby pin of this name is already matched"}676 }677 if _, shared := index.categories[mappedKey]; shared {678 for at := range index.pins {679 pin := &index.pins[at]680 if pin.category != mappedKey {681 continue682 }683 if math.Hypot(pin.x-x, pin.y-y) <= nearbyRadius {684 return pinOutcome{kind: pinHeld, reason: fmt.Sprintf(685 "beside %q in the same category; names disagree", pin.feature.Title)}686 }687 }688 }689 return pinOutcome{kind: pinDistinct}690}691692// tokenSubset reports whether one name is spelled entirely inside the other,693// either way round. The shorter side must carry at least two words: a single694// word inside a longer name -- every "Apartment" inside every apartment --695// says nothing.696func tokenSubset(a, b map[string]bool) bool {697 small, big := a, b698 if len(small) > len(big) {699 small, big = big, small700 }701 if len(small) < 2 {702 return false703 }704 for token := range small {705 if !big[token] {706 return false707 }708 }709 return true710}711712func tokensOf(normalized string) map[string]bool {713 tokens := make(map[string]bool)714 start := -1715 for at, r := range normalized {716 if r != ' ' {717 if start < 0 {718 start = at719 }720 continue721 }722 if start >= 0 {723 tokens[normalized[start:at]] = true724 start = -1725 }726 }727 if start >= 0 {728 tokens[normalized[start:]] = true729 }730 return tokens731}732733// carryIcon brings a merged category's icon across from the donor's archive734// under a source-prefixed name, so it cannot displace a serving icon that735// shares its key.736func carryIcon(winnerGame, donorGame *catalogVolume, collection *worldCollection, sourceLabel string) error {737 if collection.IconAsset == "" {738 return nil739 }740 data, held := donorGame.Icons[collection.IconAsset]741 if !held {742 collection.IconAsset = ""743 collection.IconPicture = false744 return nil745 }746 carried := slugifyLabel(sourceLabel) + "--" + collection.IconAsset747 if winnerGame.Icons == nil {748 winnerGame.Icons = make(map[string][]byte)749 }750 winnerGame.Icons[carried] = data751 collection.IconAsset = carried752 return nil753}754755func sourceLabelOf(donor *catalogWorld, donorGame *catalogVolume) string {756 for _, account := range donor.Merged {757 if account.Origin {758 return account.Source759 }760 }761 for _, v := range donor.Lenses {762 if v.Name != "" && v.Name != "Default" {763 return v.Name764 }765 }766 return donorGame.Title767}768769// sourceSlugOf reads the canonical slug off the donor's origin account. A770// map without one -- there should be none, every map opens its account at771// composition -- simply contributes no slug rather than a guessed one.772func sourceSlugOf(donor *catalogWorld) string {773 for _, account := range donor.Merged {774 if account.Origin {775 return account.Slug776 }777 }778 return ""779}780781func slugifyLabel(label string) string {782 out := make([]rune, 0, len(label))783 for _, r := range label {784 switch {785 case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':786 out = append(out, r)787 case r >= 'A' && r <= 'Z':788 out = append(out, r+'a'-'A')789 default:790 if len(out) > 0 && out[len(out)-1] != '-' {791 out = append(out, '-')792 }793 }794 }795 for len(out) > 0 && out[len(out)-1] == '-' {796 out = out[:len(out)-1]797 }798 return string(out)799}800801func gridOf(m *catalogWorld, shared tileGrid) tileGrid {802 if m.Grid == nil {803 return shared804 }805 return tileGrid{806 SourceZoom: m.Grid.SourceZoom,807 FirstTile: m.Grid.FirstTile,808 TileSize: shared.TileSize,809 Size: shared.Size,810 }811}812813func anchorsOfWorld(m *catalogWorld, grid tileGrid) []blend.Anchor {814 var anchors []blend.Anchor815 for _, collection := range m.Collections {816 if collection.Kind != kindPoint {817 continue818 }819 for _, pin := range collection.Features {820 anchors = append(anchors, blend.Anchor{821 Title: pin.Title,822 X: projectX(pin.Lng, grid),823 Y: projectY(pin.Lat, grid),824 })825 }826 }827 return anchors828}829830func enrichedCount(merge *mergedSource) int {831 count := 0832 for _, pair := range merge.Matched {833 if pair.Enriched {834 count++835 }836 }837 return count838}839840// mergeGate is the merge's own audit: every donor feature of every kind841// accounted for, every match one-to-one, no identifier doubled, nothing the842// serving map held made worse. It fails the build rather than writing a843// bundle that quietly lost something -- or quietly agreed too much.844func mergeGate(merge *mergedSource, winner *catalogWorld) error {845 heldPoints, heldShapes := 0, 0846 for _, held := range merge.Held {847 if held.Reason == heldShapeReason {848 heldShapes++849 } else {850 heldPoints++851 }852 }853 accounted := len(merge.Matched) + merge.Added + heldPoints + len(merge.Rejected)854 if accounted != merge.DonorFeatures.Point {855 return fmt.Errorf("merge accounts for %d of %d donor points", accounted, merge.DonorFeatures.Point)856 }857 if shapes := merge.DonorFeatures.Path + merge.DonorFeatures.Area; heldShapes != shapes {858 return fmt.Errorf("merge holds %d shape features of the %d the donor carries", heldShapes, shapes)859 }860 if merge.AddedShapes != 0 {861 return fmt.Errorf("merge claims %d added shapes; no shape feature merges yet", merge.AddedShapes)862 }863 claimed := make(map[int64]int64)864 for _, pair := range merge.Matched {865 if first, taken := claimed[pair.Winner]; taken {866 return fmt.Errorf("serving pin %d matched by donors %d and %d; a place is one place",867 pair.Winner, first, pair.Donor)868 }869 claimed[pair.Winner] = pair.Donor870 // Every take answers to the policy table and the registry: a key871 // nobody registered has no business in a ledger, and the older872 // enriched flag must say exactly what the takes say.873 tookNote := false874 for _, key := range pair.Took {875 if key == semconv.KeyNoteText {876 tookNote = true877 continue878 }879 if entity, known := semconv.EntityOf(key); !known || entity != semconv.EntityFeature {880 return fmt.Errorf("pair %d took %q, which no pin may carry", pair.Donor, key)881 }882 }883 if pair.Enriched != tookNote {884 return fmt.Errorf("pair %d says enriched=%t but its takes say %t",885 pair.Donor, pair.Enriched, tookNote)886 }887 }888 for _, take := range merge.Enriched {889 if entity, known := semconv.EntityOf(take.Key); !known || entity != semconv.EntityCollection {890 return fmt.Errorf("category %q took %q, which no category may carry", take.Category, take.Key)891 }892 }893 // One identifier space for every kind: a point and an area may not share894 // an id any more than two points may.895 seen := make(map[int64]string)896 var counted featureCounts897 for _, collection := range winner.Collections {898 for _, held := range collection.Features {899 if holder, taken := seen[held.ID]; taken {900 return fmt.Errorf("feature id %d held by both %q and %q", held.ID, holder, held.Title)901 }902 seen[held.ID] = held.Title903 switch collection.Kind {904 case kindPoint:905 counted.Point++906 case kindPath:907 counted.Path++908 default:909 counted.Area++910 }911 }912 }913 // The counts the map claims are no longer fields to drift; they are the914 // sums of its own ledger -- what it opened with, plus what every merge915 // says it added -- and the map must actually hold that many of each kind.916 // Held shapes stay the donor's, so only the origin account speaks for the917 // shapes the map draws.918 var ledgered featureCounts919 for _, account := range winner.Merged {920 if account.Origin {921 ledgered.Point += account.DonorFeatures.Point922 ledgered.Path += account.DonorFeatures.Path923 ledgered.Area += account.DonorFeatures.Area924 } else {925 ledgered.Point += account.Added926 if account.AddedShapes != 0 {927 return fmt.Errorf("account of %s claims %d added shapes; no shape feature merges yet",928 account.Source, account.AddedShapes)929 }930 }931 }932 if counted.Point != ledgered.Point {933 return fmt.Errorf("world holds %d points but its ledger claims %d", counted.Point, ledgered.Point)934 }935 if counted.Path != ledgered.Path || counted.Area != ledgered.Area {936 return fmt.Errorf("world holds %d paths and %d areas but its ledger claims %d and %d",937 counted.Path, counted.Area, ledgered.Path, ledgered.Area)938 }939 return nil940}