1// Package basemap draws a city's ground from its own vector data: an2// offline bundle owes its reader a raster to stand on, and no outside tile3// service is welcome in one, so the streets, trails and boundaries a city4// publishes are rendered into the tile pyramid themselves.5//6// The renderer is deliberately deterministic. Features draw in a fixed7// z-order, within a role in the order given, onto NRGBA pixels, and the8// encoder runs with fixed settings -- so the same data and the same style9// code make byte-identical tiles within one Go toolchain, and the crawler's10// hash-compare writes keep the archive churn-free. A toolchain bump may11// re-spell the PNG bytes once; the pixels do not change.12//13// Only the deepest level of a pyramid is ever rendered: tools/tiles folds14// every lower level down from the deepest complete one and ignores captured15// intermediates, so rendering them would be work the pipeline throws away.16// Stroke widths are spelled at a reference zoom and scaled with depth,17// which under fold-down reads as constant width on the ground.18package basemap1920import (21 "bytes"22 "image"23 "image/color"24 "image/draw"25 "image/png"26 "math"2728 "golang.org/x/image/vector"2930 "github.com/FelineStateMachine/atlas/internal/mgdoc"31)3233// Feature is one thing to draw: ground as rings, lines as polylines, in34// world pixels of the 8192 square. Emphasis scales the role's stroke --35// zero means one, the unremarkable width.36type Feature struct {37 Role Role38 Rings [][][2]float6439 Lines [][][2]float6440 Emphasis float6441}4243// Renderer draws tiles of one city's basemap. Build it once per render with44// NewRenderer and ask it for tiles; it holds a spatial index over the45// deepest level so a tile only visits the features that touch it.46type Renderer struct {47 features []Feature48 maxZoom int49 // index buckets feature ordinals by deepest-level tile, the level the50 // crawler actually renders.51 index map[[2]int][]int52}5354// NewRenderer prepares features for drawing. Ring winding is normalized55// here, once: the first ring of a polygon is ground and winds one way,56// every further ring is a hole and winds the other, whatever the source57// said -- real exports do not reliably follow the GeoJSON convention.58func NewRenderer(features []Feature, maxZoom int) *Renderer {59 r := &Renderer{features: features, maxZoom: maxZoom, index: make(map[[2]int][]int)}60 tiles := 1 << maxZoom61 worldTile := float64(mgdoc.WorldSize) / float64(tiles)62 for at := range r.features {63 feature := &r.features[at]64 normalizeWinding(feature.Rings)65 reach := bounds(feature.Rings)66 lineReach := bounds(feature.Lines)67 reach.minX = min(reach.minX, lineReach.minX)68 reach.minY = min(reach.minY, lineReach.minY)69 reach.maxX = max(reach.maxX, lineReach.maxX)70 reach.maxY = max(reach.maxY, lineReach.maxY)71 if reach.minX > reach.maxX {72 continue73 }74 // A stroke reaches beyond its centerline; index with a margin so a75 // wide boundary still lands in the neighbouring tile it bleeds into.76 margin := strokeMargin * worldTile / tileSize77 firstX := clampTile(int(math.Floor((reach.minX-margin)/worldTile)), tiles)78 lastX := clampTile(int(math.Floor((reach.maxX+margin)/worldTile)), tiles)79 firstY := clampTile(int(math.Floor((reach.minY-margin)/worldTile)), tiles)80 lastY := clampTile(int(math.Floor((reach.maxY+margin)/worldTile)), tiles)81 for x := firstX; x <= lastX; x++ {82 for y := firstY; y <= lastY; y++ {83 key := [2]int{x, y}84 r.index[key] = append(r.index[key], at)85 }86 }87 }88 return r89}9091// tileSize is the pipeline's tile edge.92const tileSize = 2569394// strokeMargin is the bleed a tile draws beyond its own edge, in tile95// pixels: wide enough for the widest stroke's half plus antialiasing, so96// the cut never shows and neighbours continue each other exactly.97const strokeMargin = 89899func clampTile(tile, tiles int) int {100 if tile < 0 {101 return 0102 }103 if tile >= tiles {104 return tiles - 1105 }106 return tile107}108109// normalizeWinding forces a polygon's rings into the drawing's convention:110// ground winds positive, holes negative, so the rasterizer's winding111// accumulation cuts the holes out.112func normalizeWinding(rings [][][2]float64) {113 for at, ring := range rings {114 if len(ring) < 3 {115 continue116 }117 area := signedArea(ring)118 if at == 0 && area < 0 || at > 0 && area > 0 {119 reverse(ring)120 }121 }122}123124// Tile draws one tile of one level. Every tile exists -- a tile nothing125// touches is the flat background -- because the pipeline needs the deepest126// level complete, and the empty ones cost almost nothing to keep.127func (r *Renderer) Tile(zoom, x, y int) *image.NRGBA {128 img := image.NewNRGBA(image.Rect(0, 0, tileSize, tileSize))129 draw.Draw(img, img.Bounds(), image.NewUniform(Background), image.Point{}, draw.Src)130131 worldTile := float64(mgdoc.WorldSize) / float64(int(1)<<zoom)132 scale := tileSize / worldTile133 originX := float64(x) * worldTile134 originY := float64(y) * worldTile135 window := box{136 minX: originX - strokeMargin/scale,137 minY: originY - strokeMargin/scale,138 maxX: originX + worldTile + strokeMargin/scale,139 maxY: originY + worldTile + strokeMargin/scale,140 }141 widthScale := math.Pow(2, float64(zoom-referenceZoom))142143 var candidates []int144 if zoom == r.maxZoom {145 candidates = r.index[[2]int{x, y}]146 } else {147 candidates = make([]int, 0, len(r.features))148 for at := range r.features {149 candidates = append(candidates, at)150 }151 }152153 local := func(p [2]float64) [2]float64 {154 return [2]float64{(p[0] - originX) * scale, (p[1] - originY) * scale}155 }156157 for _, role := range zOrder {158 style := styles[role]159 var fills, strokes *vector.Rasterizer160 for _, at := range candidates {161 feature := &r.features[at]162 if feature.Role != role {163 continue164 }165 if style.Fill.A > 0 && len(feature.Rings) > 0 {166 if fills == nil {167 fills = vector.NewRasterizer(tileSize, tileSize)168 }169 for _, ring := range feature.Rings {170 clipped := clipRing(ring, window)171 if clipped == nil {172 continue173 }174 first := local(clipped[0])175 moveTo(fills, first[0], first[1])176 for _, p := range clipped[1:] {177 at := local(p)178 lineTo(fills, at[0], at[1])179 }180 fills.ClosePath()181 }182 }183 if style.Stroke.A > 0 && style.StrokeWidth > 0 {184 width := style.StrokeWidth * widthScale185 if feature.Emphasis > 0 {186 width *= feature.Emphasis187 }188 if strokes == nil {189 strokes = vector.NewRasterizer(tileSize, tileSize)190 }191 strokePaths(strokes, feature.Lines, window, local, width, style.Dash, widthScale)192 // A filled role with a rim strokes its ring outlines too.193 if len(feature.Lines) == 0 && len(feature.Rings) > 0 {194 strokePaths(strokes, closedPaths(feature.Rings), window, local, width, style.Dash, widthScale)195 }196 }197 }198 if fills != nil {199 fills.Draw(img, img.Bounds(), image.NewUniform(style.Fill), image.Point{})200 }201 if strokes != nil {202 strokes.Draw(img, img.Bounds(), image.NewUniform(style.Stroke), image.Point{})203 }204 }205 return img206}207208// strokePaths clips each polyline to the bled window and strokes what209// survives, keeping the dash rhythm riding along each path.210func strokePaths(r *vector.Rasterizer, paths [][][2]float64, window box, local func([2]float64) [2]float64, width float64, dash [2]float64, widthScale float64) {211 scaledDash := [2]float64{dash[0] * widthScale, dash[1] * widthScale}212 period := scaledDash[0] + scaledDash[1]213 lscale := localScale(local)214 for _, path := range paths {215 phase := 0.0216 for at := 0; at+1 < len(path); at++ {217 from, to, kept := clipSegment(path[at], path[at+1], window)218 if kept {219 a, b := local(from), local(to)220 if dash[0] > 0 {221 // The clipped head keeps the phase its cut-off lead222 // carried, so neighbouring tiles agree on the rhythm.223 lead := math.Hypot(from[0]-path[at][0], from[1]-path[at][1]) * lscale224 strokeDashed(r, a, b, width, scaledDash, math.Mod(phase+lead, max(period, 1)))225 } else {226 strokeSegment(r, a, b, width)227 }228 }229 // The rhythm advances by the whole segment whether or not its230 // middle was drawn here.231 length := math.Hypot(path[at+1][0]-path[at][0], path[at+1][1]-path[at][1]) * lscale232 if period > 0 {233 phase = math.Mod(phase+length, period)234 }235 }236 }237}238239// localScale reports how many tile pixels one world pixel spans under the240// local transform -- the transform is a uniform scale, so any unit segment241// measures it.242func localScale(local func([2]float64) [2]float64) float64 {243 a := local([2]float64{0, 0})244 b := local([2]float64{1, 0})245 return math.Abs(b[0] - a[0])246}247248// closedPaths spells rings as polylines whose last point returns to the249// first, for stroking a filled shape's rim.250func closedPaths(rings [][][2]float64) [][][2]float64 {251 out := make([][][2]float64, 0, len(rings))252 for _, ring := range rings {253 if len(ring) < 3 {254 continue255 }256 closed := make([][2]float64, 0, len(ring)+1)257 closed = append(closed, ring...)258 if ring[0] != ring[len(ring)-1] {259 closed = append(closed, ring[0])260 }261 out = append(out, closed)262 }263 return out264}265266// EncodePNG spells a tile once, with fixed settings: archive tiles are267// written once and read forever, so the slowest, smallest compression is268// the right trade, and fixing it is part of determinism.269func EncodePNG(img *image.NRGBA) ([]byte, error) {270 var out bytes.Buffer271 encoder := png.Encoder{CompressionLevel: png.BestCompression}272 if err := encoder.Encode(&out, img); err != nil {273 return nil, err274 }275 return out.Bytes(), nil276}277278// BackgroundColor reports the flat ground color, for the checks that the279// pipeline's placeholder detection sees the same background this package280// painted.281func BackgroundColor() color.NRGBA {282 return Background283}