clone

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

feat/atlas-next-horizon
atlas / internal / app / collate.go
collate.go 5.7K
1package app23import (4 "strings"56 "golang.org/x/text/collate"7 "golang.org/x/text/language"8)910// The order titles are read in.11//12// A list of titles is read by a person, so it is ordered the way a person's13// own language orders one: punctuation before digits before letters, `¡La14// Fantoma!` at the top of a list a byte comparison starts with `188 Trading15// Post`. That is the CLDR root collation -- what a browser answers16// `String.prototype.localeCompare` with -- and there is no version of17// `strings.ToLower` that arrives at it.18//19// WHY A DEPENDENCY. `golang.org/x/text/collate` implements that same root20// collation, and it was already in this module's graph -- `golang.org/x/text`21// arrives with the desktop host and is listed indirect in go.mod -- so the22// cost of using it is one line moving from the indirect block to the direct23// one. The alternative was a hand-written weight table, and a hand-written24// table is a re-implementation of DUCET that would be wrong for the first25// title nobody thought of. The corpus is not hypothetical: eighty-five of the26// fixture set's titles are not ASCII.27//28// TWO ORDERS, because the two lists are read differently:29//30// - the panel's list is a catalogue of collections, so case and accents31// separate two titles that would otherwise tie;32// - a shape collection's feature index is a list of places a producer33// numbered, so `Level 2` precedes `Level 10` and case is not a difference34// at all.35//36// Both are kept rather than collapsed into one, even though the fixture corpus37// cannot tell them apart (every shape title in it is ASCII and only two carry38// digits): a distinction today's corpus cannot see is not a distinction to39// lose, and the first bundle with an accented district title would find it.4041// titleCollator is the tertiary-strength root collation the panel's list used.42var titleCollator = collate.New(language.Und)4344// indexCollator is primary strength: case and diacritics are not differences,45// which is what `sensitivity: "base"` asks for.46var indexCollator = collate.New(language.Und, collate.IgnoreCase, collate.IgnoreDiacritics)4748// quoteFold maps the typographic quotation marks onto their ASCII twins.49//50// The root collation gives `'` and `’` the same primary weight and separates51// them only at the end, which x/text (comparing at tertiary strength) spells as52// a difference in the wrong direction: it sorts every `Miner's` before every53// `Miner’s` rather than sorting both under one `Miner`. Folding the pair before54// the compare puts them back on one primary, and the raw-string tiebreak below55// restores the order the browser gave two titles that differ in nothing else.56var quoteFold = strings.NewReplacer(57 "’", "'", "‘", "'", "“", `"`, "”", `"`,58)5960// compareTitles is `left.localeCompare(right)`: the order the panel lists what61// the map is drawing.62func compareTitles(left, right string) int {63 if held := titleCollator.CompareString(quoteFold.Replace(left), quoteFold.Replace(right)); held != 0 {64 return held65 }66 // Two titles equal under the fold differ only in which quotation mark they67 // spell, and the browser separates them: the ASCII one first, which is the68 // order of the code points themselves.69 return strings.Compare(left, right)70}7172// compareIndexTitles is `left.localeCompare(right, undefined, {numeric: true,73// sensitivity: "base"})`: the order the siblings of a feature index stand in.74//75// The numeric half is a rewrite rather than a second comparison. A run of76// digits becomes the same number written to a fixed width, so the collation77// that follows puts `Level 2` before `Level 10` without ever being told that78// either is a number -- and, crucially, everything around the run goes on being79// compared in the one pass the collation is defined over. Comparing digit runs80// separately, segment by segment, is what gets `Hydrant 3x` and81// `Hydrant [Chest]` the wrong way round: a bracket and a digit have to meet82// each other, and they cannot if one of them has been taken out of the string83// first.84//85// It is done here rather than with `collate.Numeric` because that option86// mis-ranks a run whose value is zero: `Manual Page 0 / 1` lands after87// `Manual Page 54 / 55`.88func compareIndexTitles(left, right string) int {89 return indexCollator.CompareString(90 numericKey(quoteFold.Replace(left)),91 numericKey(quoteFold.Replace(right)),92 )93}9495// numericWidth is the width every run of digits is written to. Twenty digits96// is past any number a title carries and past what a float could hold anyway;97// a longer run is left alone rather than truncated, which costs it the numeric98// reading and keeps it comparable.99const numericWidth = 20100101// numericKey rewrites every run of digits as that number, zero-padded, so that102// comparing the keys as text compares the numbers as numbers. Leading zeros go103// with it: `020` and `20` are one number written two ways.104func numericKey(title string) string {105 if !strings.ContainsAny(title, "0123456789") {106 return title107 }108 var out strings.Builder109 out.Grow(len(title))110 for at := 0; at < len(title); {111 if !isDigit(title[at]) {112 out.WriteByte(title[at])113 at++114 continue115 }116 run, rest := digitRun(title[at:])117 digits := strings.TrimLeft(run, "0")118 if digits == "" {119 digits = "0"120 }121 if len(digits) > numericWidth {122 out.WriteString(run)123 } else {124 out.WriteString(strings.Repeat("0", numericWidth-len(digits)))125 out.WriteString(digits)126 }127 at = len(title) - len(rest)128 }129 return out.String()130}131132func isDigit(character byte) bool { return character >= '0' && character <= '9' }133134// digitRun splits the leading run of digits off a string.135func digitRun(text string) (string, string) {136 at := 0137 for at < len(text) && isDigit(text[at]) {138 at++139 }140 return text[:at], text[at:]141}