1package compose23import (4 "encoding/json"5 "math"67 "github.com/FelineStateMachine/atlas/internal/generate/doc"8 "github.com/FelineStateMachine/atlas/internal/generate/tiles"9)1011// surfaceGrid is the world square a lens is cut from: where it sits in the12// source tile grid, and how big it is in pixels.13type surfaceGrid struct {14 SourceZoom int15 FirstTile int16 TileSize int17 Size int18}1920// surfaceMargin is how much ground is kept around a world's outermost feature,21// so the last pin sits inside a cell rather than along its edge and a map drawn22// right up to its own border keeps a little of what surrounds it. It is a floor23// of 32 world pixels and otherwise a fiftieth of the longer span.24const (25 surfaceMarginFloor = 32.026 surfaceMarginShare = 0.0227)2829// markSurfaces measures the ground a world actually covers, as against the30// window its raster fills.31//32// Many sheets are drawn inside a printed border or a wide margin of nothing, and33// dividing that whole window into named cells spends most of them on blank34// paper. So what a grid divides is measured from the world's own contents:35// where its features stand, and the ground its shapes outline. That is the36// archive answering for each world rather than a list of worlds to treat37// specially, and it says the same thing about a border whether the border is38// solid or patterned.39func markSurfaces(w *composedWorld, grid surfaceGrid) {40 for index := range w.Lenses {41 lens := &w.Lenses[index]42 window := tiles.Box{Width: grid.Size, Height: grid.Size}43 if lens.Bounds != nil {44 window = *lens.Bounds45 }46 box, ok := contentExtent(w.Collections, lens.Shard, window, grid)47 if !ok {48 continue49 }50 if lens.Surface != nil {51 box = unionBox(*lens.Surface, box)52 }53 box = intersectBox(box, window)54 lens.Surface = &box55 }56}5758// contentExtent is where a world's own contents lie, in the world pixel space59// the tiles are cut in and measured down from the top of it, as the raster clip60// is. Anything drawn outside the window is left out of the reckoning: a few61// features sit off the sheet entirely, and one of those would stretch the ground62// back over everything it had just left out.63func contentExtent(collections []composedCollection, shard int64, window tiles.Box, grid surfaceGrid) (tiles.Box, bool) {64 left, top := math.Inf(1), math.Inf(1)65 right, bottom := math.Inf(-1), math.Inf(-1)66 found := false67 grow := func(x, y float64) {68 if x < float64(window.X) || y < float64(window.Y) ||69 x > float64(window.X+window.Width) || y > float64(window.Y+window.Height) {70 return71 }72 left, top = math.Min(left, x), math.Min(top, y)73 right, bottom = math.Max(right, x), math.Max(bottom, y)74 found = true75 }76 for _, collection := range collections {77 for _, feature := range collection.Features {78 if shard != 0 && feature.Shard != shard {79 continue80 }81 if collection.Kind == doc.KindPoint {82 if feature.At == nil {83 continue84 }85 grow(projectX(feature.At.Lng, grid), projectY(feature.At.Lat, grid))86 continue87 }88 for _, part := range feature.Geometry {89 for _, point := range flattenCoordinates(part.Coordinates) {90 grow(projectX(point[0], grid), projectY(point[1], grid))91 }92 }93 }94 }95 if !found {96 return tiles.Box{}, false97 }98 margin := math.Max(surfaceMarginFloor,99 surfaceMarginShare*math.Max(right-left, bottom-top))100 return tiles.Box{101 X: int(left - margin),102 Y: int(top - margin),103 Width: int((right - left) + margin*2),104 Height: int((bottom - top) + margin*2),105 }, true106}107108// projectX and projectY mirror the reader's own projection, so bounds land in109// the same world pixel space the tile pyramid and the raster clip already use.110// The volume's coordinates are spherical Mercator against its tile window,111// whether that window pictures a real planet or a game's artwork.112func projectX(lng float64, grid surfaceGrid) float64 {113 worldTiles := math.Pow(2, float64(grid.SourceZoom))114 xTile := ((lng + 180) / 360) * worldTiles115 return (xTile - float64(grid.FirstTile)) * float64(grid.TileSize)116}117118func projectY(lat float64, grid surfaceGrid) float64 {119 worldTiles := math.Pow(2, float64(grid.SourceZoom))120 yTile := (1 - math.Asinh(math.Tan(lat*math.Pi/180))/math.Pi) / 2 * worldTiles121 return (yTile - float64(grid.FirstTile)) * float64(grid.TileSize)122}123124// flattenCoordinates walks a GeoJSON coordinate array of any nesting and yields125// every position in it, as [longitude, latitude]. The geometry itself is carried126// opaquely through composition, so this is the one place its shape is looked127// into, and it looks only for pairs of numbers.128func flattenCoordinates(raw json.RawMessage) [][2]float64 {129 var value any130 if err := json.Unmarshal(raw, &value); err != nil {131 return nil132 }133 var out [][2]float64134 var walk func(node any)135 walk = func(node any) {136 list, ok := node.([]any)137 if !ok {138 return139 }140 if len(list) == 2 {141 x, xOK := list[0].(float64)142 y, yOK := list[1].(float64)143 if xOK && yOK {144 out = append(out, [2]float64{x, y})145 return146 }147 }148 for _, child := range list {149 walk(child)150 }151 }152 walk(value)153 return out154}155156func unionBox(a, b tiles.Box) tiles.Box {157 left, top := min(a.X, b.X), min(a.Y, b.Y)158 right, bottom := max(a.X+a.Width, b.X+b.Width), max(a.Y+a.Height, b.Y+b.Height)159 return tiles.Box{X: left, Y: top, Width: right - left, Height: bottom - top}160}161162// intersectBox is the overlap of two rectangles, or the second of them where163// they do not meet at all.164func intersectBox(a, b tiles.Box) tiles.Box {165 left, top := max(a.X, b.X), max(a.Y, b.Y)166 right, bottom := min(a.X+a.Width, b.X+b.Width), min(a.Y+a.Height, b.Y+b.Height)167 if right <= left || bottom <= top {168 return b169 }170 return tiles.Box{X: left, Y: top, Width: right - left, Height: bottom - top}171}