1package app23import (4 "math"5 "sort"6 "sync"78 "github.com/FelineStateMachine/atlas/format/semconv"9 "github.com/FelineStateMachine/atlas/format/vnext"10 "github.com/FelineStateMachine/atlas/internal/app/cells"11 "github.com/FelineStateMachine/atlas/internal/app/hostenv"12)1314// payloadLens is the presentation-ready view of one native raster pyramid.15type payloadLens struct {16 Name string `json:"name"`17 Tiles string `json:"tiles"`18 MinZoom int `json:"minZoom"`19 MaxZoom int `json:"maxZoom"`20 FullZoom int `json:"fullZoom"`21 SourceZoom int `json:"sourceZoom"`22 Formats []string `json:"formats"`23 Interpolate bool `json:"interpolate"`24 Background string `json:"background,omitempty"`25 Shard int `json:"shard,omitempty"`26 Coverage map[string]payloadCoverage `json:"coverage,omitempty"`27 // Bounds is the raster window the pyramid fills; Surface is the ground28 // that window pictures. They differ on a split sheet, where the window29 // was grown to take in a title drawn beside the map, and anything that30 // divides the world measures the ground (docs/render-seam.md §6.1).31 Bounds *cells.Rect `json:"bounds"`32 Surface *cells.Rect `json:"surface"`33}3435type payloadCoverage struct {36 X int `json:"x"`37 Y int `json:"y"`38 W int `json:"w"`39 H int `json:"h"`40 Bits string `json:"bits"`41}4243// ---------------------------------------------------------------------------44// The model45// ---------------------------------------------------------------------------4647// worldModel is one world, stood up: its collections in payload order, its48// point features unpacked, its shape features projected onto the world square.49// It is immutable once built and shared by every request reading that build of50// that world.51type worldModel struct {52 Slug string53 Space vnext.CoordinateSpace54 Lenses []payloadLens55 Attrs map[string]string56 Origin string57 Grid tileGrid58 Members []*collectionModel59 ByID map[string]*collectionModel60 Points []*pointModel61 PointByID map[string]*pointModel62 Shapes []*shapeModel63 ShapeByID map[string]*shapeModel64}6566// collectionModel is one collection with everything a row needs already67// decided: its kind, its curated label policy, whether it starts hidden.68type collectionModel struct {69 ID string70 FeatureSet string71 Title string72 Kind string73 Group string74 Icon string7576 // IconAsset is the artwork the collection wears, as a path under the77 // build's `icons/`. The map composes a marker from it and the legend78 // draws it as the row's mark; both name it the same way, which is what79 // keeps a row and the pins it stands for looking like each other.80 IconAsset string8182 // Color and IconColor are the collection's declared accent, in the83 // order the seam consults them. Neither is a decision -- the one colour84 // a collection wears is `collectionColor`.85 Color string86 IconColor string8788 Attrs map[string]string89 Curated string // the producer's label policy, through semconv90 RenderAs string91 Hidden bool // the payload's own "visible": false92 Count int93 Index int94 Shapes []*shapeModel95}9697// Domain is the ground an isolate may touch: point collections isolate98// against point collections, shape collections against shape collections, so99// highlighting a region and then asking for only one resource leaves the100// region standing with the resource inside it.101func (c *collectionModel) Domain() string {102 if c.Kind == semconv.GeometryPoint {103 return "features"104 }105 return "zones"106}107108// pointModel is one packed location, already on the world square.109type pointModel struct {110 ID string111 Title string112 Coordinates string113 X, Y float64114 Shard int64115 Feature *vnext.Feature116 Collection *collectionModel117}118119// shapeModel is one path or area feature: its rings in world pixels, its120// extent, and the collection it belongs to.121type shapeModel struct {122 ID string123 Title string124 Subtitle string125 Parent string126 Depth int127 HasText bool128 Attrs map[string]string129 Feature *vnext.Feature130 Collection *collectionModel131 // Shard is the layer of a split world this ground belongs to. A shape on132 // another lens's shard is elsewhere in the world rather than filtered133 // out, exactly as a point is.134 Shard int64135 Polygons [][][]point // rings per polygon; ring 0 is the outline136 Lines [][]point137 MinX, MinY float64138 MaxX, MaxY float64139 Drawn bool // carries geometry the chart can draw140}141142type point struct{ X, Y float64 }143144// tileGrid is the window a world's degrees are projected through: the volume's145// grid, overridden by whatever the world declares for itself.146type tileGrid struct {147 SourceZoom int148 OriginX int149 OriginY int150 // FirstTile remains only for legacy v3 projections. Native worlds carry151 // distinct column and row origins.152 FirstTile int153 TileSize int154 Size int155}156157func (g tileGrid) project(lat, lng float64) (x, y float64) {158 worldTiles := math.Pow(2, float64(g.SourceZoom))159 xTile := (lng + 180) / 360 * worldTiles160 yTile := (1 - math.Asinh(math.Tan(lat*math.Pi/180))/math.Pi) / 2 * worldTiles161 originX, originY := g.origins()162 return (xTile - float64(originX)) * float64(g.TileSize),163 -(yTile - float64(originY)) * float64(g.TileSize)164}165166func (g tileGrid) unproject(x, y float64) (lat, lng float64) {167 worldTiles := math.Pow(2, float64(g.SourceZoom))168 originX, originY := g.origins()169 xTile := x/float64(g.TileSize) + float64(originX)170 yTile := y/float64(g.TileSize) + float64(originY)171 lng = xTile/worldTiles*360 - 180172 lat = math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi173 return lat, lng174}175176func (g tileGrid) origins() (int, int) {177 if g.OriginX != 0 || g.OriginY != 0 {178 return g.OriginX, g.OriginY179 }180 return g.FirstTile, g.FirstTile181}182183// ---------------------------------------------------------------------------184// Building185// ---------------------------------------------------------------------------186187// worlds is the model cache. Standing a world up decodes a payload and188// unpacks every location, which is work worth doing once per build rather than189// once per keystroke of a search. The key carries the build's stamp, so a new190// build is a new entry and nothing is ever stale; a handful of entries is kept191// because a reader wanders between two or three worlds and back.192type worldCache struct {193 mu sync.Mutex194 held map[string]*worldModel195 order []string196}197198const worldsHeld = 6199200func newWorldCache() *worldCache { return &worldCache{held: map[string]*worldModel{}} }201202func (c *worldCache) get(key string) (*worldModel, bool) {203 c.mu.Lock()204 defer c.mu.Unlock()205 model, ok := c.held[key]206 return model, ok207}208209func (c *worldCache) put(key string, model *worldModel) {210 c.mu.Lock()211 defer c.mu.Unlock()212 if _, held := c.held[key]; !held {213 c.order = append(c.order, key)214 for len(c.order) > worldsHeld {215 delete(c.held, c.order[0])216 c.order = c.order[1:]217 }218 }219 c.held[key] = model220}221222// world stands one world of one volume up, or answers nil when the payload223// cannot be read. A world that will not decode is not a reason to refuse a224// page: the chrome still renders, the legend is simply empty, and the failure225// is in the log where somebody can act on it.226func (a *App) world(volume hostenv.Volume, slug string) *worldModel {227 info := volume.Info()228 key := info.Slug + "@" + vnext.ShortStamp(info.Stamp) + "/" + slug229 if held, ok := a.worlds.get(key); ok {230 return held231 }232 semantic, err := a.semanticVolume(volume)233 if err != nil {234 return nil235 }236 world, held := semanticWorld(semantic, slug)237 if !held {238 return nil239 }240 model, err := buildVNextWorld(world, semantic.Assets)241 if err != nil {242 return nil243 }244 a.worlds.put(key, model)245 return model246}247248func (a *App) outlineWorld(volume hostenv.Volume, slug string) *worldModel {249 semantic, held := semanticWorld(volume.Outline(), slug)250 if !held {251 return nil252 }253 model, err := buildVNextWorld(semantic, volume.Outline().Assets)254 if err != nil {255 return nil256 }257 summaries, err := volume.FeatureSetSummaries()258 if err != nil {259 return nil260 }261 counts := make(map[string]int, len(summaries))262 for _, summary := range summaries {263 counts[summary.FeatureSet] = summary.Rows264 }265 for _, collection := range model.Members {266 collection.Count = counts[collection.FeatureSet]267 }268 return model269}270271func shapeDepth(model *worldModel, shape *shapeModel, guard int) int {272 if shape.Parent == "" || guard > 32 {273 return 0274 }275 parent, held := model.ShapeByID[shape.Parent]276 if !held {277 return 0278 }279 return 1 + shapeDepth(model, parent, guard+1)280}281282// ---------------------------------------------------------------------------283// Containment284// ---------------------------------------------------------------------------285286// grace is the pixel of slack containment allows. A pin dropped on a zone's287// border was put there to mean the zone, and exact point-in-polygon arithmetic288// would flip it out over the width of the line it stands on.289const grace = 1.0290291// contains answers whether a coordinate stands inside this feature: inside its292// outline and outside its holes, or within a pixel of anything it draws.293func (s *shapeModel) contains(x, y float64) bool {294 if x < s.MinX-grace || x > s.MaxX+grace || y < s.MinY-grace || y > s.MaxY+grace {295 return false296 }297 for _, polygon := range s.Polygons {298 if len(polygon) == 0 || !inRing(polygon[0], x, y) {299 continue300 }301 inHole := false302 for _, hole := range polygon[1:] {303 if inRing(hole, x, y) {304 inHole = true305 break306 }307 }308 if !inHole {309 return true310 }311 }312 for _, polygon := range s.Polygons {313 for _, ring := range polygon {314 if nearRing(ring, x, y) {315 return true316 }317 }318 }319 for _, line := range s.Lines {320 if nearRing(line, x, y) {321 return true322 }323 }324 return false325}326327// inRing is the crossing-number test, boundary-inclusive on the way in.328func inRing(ring []point, x, y float64) bool {329 inside := false330 for at, prior := 0, len(ring)-1; at < len(ring); prior, at = at, at+1 {331 a, b := ring[prior], ring[at]332 if (a.Y > y) == (b.Y > y) {333 continue334 }335 if x < (b.X-a.X)*(y-a.Y)/(b.Y-a.Y)+a.X {336 inside = !inside337 }338 }339 return inside340}341342// nearRing is the pixel of grace: within one world pixel of a drawn edge.343func nearRing(ring []point, x, y float64) bool {344 for at := 1; at < len(ring); at++ {345 if segmentDistanceSquared(ring[at-1], ring[at], x, y) <= grace*grace {346 return true347 }348 }349 return false350}351352func segmentDistanceSquared(a, b point, x, y float64) float64 {353 dx, dy := b.X-a.X, b.Y-a.Y354 span := dx*dx + dy*dy355 t := 0.0356 if span > 0 {357 t = ((x-a.X)*dx + (y-a.Y)*dy) / span358 t = math.Max(0, math.Min(1, t))359 }360 cx, cy := a.X+t*dx, a.Y+t*dy361 return (cx-x)*(cx-x) + (cy-y)*(cy-y)362}363364// sortedIDs is the order a set of collection ids is written in everywhere it365// is written: as strings, which is how the session record and the state366// island both spell it (docs/app.md §6).367func sortedIDs(ids []string) []string {368 out := append([]string(nil), ids...)369 sort.Strings(out)370 return out371}