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 / island.go
island.go 5.3K
1package app23import (4 "encoding/json"5 "html/template"6 "math"7 "sort"8 "strconv"9)1011// The state island.12//13// The explorer page carries the session it was rendered from as an inert JSON14// script node. It exists for one reason: the application's own account of the15// arrangement and the seam's report of the same arrangement must diff cleanly16// against each other -- the joint diagnostics issue #5 §6 asks for, documented17// in docs/app.md §6. These are the session schema's published key names.18//19// Where the arrangement is stored is this application's business: it is a20// file per volume behind hostenv, not a browser's local storage. What the21// island contains is a published contract, which is why the shape below is22// spelled out by hand rather than derived from the Session struct. When the23// two disagree, this file is the one that is wrong. tests/island drives the24// application through its routes over the corpus and holds the island to this25// shape; the e2e suite reads the same island out of a real browser.26//27// Two of the twelve keys cannot be produced here and are carried as the28// server's echo of what the seam last said: center and zoom are the chart's29// camera, arrived at by fitting a raster to a window, and no amount of session30// state computes them. They are null until the seam has reported one31// (docs/app.md §6).3233// islandDoc is the whole island: which volume the reader was last in, and the34// record for that volume.35type islandDoc struct {36 Last string `json:"last"`37 Entry *islandEntry `json:"entry"`38}3940// islandEntry is one volume's arrangement, in the session schema's published41// key names and key order (docs/app.md §6).42type islandEntry struct {43 Volume string `json:"volume"`44 World string `json:"world"`45 Lens int `json:"lens"`46 Center []float64 `json:"center"`47 Zoom *float64 `json:"zoom"`48 Hidden []any `json:"hidden"`49 Collapsed []string `json:"collapsed"`50 Expanded []any `json:"expanded"`51 Labels []string `json:"labels"`52 OverviewDocked bool `json:"overviewDocked"`53 DockFolded bool `json:"dockFolded"`54 DockDismissed bool `json:"dockDismissed"`55}5657// island renders the state island's JSON body.58func island(last string, model *worldModel, shown *VolumeView, session Session) template.JS {59 doc := islandDoc{Last: last}60 if shown != nil {61 doc.Entry = islandOf(model, shown, session)62 }63 body, err := json.Marshal(doc)64 if err != nil {65 // Nothing above can fail to marshal. An empty island is a saner66 // face than a panic, and the page still works without it.67 return template.JS(`{"last":"","entry":null}`)68 }69 return template.JS(body)70}7172func islandOf(model *worldModel, shown *VolumeView, session Session) *islandEntry {73 entry := &islandEntry{74 Volume: shown.Slug,75 World: shown.World.Slug,76 Lens: shown.LensIndex,77 Hidden: identifiers(session.Hidden),78 Collapsed: stringsOf(session.Collapsed),79 Expanded: identifiers(session.Expanded),80 Labels: overrideLedger(session.Labels),81 OverviewDocked: session.Overview.Docked,82 DockFolded: !session.Dock.Open,83 DockDismissed: session.Dock.Dismissed,84 }85 _ = model86 if camera, held := session.Cameras[shown.World.Slug]; held {87 // Rounded exactly as the seam rounds what it reports, so the88 // island and the seam's report diff without a normalizer in89 // between: whole world units for the centre, three decimals for90 // the zoom (docs/app.md §6).91 entry.Center = []float64{halfUp(camera.X), halfUp(camera.Y)}92 zoom := math.Round(camera.Zoom*1000) / 100093 entry.Zoom = &zoom94 }95 return entry96}9798// halfUp rounds the way JavaScript's Math.round does, because that is how the99// seam rounds its half of the diff: a half goes up, toward positive infinity,100// rather than away from zero. On the y axis -- where every world coordinate is101// negative -- the two conventions disagree on exactly the values a cell102// boundary lands on, which is a whole world unit of difference in a field the103// island and the seam's report must agree on exactly.104func halfUp(value float64) float64 { return math.Floor(value + 0.5) }105106// identifiers writes a set of collection ids the way the session schema107// publishes them:108// sorted as the strings they ride the DOM as, and emitted as the numbers the109// payload declares them as. A world whose ids are not numeric keeps its110// strings, because a reader is never refused over the shape of an id.111func identifiers(values []string) []any {112 sorted := sortedIDs(values)113 out := make([]any, 0, len(sorted))114 for _, value := range sorted {115 if number, err := strconv.ParseInt(value, 10, 64); err == nil {116 out = append(out, number)117 continue118 }119 out = append(out, value)120 }121 return out122}123124func stringsOf(values []string) []string {125 out := sortedIDs(values)126 if out == nil {127 return []string{}128 }129 return out130}131132// overrideLedger is the label-override ledger as the session schema spells133// it: one134// "<collection>=<policy>" per override, sorted, so a record is stable to diff.135func overrideLedger(labels map[string]string) []string {136 out := make([]string, 0, len(labels))137 for collection, policy := range labels {138 if policy == "" {139 continue140 }141 out = append(out, collection+"="+policy)142 }143 sort.Strings(out)144 return out145}