clone

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

main
atlas / internal / app / grid.go
grid.go 10.6K
1package app23import (4 "strings"56 "github.com/FelineStateMachine/atlas/internal/app/cells"7)89// heldCell is the question the session's grid asks of every point: is it10// inside the cell being held. It is nil when no grid is open, when no cell is11// held inside it, or when the session's system does not divide this world.12//13// A grid narrows what stands the same way a highlight does14// (docs/render-seam.md ยง4.2, rule 4), and the count above the panel beside the15// map has to be the count of what the map is drawing -- so the application has16// to be able to answer "is this feature inside the held cell" without asking17// the seam, and it has to be able to answer it for whichever system the18// session holds. Answering for one system and shrugging at the other is the19// shape of the disagreement: the seam narrows the drawn set, the server20// narrows nothing, and the two counts part company while both are sure. The21// arithmetic itself, both systems of it, and why there is a Go copy at all, is22// internal/app/cells.23//24// The last case -- a system this world does not offer -- is a session that has25// outlived its world: a reader holding an S2 cell on a sphere and then opening26// a plane in the same volume. Nothing narrows, because the address names no27// ground here. applyGrid refuses to *enter* that state; this is what the28// filter does about a record already in it.29func heldCell(session Session, model *worldModel, lens *payloadLens) cells.Held {30 if session.Grid.System == "" || session.Grid.Cell == "" {31 return nil32 }33 var attrs map[string]string34 size := 0.035 if model != nil {36 attrs, size = model.Attrs, float64(model.Grid.Size)37 }38 if !cells.Applicable(attrs, session.Grid.System) {39 return nil40 }41 switch session.Grid.System {42 case cells.SystemGeohash:43 // Geohash has a mode per ground. On an earth-anchored plane the44 // address is a genuine WGS84 geohash and the question is asked in45 // degrees; on every other plane it halves the world square, so the46 // grid covers the full map and stays put across lens switches; and a47 // sphere keeps the surface ladder (internal/app/cells, GeohashGround).48 if mapping, anchored := cells.EarthMercator(attrs); anchored {49 return cells.GeohashRealHeld(mapping, session.Grid.Cell)50 }51 var surface, bounds *cells.Rect52 if lens != nil {53 surface, bounds = lens.Surface, lens.Bounds54 }55 return cells.GeohashHeld(cells.GeohashGround(surface, bounds, size, attrs), session.Grid.Cell)56 case cells.SystemS2:57 // The ground S2 divides is the body itself rather than the lens's58 // window on it, which is why nothing here reads the surface: the59 // address is a place on a sphere, and the flattening the world60 // declares is the whole of what puts it back on the raster. That61 // there *is* one was settled above -- this reads it.62 mapping, mapped := cells.MappingOf(attrs)63 if !mapped {64 return nil65 }66 return cells.S2Held(mapping, session.Grid.Cell)67 }68 return nil69}7071// THE NAVIGATOR'S VOCABULARY, per system.72//73// Everything below answers the same question the seam's `CellSystem` answers74// about itself -- what the field keeps of a keystroke, when the draft has75// become a place, what is one level out, how the control names it -- for the76// two systems a session can hold. It is the server's half of77// analysis/cellsystems, and it is here rather than in internal/app/cells for78// one reason: a normalization is a *field* concern, not arithmetic, and the79// port says so in as many words (S2ParseCell's doc: "minus the keystroke80// normalization, which belongs to the field and not to the server").81//82// WHY THE SERVER NORMALIZES AT ALL. In the reference the field normalized as83// the reader typed and the session only ever saw addresses. Here the field is84// an <input> posting its raw value, and the record is the only place an85// address can be made canonical -- so the server does what the field used to,86// and the field is left to be an input. The alternative is a session holding87// "M6X!", a seam that would have held "m6x", and two halves of one page88// dividing different ground.8990// normalizeCell is what the navigator's field keeps of a keystroke: the91// characters this system spells addresses with, lowercased, cut at the depth92// its telescope stops at. Nothing here refuses -- a reader mid-word is not93// making a mistake, and what they have typed so far is either a place or a94// draft, which is [parseCell]'s question rather than this one's.95//96// The depth is the WORLD'S, not the system's alone: geohash on an97// earth-anchored plane takes six characters where a synthetic ground takes98// three (internal/app/cells, GeohashDepthOf), which is why the attributes99// ride along.100func normalizeCell(system, text string, attrs map[string]string) string {101 keep, depth := "", 0102 switch system {103 case cells.SystemGeohash:104 keep, depth = cells.GeohashAlphabet, cells.GeohashDepthOf(attrs)105 case cells.SystemS2:106 keep, depth = "0123456789abcdef", cells.S2InputLength107 default:108 return ""109 }110 out := make([]rune, 0, depth)111 for _, character := range strings.ToLower(text) {112 if !strings.ContainsRune(keep, character) {113 continue114 }115 if out = append(out, character); len(out) == depth {116 break117 }118 }119 return string(out)120}121122// parseCell asks whether a normalized draft has become a place, and answers123// the canonical spelling of it.124//125// The two systems answer differently, and the difference is the contract's126// "whole-or-nothing" rule. A geohash address refines by appending a character,127// so every prefix of one is itself an address and parsing never refuses: the128// reader travels on every keystroke. An S2 token is a Hilbert index, and half129// of one names nothing at all -- so a partial token is a DRAFT the field holds130// while the session stays exactly where it was, and only a whole token moves131// the reader. A token past the floor clamps to it and comes back respelled.132//133// Real mode adds geohash's one refusal: on an earth-anchored plane the134// grid's cells start at the window's base depth, so a shorter spelling names135// a cell the grid never shows -- a hemisphere against a city -- and it is a136// draft, exactly as the seam's `parseInput` holds it.137func parseCell(system, text string, attrs map[string]string) (string, bool) {138 switch system {139 case cells.SystemGeohash:140 if text != "" {141 if mapping, anchored := cells.EarthMercator(attrs); anchored &&142 len(text) < cells.GeohashBaseDepth(mapping) {143 return "", false144 }145 }146 return text, true147 case cells.SystemS2:148 return cells.S2ParseCell(text)149 }150 return "", false151}152153// parentCell is one level out of an address, per the system holding it.154//155// This is the line the telescope used to get wrong, and it is worth saying why156// out loud: an address is opaque, and only its own system knows how to shorten157// it. Geohash refines by appending a character, so its parent is the address158// with the last character taken off. S2 does not -- the parent of `47a1cb` is159// `47a1`, three characters shorter and spelled differently -- so taking a160// character off an S2 token produces either a different cell somewhere else on161// the body or no cell at all. Both were wrong in the same quiet way: the162// reader pressed Escape and arrived somewhere they had never been.163// Real mode moves geohash's own answer once more: a base-depth cell on an164// earth-anchored plane telescopes out to the ROOT, because the level between165// it and the root -- a three-character hemisphere slice -- is not a place the166// window's grid has (analysis/cellsystems/geohash.ts, `parent`).167func parentCell(system, cell string, attrs map[string]string) string {168 switch system {169 case cells.SystemGeohash:170 runes := []rune(cell)171 if len(runes) == 0 {172 return ""173 }174 if mapping, anchored := cells.EarthMercator(attrs); anchored &&175 len(runes) <= cells.GeohashBaseDepth(mapping) {176 return ""177 }178 return string(runes[:len(runes)-1])179 case cells.SystemS2:180 return cells.S2Parent(cell)181 }182 return ""183}184185// systemName is how the navigator names a system in prose, and systemMark is186// the mark its cycle button wears. Both are the registry's own spellings187// (analysis/cellsystems: `name` and `short`), because the button and the field188// label are read beside a seam that draws the same words.189func systemName(system string) string {190 switch system {191 case cells.SystemGeohash:192 return "Geohash"193 case cells.SystemS2:194 return "S2"195 }196 return ""197}198199func systemMark(system string) string {200 switch system {201 case cells.SystemGeohash:202 return "G"203 case cells.SystemS2:204 return "S2"205 }206 return ""207}208209// inputLength is how many characters the navigator's field accepts, which is210// the depth this system's telescope stops at ON THIS WORLD. It is the same211// number [normalizeCell] cuts at, and it is spelled on the input as well so212// that the field and the record agree in front of the reader rather than213// behind them -- six characters for a real geohash on an earth-anchored214// plane, three for a synthetic one, the seam's own `inputLength(ground)`.215func inputLength(system string, attrs map[string]string) int {216 switch system {217 case cells.SystemGeohash:218 return cells.GeohashDepthOf(attrs)219 case cells.SystemS2:220 return cells.S2InputLength221 }222 return 0223}224225// nextSystem is the one the cycle button offers: the next this world divides226// by, wrapping. A system the world does not offer -- or a list of one -- has no227// next, which is the empty answer and the hidden button.228func nextSystem(offered []string, system string) string {229 if len(offered) < 2 {230 return ""231 }232 for at, candidate := range offered {233 if candidate == system {234 return offered[(at+1)%len(offered)]235 }236 }237 return ""238}239240// currentLens is the lens the session has open, or the world's first, which is241// the one a world opens on when nobody has chosen. It is the same choice242// [App.view] makes when it assembles the page; a grid dividing a different243// lens's ground from the one being drawn would divide the wrong rectangle on a244// split sheet.245func currentLens(model *worldModel, session Session) *payloadLens {246 if model == nil {247 return nil248 }249 for at := range model.Lenses {250 entry := &model.Lenses[at]251 if entry.Name == session.Lens || (session.Lens == "" && at == 0) {252 return entry253 }254 }255 return nil256}257258// gridGround is the rectangle the systems divide: the lens's declared surface259// where it has one, its raster window otherwise, and the whole world square260// where it declares neither (internal/app/cells, SurfaceExtent).261func gridGround(c *concernContext) cells.Extent {262 var surface, bounds *cells.Rect263 size := 0.0264 if c.world != nil {265 size = float64(c.world.Grid.Size)266 if lens := currentLens(c.world, *c.session); lens != nil {267 surface, bounds = lens.Surface, lens.Bounds268 }269 }270 return cells.SurfaceExtent(surface, bounds, size)271}