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 / world.go
world.go 20.1K
1package app23import (4 "encoding/json"5 "fmt"6 "io"7 "math"8 "sort"9 "strconv"10 "sync"1112 "github.com/FelineStateMachine/atlas/format/bundle"13 "github.com/FelineStateMachine/atlas/format/semconv"14 "github.com/FelineStateMachine/atlas/internal/app/cells"15 "github.com/FelineStateMachine/atlas/internal/app/hostenv"16)1718// The world as the display logic reads it.19//20// A world payload is JSON on the data plane and the seam reads it there21// directly; the application reads the same bytes for a different reason. Every22// display decision the reference implementation made in the browser -- which23// legend sections exist, what each row counts, which names speak, which24// features survive a filter -- is made here instead, once, in Go, before a25// template is handed anything (issue #5 §4.5).26//27// The decoding below is deliberately lenient in the reader's direction: a28// field this build has never heard of is ignored, and a collection missing29// half its optional fields still renders. Conventions are read only through30// format/semconv; nothing here compares an "atlas." string literal of its own.31//32// The payload's Go shape lives here rather than in format/bundle because the33// format package serves these bytes without opening them: nothing in it needs34// a struct for the payload, and inventing one there would export a shape the35// format lane has not committed to. When the format lane wants one, this36// moves and the app imports it.3738// worldPayload is worlds/<slug>.json.39type worldPayload struct {40 Attrs map[string]string `json:"attrs"`41 Grid *payloadGrid `json:"grid"`42 Lenses []payloadLens `json:"lenses"`43 Collections []payloadCollection `json:"collections"`44 Merged []payloadMerge `json:"merged"`45}4647// payloadGrid is a world's override of the volume's tile window. Absent48// fields keep the volume's.49type payloadGrid struct {50 SourceZoom *int `json:"sourceZoom"`51 FirstTile *int `json:"firstTile"`52 TileSize *int `json:"tileSize"`53 Size *int `json:"size"`54}5556// payloadLens is one raster pyramid picturing this world.57type payloadLens struct {58 Name string `json:"name"`59 Tiles string `json:"tiles"`60 MinZoom int `json:"minZoom"`61 MaxZoom int `json:"maxZoom"`62 Shard int `json:"shard"`63 // Bounds is the raster window the pyramid fills; Surface is the ground64 // that window pictures. They differ on a split sheet, where the window65 // was grown to take in a title drawn beside the map, and anything that66 // divides the world measures the ground (docs/render-seam.md §6.1).67 Bounds *cells.Rect `json:"bounds"`68 Surface *cells.Rect `json:"surface"`69}7071// payloadCollection is one ordered group of features.72type payloadCollection struct {73 ID int64 `json:"id"`74 Title string `json:"title"`75 Kind string `json:"kind"`76 Group string `json:"group"`77 Icon string `json:"icon"`78 IconAsset string `json:"iconAsset"`79 // Color is the collection's own accent and IconColor the older spelling80 // of the same fact. Both are read because the seam reads both, and a81 // legend that fell back to the palette where the seam honoured a82 // declared colour would draw a different world than the map.83 Color string `json:"color"`84 IconColor string `json:"iconColor"`85 Visible *bool `json:"visible"`86 Attrs map[string]string `json:"attrs"`87 Features []payloadFeature `json:"features"`88}8990// payloadFeature is one path or area. Points are not here: they live packed91// in the .bin, and owner indexes this array.92type payloadFeature struct {93 ID int64 `json:"id"`94 Title string `json:"title"`95 Subtitle string `json:"subtitle"`96 Parent *int64 `json:"parent"`97 Center *bundle.Coordinate `json:"center"`98 HasText bool `json:"hasText"`99 Shard int64 `json:"shard"`100 Attrs map[string]string `json:"attrs"`101 Geometry []payloadGeometry `json:"geometry"`102}103104// payloadGeometry is one GeoJSON-shaped ring set in the volume's own degrees.105type payloadGeometry struct {106 Type string `json:"type"`107 Coordinates json.RawMessage `json:"coordinates"`108}109110// payloadMerge is one source's account of what it contributed.111type payloadMerge struct {112 Slug string `json:"slug"`113 Source string `json:"source"`114 Origin bool `json:"origin"`115}116117// ---------------------------------------------------------------------------118// The model119// ---------------------------------------------------------------------------120121// worldModel is one world, stood up: its collections in payload order, its122// point features unpacked, its shape features projected onto the world square.123// It is immutable once built and shared by every request reading that build of124// that world.125type worldModel struct {126 Slug string127 Lenses []payloadLens128 Attrs map[string]string129 Origin string130 Grid tileGrid131 Members []*collectionModel132 ByID map[string]*collectionModel133 Points []*pointModel134 PointByID map[string]*pointModel135 Shapes []*shapeModel136 ShapeByID map[string]*shapeModel137}138139// collectionModel is one collection with everything a row needs already140// decided: its kind, its curated label policy, whether it starts hidden.141type collectionModel struct {142 ID string143 Title string144 Kind string145 Group string146 Icon string147148 // IconAsset is the artwork the collection wears, as a path under the149 // build's `icons/`. The map composes a marker from it and the legend150 // draws it as the row's mark; both name it the same way, which is what151 // keeps a row and the pins it stands for looking like each other.152 IconAsset string153154 // Color and IconColor are the collection's declared accent, in the155 // order the seam consults them. Neither is a decision -- the one colour156 // a collection wears is `collectionColor`.157 Color string158 IconColor string159160 Attrs map[string]string161 Curated string // the producer's label policy, through semconv162 RenderAs string163 Hidden bool // the payload's own "visible": false164 Count int165 Index int166 Shapes []*shapeModel167}168169// Domain is the ground an isolate may touch: point collections isolate170// against point collections, shape collections against shape collections, so171// highlighting a region and then asking for only one resource leaves the172// region standing with the resource inside it.173func (c *collectionModel) Domain() string {174 if c.Kind == semconv.GeometryPoint {175 return "features"176 }177 return "zones"178}179180// pointModel is one packed location, already on the world square.181type pointModel struct {182 ID string183 Title string184 Lat, Lng float64185 X, Y float64186 Shard int64187 Collection *collectionModel188}189190// shapeModel is one path or area feature: its rings in world pixels, its191// extent, and the collection it belongs to.192type shapeModel struct {193 ID string194 Title string195 Subtitle string196 Parent string197 Depth int198 HasText bool199 Attrs map[string]string200 Collection *collectionModel201 // Shard is the layer of a split world this ground belongs to. A shape on202 // another lens's shard is elsewhere in the world rather than filtered203 // out, exactly as a point is.204 Shard int64205 Polygons [][][]point // rings per polygon; ring 0 is the outline206 Lines [][]point207 MinX, MinY float64208 MaxX, MaxY float64209 Drawn bool // carries geometry the chart can draw210}211212type point struct{ X, Y float64 }213214// tileGrid is the window a world's degrees are projected through: the volume's215// grid, overridden by whatever the world declares for itself.216type tileGrid struct {217 SourceZoom int218 FirstTile int219 TileSize int220 Size int221}222223func (g tileGrid) project(lat, lng float64) (x, y float64) {224 worldTiles := math.Pow(2, float64(g.SourceZoom))225 xTile := (lng + 180) / 360 * worldTiles226 yTile := (1 - math.Asinh(math.Tan(lat*math.Pi/180))/math.Pi) / 2 * worldTiles227 return (xTile - float64(g.FirstTile)) * float64(g.TileSize),228 -(yTile - float64(g.FirstTile)) * float64(g.TileSize)229}230231// ---------------------------------------------------------------------------232// Building233// ---------------------------------------------------------------------------234235// worlds is the model cache. Standing a world up decodes a payload and236// unpacks every location, which is work worth doing once per build rather than237// once per keystroke of a search. The key carries the build's stamp, so a new238// build is a new entry and nothing is ever stale; a handful of entries is kept239// because a reader wanders between two or three worlds and back.240type worldCache struct {241 mu sync.Mutex242 held map[string]*worldModel243 order []string244}245246const worldsHeld = 6247248func newWorldCache() *worldCache { return &worldCache{held: map[string]*worldModel{}} }249250func (c *worldCache) get(key string) (*worldModel, bool) {251 c.mu.Lock()252 defer c.mu.Unlock()253 model, ok := c.held[key]254 return model, ok255}256257func (c *worldCache) put(key string, model *worldModel) {258 c.mu.Lock()259 defer c.mu.Unlock()260 if _, held := c.held[key]; !held {261 c.order = append(c.order, key)262 for len(c.order) > worldsHeld {263 delete(c.held, c.order[0])264 c.order = c.order[1:]265 }266 }267 c.held[key] = model268}269270// world stands one world of one volume up, or answers nil when the payload271// cannot be read. A world that will not decode is not a reason to refuse a272// page: the chrome still renders, the legend is simply empty, and the failure273// is in the log where somebody can act on it.274func (a *App) world(volume hostenv.Volume, slug string) *worldModel {275 manifest := volume.Manifest()276 key := manifest.Volume.Slug + "@" + bundle.ShortStamp(manifest.Version.Stamp) + "/" + slug277 if held, ok := a.worlds.get(key); ok {278 return held279 }280 model, err := buildWorld(volume, manifest, slug)281 if err != nil {282 return nil283 }284 a.worlds.put(key, model)285 return model286}287288func buildWorld(volume hostenv.Volume, manifest bundle.Manifest, slug string) (*worldModel, error) {289 payload, err := readEntry(volume, bundle.WorldEntryName(slug, bundle.WorldSuffix))290 if err != nil {291 return nil, err292 }293 var decoded worldPayload294 if err := json.Unmarshal(payload, &decoded); err != nil {295 return nil, fmt.Errorf("world %s payload: %w", slug, err)296 }297298 grid := tileGrid{299 SourceZoom: manifest.TileGrid.SourceZoom,300 FirstTile: manifest.TileGrid.FirstTile,301 TileSize: manifest.TileGrid.TileSize,302 Size: manifest.TileGrid.Size,303 }304 if own := decoded.Grid; own != nil {305 if own.SourceZoom != nil {306 grid.SourceZoom = *own.SourceZoom307 }308 if own.FirstTile != nil {309 grid.FirstTile = *own.FirstTile310 }311 if own.TileSize != nil {312 grid.TileSize = *own.TileSize313 }314 if own.Size != nil {315 grid.Size = *own.Size316 }317 }318319 model := &worldModel{320 Slug: slug,321 Lenses: decoded.Lenses,322 Attrs: decoded.Attrs,323 Grid: grid,324 ByID: map[string]*collectionModel{},325 PointByID: map[string]*pointModel{},326 ShapeByID: map[string]*shapeModel{},327 }328 for _, account := range decoded.Merged {329 if account.Origin {330 model.Origin = account.Source331 break332 }333 }334335 for at, held := range decoded.Collections {336 kind := held.Kind337 if kind == "" {338 kind = semconv.GeometryPoint339 }340 collection := &collectionModel{341 ID: strconv.FormatInt(held.ID, 10),342 Title: held.Title,343 Kind: kind,344 Group: held.Group,345 Icon: held.Icon,346 IconAsset: held.IconAsset,347 Color: held.Color,348 IconColor: held.IconColor,349350 Attrs: held.Attrs,351 Curated: semconv.LabelPolicy(kind, held.Attrs),352 RenderAs: semconv.RenderAs(held.Attrs, ""),353 Hidden: held.Visible != nil && !*held.Visible,354 Index: at,355 }356 model.Members = append(model.Members, collection)357 model.ByID[collection.ID] = collection358359 for _, feature := range held.Features {360 shape := buildShape(feature, collection, grid)361 collection.Shapes = append(collection.Shapes, shape)362 model.Shapes = append(model.Shapes, shape)363 model.ShapeByID[shape.ID] = shape364 }365 collection.Count = len(held.Features)366 }367368 // Points arrive packed. The owner column indexes the collections array,369 // which is why the array's order is significant and why nothing here370 // sorts it.371 if packed, err := readEntry(volume, bundle.WorldEntryName(slug, bundle.PackedSuffix)); err == nil {372 if locations, err := bundle.UnpackLocations(packed); err == nil {373 for _, location := range locations {374 owner := int(location.Owner)375 if owner < 0 || owner >= len(model.Members) {376 continue377 }378 collection := model.Members[owner]379 x, y := grid.project(location.Lat, location.Lng)380 pin := &pointModel{381 ID: strconv.FormatInt(location.ID, 10),382 Title: location.Title,383 Lat: location.Lat,384 Lng: location.Lng,385 X: x,386 Y: y,387 Shard: location.Shard,388 Collection: collection,389 }390 model.Points = append(model.Points, pin)391 model.PointByID[pin.ID] = pin392 collection.Count++393 }394 }395 }396397 // Depth is the parent chain, so the feature index can indent a398 // sub-watershed under its watershed without the template counting.399 for _, shape := range model.Shapes {400 shape.Depth = shapeDepth(model, shape, 0)401 }402 return model, nil403}404405func shapeDepth(model *worldModel, shape *shapeModel, guard int) int {406 if shape.Parent == "" || guard > 32 {407 return 0408 }409 parent, held := model.ShapeByID[shape.Parent]410 if !held {411 return 0412 }413 return 1 + shapeDepth(model, parent, guard+1)414}415416func buildShape(feature payloadFeature, collection *collectionModel, grid tileGrid) *shapeModel {417 shape := &shapeModel{418 ID: strconv.FormatInt(feature.ID, 10),419 Title: feature.Title,420 Subtitle: feature.Subtitle,421 HasText: feature.HasText,422 Shard: feature.Shard,423 Attrs: feature.Attrs,424 Collection: collection,425 MinX: math.Inf(1), MinY: math.Inf(1),426 MaxX: math.Inf(-1), MaxY: math.Inf(-1),427 }428 if feature.Parent != nil {429 shape.Parent = strconv.FormatInt(*feature.Parent, 10)430 }431 for _, geometry := range feature.Geometry {432 switch geometry.Type {433 case "Polygon":434 var rings [][][2]float64435 if json.Unmarshal(geometry.Coordinates, &rings) != nil {436 continue437 }438 shape.addPolygon(rings, grid)439 case "MultiPolygon":440 var polygons [][][][2]float64441 if json.Unmarshal(geometry.Coordinates, &polygons) != nil {442 continue443 }444 for _, rings := range polygons {445 shape.addPolygon(rings, grid)446 }447 case "LineString":448 var line [][2]float64449 if json.Unmarshal(geometry.Coordinates, &line) != nil {450 continue451 }452 shape.addLine(line, grid)453 case "MultiLineString":454 var lines [][][2]float64455 if json.Unmarshal(geometry.Coordinates, &lines) != nil {456 continue457 }458 for _, line := range lines {459 shape.addLine(line, grid)460 }461 }462 }463 shape.Drawn = len(shape.Polygons) > 0 || len(shape.Lines) > 0464 return shape465}466467// Coordinates arrive GeoJSON-ordered -- longitude first -- and land on the468// world square through the volume's own tile window.469func (s *shapeModel) addPolygon(rings [][][2]float64, grid tileGrid) {470 converted := make([][]point, 0, len(rings))471 for _, ring := range rings {472 converted = append(converted, s.convert(ring, grid))473 }474 if len(converted) > 0 {475 s.Polygons = append(s.Polygons, converted)476 }477}478479func (s *shapeModel) addLine(line [][2]float64, grid tileGrid) {480 if converted := s.convert(line, grid); len(converted) > 1 {481 s.Lines = append(s.Lines, converted)482 }483}484485func (s *shapeModel) convert(ring [][2]float64, grid tileGrid) []point {486 out := make([]point, 0, len(ring))487 for _, pair := range ring {488 x, y := grid.project(pair[1], pair[0])489 out = append(out, point{X: x, Y: y})490 s.MinX, s.MaxX = math.Min(s.MinX, x), math.Max(s.MaxX, x)491 s.MinY, s.MaxY = math.Min(s.MinY, y), math.Max(s.MaxY, y)492 }493 return out494}495496// ---------------------------------------------------------------------------497// Containment498// ---------------------------------------------------------------------------499500// grace is the pixel of slack containment allows. A pin dropped on a zone's501// border was put there to mean the zone, and exact point-in-polygon arithmetic502// would flip it out over the width of the line it stands on.503const grace = 1.0504505// contains answers whether a coordinate stands inside this feature: inside its506// outline and outside its holes, or within a pixel of anything it draws.507func (s *shapeModel) contains(x, y float64) bool {508 if x < s.MinX-grace || x > s.MaxX+grace || y < s.MinY-grace || y > s.MaxY+grace {509 return false510 }511 for _, polygon := range s.Polygons {512 if len(polygon) == 0 || !inRing(polygon[0], x, y) {513 continue514 }515 inHole := false516 for _, hole := range polygon[1:] {517 if inRing(hole, x, y) {518 inHole = true519 break520 }521 }522 if !inHole {523 return true524 }525 }526 for _, polygon := range s.Polygons {527 for _, ring := range polygon {528 if nearRing(ring, x, y) {529 return true530 }531 }532 }533 for _, line := range s.Lines {534 if nearRing(line, x, y) {535 return true536 }537 }538 return false539}540541// inRing is the crossing-number test, boundary-inclusive on the way in.542func inRing(ring []point, x, y float64) bool {543 inside := false544 for at, prior := 0, len(ring)-1; at < len(ring); prior, at = at, at+1 {545 a, b := ring[prior], ring[at]546 if (a.Y > y) == (b.Y > y) {547 continue548 }549 if x < (b.X-a.X)*(y-a.Y)/(b.Y-a.Y)+a.X {550 inside = !inside551 }552 }553 return inside554}555556// nearRing is the pixel of grace: within one world pixel of a drawn edge.557func nearRing(ring []point, x, y float64) bool {558 for at := 1; at < len(ring); at++ {559 if segmentDistanceSquared(ring[at-1], ring[at], x, y) <= grace*grace {560 return true561 }562 }563 return false564}565566func segmentDistanceSquared(a, b point, x, y float64) float64 {567 dx, dy := b.X-a.X, b.Y-a.Y568 span := dx*dx + dy*dy569 t := 0.0570 if span > 0 {571 t = ((x-a.X)*dx + (y-a.Y)*dy) / span572 t = math.Max(0, math.Min(1, t))573 }574 cx, cy := a.X+t*dx, a.Y+t*dy575 return (cx-x)*(cx-x) + (cy-y)*(cy-y)576}577578// ---------------------------------------------------------------------------579// Text580// ---------------------------------------------------------------------------581582// featureText is one feature's entry in worlds/<slug>.text: prose, links, and583// the attributes that only matter once a card is open.584type featureText struct {585 Description string `json:"d"`586 Links []int64 `json:"l"`587 Attrs map[string]string `json:"a"`588}589590// text reads one world's prose file. The whole file is read rather than one591// entry because it is a single JSON object and there is nothing to seek to;592// it is small beside the tiles and it is read only when a card opens.593func (a *App) text(volume hostenv.Volume, world string) map[string]featureText {594 manifest := volume.Manifest()595 key := manifest.Volume.Slug + "@" + bundle.ShortStamp(manifest.Version.Stamp) + "/" + world + ".text"596 if held, ok := a.texts.get(key); ok {597 return held598 }599 out := map[string]featureText{}600 if data, err := readEntry(volume, bundle.WorldEntryName(world, bundle.TextSuffix)); err == nil {601 _ = json.Unmarshal(data, &out)602 }603 a.texts.put(key, out)604 return out605}606607// textCache is the same shape of cache as worldCache, for the prose file.608type textCache struct {609 mu sync.Mutex610 held map[string]map[string]featureText611 order []string612}613614func newTextCache() *textCache {615 return &textCache{held: map[string]map[string]featureText{}}616}617618func (c *textCache) get(key string) (map[string]featureText, bool) {619 c.mu.Lock()620 defer c.mu.Unlock()621 held, ok := c.held[key]622 return held, ok623}624625func (c *textCache) put(key string, held map[string]featureText) {626 c.mu.Lock()627 defer c.mu.Unlock()628 if _, seen := c.held[key]; !seen {629 c.order = append(c.order, key)630 for len(c.order) > worldsHeld {631 delete(c.held, c.order[0])632 c.order = c.order[1:]633 }634 }635 c.held[key] = held636}637638// readEntry reads one archive entry whole.639func readEntry(volume hostenv.Volume, name string) ([]byte, error) {640 entry, size, err := volume.Open(name)641 if err != nil {642 return nil, fmt.Errorf("open %s: %w", name, err)643 }644 defer entry.Close()645 _ = size646 out, err := io.ReadAll(entry)647 if err != nil {648 return nil, fmt.Errorf("read %s: %w", name, err)649 }650 return out, nil651}652653// sortedIDs is the order a set of collection ids is written in everywhere it654// is written: as strings, which is how the session record and the state655// island both spell it (docs/app.md §6).656func sortedIDs(ids []string) []string {657 out := append([]string(nil), ids...)658 sort.Strings(out)659 return out660}