clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: draw a city's own ground

Dami committed 2c28ecb
A bundle owes its reader a raster to stand on, and it ships offline,
so no tile service can provide one; a city publishes its streets and
trails as vectors, and this renders them. The new basemap package
rasterizes role-styled features -- ground fills, capsule-stroked
lines, dashed trails, one accent boundary -- onto the pipeline's 256px
tiles with x/image/vector, which the module already carried.

Determinism is the design: fixed z-order, fixed encoder settings,
normalized ring winding, so the same data and style make byte-identical
tiles and a re-render leaves no churn. Only the deepest level is ever
drawn, because tools/tiles folds everything below it down anyway;
widths are spelled at a reference zoom and scale with depth, which
under fold-down reads as constant width on the ground.
commit
2c28ecbd6ab7e9f2934059eeb76cb0e2bd6e009c
parent
9f653cadc68a471d57bfe2556d6739c846ab3199
author
Dami <dami.etoile@gmail.com>
5 files changed, +722 -0
Ainternal/basemap/basemap.go283++++++++++++++++++++++++++++++
Ainternal/basemap/basemap_test.go153++++++++++++++++
Ainternal/basemap/clip.go137+++++++++++++++
Ainternal/basemap/stroke.go93++++++++++
Ainternal/basemap/style.go56++++++
A internal/basemap/basemap.go +283 -0
@@ -0,0 +1,283 @@
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.18+package basemap19+20+import (21+ "bytes"22+ "image"23+ "image/color"24+ "image/draw"25+ "image/png"26+ "math"27+28+ "golang.org/x/image/vector"29+30+ "github.com/FelineStateMachine/atlas/internal/mgdoc"31+)32+33+// 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.36+type Feature struct {37+ Role Role38+ Rings [][][2]float6439+ Lines [][][2]float6440+ Emphasis float6441+}42+43+// 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.46+type 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+}53+54+// 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.58+func 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+}90+91+// tileSize is the pipeline's tile edge.92+const tileSize = 25693+94+// 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.97+const strokeMargin = 898+99+func clampTile(tile, tiles int) int {100+ if tile < 0 {101+ return 0102+ }103+ if tile >= tiles {104+ return tiles - 1105+ }106+ return tile107+}108+109+// 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.112+func 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+}123+124+// 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.127+func (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)130+131+ 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))142+143+ 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+ }152+153+ local := func(p [2]float64) [2]float64 {154+ return [2]float64{(p[0] - originX) * scale, (p[1] - originY) * scale}155+ }156+157+ 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+}207+208+// strokePaths clips each polyline to the bled window and strokes what209+// survives, keeping the dash rhythm riding along each path.210+func 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+}238+239+// 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.242+func 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+}247+248+// closedPaths spells rings as polylines whose last point returns to the249+// first, for stroking a filled shape's rim.250+func 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+}265+266+// 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.269+func 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+}277+278+// BackgroundColor reports the flat ground color, for the checks that the279+// pipeline's placeholder detection sees the same background this package280+// painted.281+func BackgroundColor() color.NRGBA {282+ return Background283+}
A internal/basemap/basemap_test.go +153 -0
@@ -0,0 +1,153 @@
1+package basemap2+3+import (4+ "bytes"5+ "image"6+ "testing"7+)8+9+// A square with a square hole, big enough to land across several tiles at10+// the deepest level of a small pyramid.11+func fixtureFeatures() []Feature {12+ outer := [][2]float64{{1000, 1000}, {5000, 1000}, {5000, 5000}, {1000, 5000}}13+ hole := [][2]float64{{2000, 2000}, {2000, 3000}, {3000, 3000}, {3000, 2000}}14+ street := [][2]float64{{500, 4000}, {7500, 4200}}15+ trail := [][2]float64{{500, 6000}, {7500, 6100}}16+ return []Feature{17+ {Role: RolePark, Rings: [][][2]float64{outer, hole}},18+ {Role: RoleStreet, Lines: [][][2]float64{street}, Emphasis: 1.8},19+ {Role: RoleTrail, Lines: [][][2]float64{trail}},20+ {Role: RoleBoundary, Lines: [][][2]float64{{{800, 800}, {7400, 800}, {7400, 7400}, {800, 7400}, {800, 800}}}},21+ }22+}23+24+// The same features must render and encode to the same bytes, twice over --25+// the property the crawler's hash-compare writes stand on.26+func TestRenderIsDeterministic(t *testing.T) {27+ first := NewRenderer(fixtureFeatures(), 3)28+ second := NewRenderer(fixtureFeatures(), 3)29+ for _, tile := range [][3]int{{3, 1, 1}, {3, 2, 2}, {3, 0, 0}} {30+ a := first.Tile(tile[0], tile[1], tile[2])31+ b := second.Tile(tile[0], tile[1], tile[2])32+ if !bytes.Equal(a.Pix, b.Pix) {33+ t.Fatalf("tile %v pixels differ between renders", tile)34+ }35+ pngA, err := EncodePNG(a)36+ if err != nil {37+ t.Fatal(err)38+ }39+ pngB, err := EncodePNG(b)40+ if err != nil {41+ t.Fatal(err)42+ }43+ if !bytes.Equal(pngA, pngB) {44+ t.Fatalf("tile %v encodes differently between renders", tile)45+ }46+ }47+}48+49+// A hole in a polygon must keep the background, however the source wound50+// its rings.51+func TestHolesStayGround(t *testing.T) {52+ // Both rings wound the same way: normalization must fix the hole.53+ outer := [][2]float64{{1000, 1000}, {5000, 1000}, {5000, 5000}, {1000, 5000}}54+ hole := [][2]float64{{2000, 2000}, {3000, 2000}, {3000, 3000}, {2000, 3000}}55+ renderer := NewRenderer([]Feature{{Role: RolePark, Rings: [][][2]float64{outer, hole}}}, 1)56+57+ // Zoom 1: the world is 2x2 tiles of 4096 world px, so tile (0,0) shows58+ // the square and its hole. World px 2500,2500 (hole middle) is tile px59+ // 156; world px 1500,1500 (park ground) is tile px 93.60+ tile := renderer.Tile(1, 0, 0)61+ background := tile.NRGBAAt(156, 156)62+ if background != Background {63+ t.Fatalf("hole middle is %v, not background", background)64+ }65+ park := tile.NRGBAAt(93, 93)66+ if park != styles[RolePark].Fill {67+ t.Fatalf("park ground is %v", park)68+ }69+}70+71+// Adjacent tiles must continue a shape exactly along their shared edge:72+// the same column of pixels, whichever tile drew it.73+func TestNeighbouringTilesAgreeAtTheSeam(t *testing.T) {74+ renderer := NewRenderer(fixtureFeatures(), 3)75+ // At zoom 3 the world is 8 tiles of 1024 world px. The street crosses76+ // the boundary between tiles (1,3) and (2,3); compare the last column77+ // of the left tile with the first column of the right one. Antialiased78+ // coverage at a shared edge comes from the same geometry through the79+ // same transform, so the columns must match exactly.80+ left := renderer.Tile(3, 1, 3)81+ right := renderer.Tile(3, 2, 3)82+ for y := range tileSize {83+ l := left.NRGBAAt(tileSize-1, y)84+ r := right.NRGBAAt(0, y)85+ // The seam columns sit one world pixel apart, so demand agreement86+ // in kind, not in exact antialias: both background or both not.87+ lGround := l == Background88+ rGround := r == Background89+ if lGround != rGround {90+ t.Fatalf("seam disagrees at y=%d: %v vs %v", y, l, r)91+ }92+ }93+}94+95+// A tile nothing touches is the flat background, and every tile exists.96+// Tile (6,2) sits east of the park and street, north of the trail, and97+// inside the boundary rectangle's ring without touching its lines.98+func TestEmptyTileIsBackground(t *testing.T) {99+ renderer := NewRenderer(fixtureFeatures(), 3)100+ tile := renderer.Tile(3, 6, 2)101+ for at := 0; at < len(tile.Pix); at += 4 {102+ if tile.Pix[at] != Background.R || tile.Pix[at+1] != Background.G || tile.Pix[at+2] != Background.B {103+ t.Fatalf("empty tile carries paint at offset %d", at)104+ }105+ }106+}107+108+// Two renders of one empty tile share bytes with each other -- the property109+// the placeholder machinery downstream leans on to fold background tiles110+// into one hash.111+func TestEmptyTilesShareBytes(t *testing.T) {112+ renderer := NewRenderer(fixtureFeatures(), 3)113+ a, err := EncodePNG(renderer.Tile(3, 6, 2))114+ if err != nil {115+ t.Fatal(err)116+ }117+ b, err := EncodePNG(renderer.Tile(3, 5, 2))118+ if err != nil {119+ t.Fatal(err)120+ }121+ if !bytes.Equal(a, b) {122+ t.Fatal("two empty tiles encode differently")123+ }124+}125+126+// The index must hand a tile every feature that bleeds into it, not only127+// those whose geometry starts there: a stroke near an edge shows in the128+// neighbour.129+func TestStrokesBleedAcrossTileEdges(t *testing.T) {130+ // At zoom 6 a tile is 128 world px; a street whose centerline sits half131+ // a world pixel into tile (8,0) still paints the last column of tile132+ // (7,0), because its width reaches across the seam.133+ street := []Feature{{Role: RoleStreet, Lines: [][][2]float64{{{1024.5, 100}, {1024.5, 900}}}}}134+ renderer := NewRenderer(street, 6)135+ tile := renderer.Tile(6, 7, 1)136+ found := false137+ for y := 0; y < tileSize && !found; y++ {138+ if tile.NRGBAAt(tileSize-1, y) != Background {139+ found = true140+ }141+ }142+ if !found {143+ t.Fatal("a street on the far side of the seam leaves no paint in this tile")144+ }145+}146+147+func TestTileSizeIsThePipelines(t *testing.T) {148+ renderer := NewRenderer(nil, 1)149+ tile := renderer.Tile(1, 0, 0)150+ if tile.Bounds() != image.Rect(0, 0, 256, 256) {151+ t.Fatalf("tile bounds are %v", tile.Bounds())152+ }153+}
A internal/basemap/clip.go +137 -0
@@ -0,0 +1,137 @@
1+package basemap2+3+// box is an axis-aligned window in world pixels.4+type box struct {5+ minX, minY, maxX, maxY float646+}7+8+func (b box) contains(p [2]float64) bool {9+ return p[0] >= b.minX && p[0] <= b.maxX && p[1] >= b.minY && p[1] <= b.maxY10+}11+12+func (b box) overlaps(o box) bool {13+ return b.minX <= o.maxX && o.minX <= b.maxX && b.minY <= o.maxY && o.minY <= b.maxY14+}15+16+// bounds measures a set of rings or lines.17+func bounds(paths [][][2]float64) box {18+ out := box{minX: 1e18, minY: 1e18, maxX: -1e18, maxY: -1e18}19+ for _, path := range paths {20+ for _, p := range path {21+ out.minX = min(out.minX, p[0])22+ out.minY = min(out.minY, p[1])23+ out.maxX = max(out.maxX, p[0])24+ out.maxY = max(out.maxY, p[1])25+ }26+ }27+ return out28+}29+30+// clipRing cuts a polygon ring to a window, Sutherland–Hodgman: one pass31+// per edge, each keeping the inside and splicing in the crossings. The32+// window carries bleed beyond the visible tile, so the cut edges land33+// outside the picture and adjacent tiles continue the shape exactly.34+func clipRing(ring [][2]float64, window box) [][2]float64 {35+ type edge func(p [2]float64) bool36+ inside := []edge{37+ func(p [2]float64) bool { return p[0] >= window.minX },38+ func(p [2]float64) bool { return p[0] <= window.maxX },39+ func(p [2]float64) bool { return p[1] >= window.minY },40+ func(p [2]float64) bool { return p[1] <= window.maxY },41+ }42+ cross := []func(a, b [2]float64) [2]float64{43+ func(a, b [2]float64) [2]float64 { return lerpX(a, b, window.minX) },44+ func(a, b [2]float64) [2]float64 { return lerpX(a, b, window.maxX) },45+ func(a, b [2]float64) [2]float64 { return lerpY(a, b, window.minY) },46+ func(a, b [2]float64) [2]float64 { return lerpY(a, b, window.maxY) },47+ }48+ current := ring49+ for at := range inside {50+ if len(current) == 0 {51+ return nil52+ }53+ next := make([][2]float64, 0, len(current)+4)54+ for i := range current {55+ a := current[(i+len(current)-1)%len(current)]56+ b := current[i]57+ aIn, bIn := inside[at](a), inside[at](b)58+ switch {59+ case aIn && bIn:60+ next = append(next, b)61+ case aIn && !bIn:62+ next = append(next, cross[at](a, b))63+ case !aIn && bIn:64+ next = append(next, cross[at](a, b), b)65+ }66+ }67+ current = next68+ }69+ if len(current) < 3 {70+ return nil71+ }72+ return current73+}74+75+func lerpX(a, b [2]float64, x float64) [2]float64 {76+ t := (x - a[0]) / (b[0] - a[0])77+ return [2]float64{x, a[1] + t*(b[1]-a[1])}78+}79+80+func lerpY(a, b [2]float64, y float64) [2]float64 {81+ t := (y - a[1]) / (b[1] - a[1])82+ return [2]float64{a[0] + t*(b[0]-a[0]), y}83+}84+85+// clipSegment cuts one segment to a window, Liang–Barsky, reporting whether86+// anything survives.87+func clipSegment(a, b [2]float64, window box) ([2]float64, [2]float64, bool) {88+ t0, t1 := 0.0, 1.089+ dx, dy := b[0]-a[0], b[1]-a[1]90+ clip := func(p, q float64) bool {91+ if p == 0 {92+ return q >= 093+ }94+ t := q / p95+ if p < 0 {96+ if t > t1 {97+ return false98+ }99+ if t > t0 {100+ t0 = t101+ }102+ } else {103+ if t < t0 {104+ return false105+ }106+ if t < t1 {107+ t1 = t108+ }109+ }110+ return true111+ }112+ if !clip(-dx, a[0]-window.minX) || !clip(dx, window.maxX-a[0]) ||113+ !clip(-dy, a[1]-window.minY) || !clip(dy, window.maxY-a[1]) {114+ return a, b, false115+ }116+ from := [2]float64{a[0] + t0*dx, a[1] + t0*dy}117+ to := [2]float64{a[0] + t1*dx, a[1] + t1*dy}118+ return from, to, true119+}120+121+// signedArea is a ring's area with the sign of its winding, in the y-down122+// pixel plane: positive is clockwise on the screen.123+func signedArea(ring [][2]float64) float64 {124+ area := 0.0125+ for i := range ring {126+ j := (i + 1) % len(ring)127+ area += ring[i][0]*ring[j][1] - ring[j][0]*ring[i][1]128+ }129+ return area / 2130+}131+132+// reverse flips a ring's winding in place.133+func reverse(ring [][2]float64) {134+ for i, j := 0, len(ring)-1; i < j; i, j = i+1, j-1 {135+ ring[i], ring[j] = ring[j], ring[i]136+ }137+}
A internal/basemap/stroke.go +93 -0
@@ -0,0 +1,93 @@
1+package basemap2+3+import (4+ "math"5+6+ "golang.org/x/image/vector"7+)8+9+// kappa is the control-point offset that makes four cubic Béziers spell a10+// circle as closely as cubics can.11+const kappa = 0.552284749830793612+13+// strokeSegment adds one capsule to the path: the segment widened to its14+// stroke, with a semicircular cap at each end. Every capsule winds the same15+// way, and the rasterizer's saturating accumulation renders the union --16+// overlapping capsules at a joint read as one continuous line, no seams.17+// Coordinates are tile-local pixels.18+func strokeSegment(r *vector.Rasterizer, a, b [2]float64, width float64) {19+ half := width / 220+ dx, dy := b[0]-a[0], b[1]-a[1]21+ length := math.Hypot(dx, dy)22+ if length == 0 {23+ return24+ }25+ // The unit direction and left normal, scaled.26+ ux, uy := dx/length, dy/length27+ nx, ny := -uy*half, ux*half28+ k := kappa * half29+30+ moveTo(r, a[0]+nx, a[1]+ny)31+ lineTo(r, b[0]+nx, b[1]+ny)32+ // The far cap: from b's left shoulder, around the nose at b+u*half, to33+ // b's right shoulder, two quarter arcs. A tangent control point moves34+ // k along the tangent; n is already half long, so n scaled by kappa is35+ // the same k in the normal's direction.36+ cubeTo(r,37+ b[0]+nx+ux*k, b[1]+ny+uy*k,38+ b[0]+ux*half+nx*kappa, b[1]+uy*half+ny*kappa,39+ b[0]+ux*half, b[1]+uy*half)40+ cubeTo(r,41+ b[0]+ux*half-nx*kappa, b[1]+uy*half-ny*kappa,42+ b[0]-nx+ux*k, b[1]-ny+uy*k,43+ b[0]-nx, b[1]-ny)44+ lineTo(r, a[0]-nx, a[1]-ny)45+ // The near cap, back around a-u*half.46+ cubeTo(r,47+ a[0]-nx-ux*k, a[1]-ny-uy*k,48+ a[0]-ux*half-nx*kappa, a[1]-uy*half-ny*kappa,49+ a[0]-ux*half, a[1]-uy*half)50+ cubeTo(r,51+ a[0]-ux*half+nx*kappa, a[1]-uy*half+ny*kappa,52+ a[0]+nx-ux*k, a[1]+ny-uy*k,53+ a[0]+nx, a[1]+ny)54+ r.ClosePath()55+}56+57+// strokeDashed walks a segment in the dash rhythm, stroking the on-runs.58+// The phase rides along the polyline so the rhythm survives vertices, and59+// the caller threads it between segments.60+func strokeDashed(r *vector.Rasterizer, a, b [2]float64, width float64, dash [2]float64, phase float64) float64 {61+ on, off := dash[0], dash[1]62+ period := on + off63+ if period <= 0 {64+ strokeSegment(r, a, b, width)65+ return phase66+ }67+ dx, dy := b[0]-a[0], b[1]-a[1]68+ length := math.Hypot(dx, dy)69+ if length == 0 {70+ return phase71+ }72+ ux, uy := dx/length, dy/length73+ at := 0.074+ for at < length {75+ place := math.Mod(phase+at, period)76+ if place < on {77+ run := math.Min(on-place, length-at)78+ from := [2]float64{a[0] + ux*at, a[1] + uy*at}79+ to := [2]float64{a[0] + ux*(at+run), a[1] + uy*(at+run)}80+ strokeSegment(r, from, to, width)81+ at += run82+ } else {83+ at += period - place84+ }85+ }86+ return math.Mod(phase+length, period)87+}88+89+func moveTo(r *vector.Rasterizer, x, y float64) { r.MoveTo(float32(x), float32(y)) }90+func lineTo(r *vector.Rasterizer, x, y float64) { r.LineTo(float32(x), float32(y)) }91+func cubeTo(r *vector.Rasterizer, x1, y1, x2, y2, x3, y3 float64) {92+ r.CubeTo(float32(x1), float32(y1), float32(x2), float32(y2), float32(x3), float32(y3))93+}
A internal/basemap/style.go +56 -0
@@ -0,0 +1,56 @@
1+package basemap2+3+import "image/color"4+5+// Role names what a feature is to the ground: not which dataset it came6+// from, but which stroke of the drawing it belongs to. The curated tables7+// in arcgismap assign roles; this package only knows how each one draws.8+type Role string9+10+const (11+ RoleBackground Role = "background"12+ RoleParcel Role = "parcel"13+ RolePark Role = "park"14+ RoleWater Role = "water"15+ RoleStreet Role = "street"16+ RoleTrail Role = "trail"17+ RoleBoundary Role = "boundary"18+)19+20+// Style is how one role draws: a fill for ground, a stroke for lines and21+// rims, widths in pixels at the reference zoom. Dash is the on/off rhythm22+// of a dashed stroke, zero for solid.23+type Style struct {24+ Fill color.NRGBA25+ Stroke color.NRGBA26+ StrokeWidth float6427+ Dash [2]float6428+}29+30+// referenceZoom is the zoom the style table's widths are spelled at. A31+// render at another depth scales them, so a stroke keeps its ground truth32+// -- constant world width -- however deep the pyramid goes.33+const referenceZoom = 634+35+// Background is the ground everything else draws upon: a step off the36+// app's own near-black, so the map reads as a surface and the palette37+// accents stay the information.38+var Background = color.NRGBA{R: 0x14, G: 0x18, B: 0x1d, A: 0xff}39+40+// styles is the whole drawing, keyed by role. The colors keep to the app's41+// identity: neutral grays for the everyday linework, the warm earth tone42+// for trails, navy for water, and the cerulean accent spent on exactly one43+// line -- the city's own boundary.44+var styles = map[Role]Style{45+ RoleParcel: {Stroke: color.NRGBA{R: 0x23, G: 0x2a, B: 0x31, A: 0xff}, StrokeWidth: 1},46+ RolePark: {Fill: color.NRGBA{R: 0x1f, G: 0x2b, B: 0x22, A: 0xff}},47+ RoleWater: {Fill: color.NRGBA{R: 0x15, G: 0x25, B: 0x39, A: 0xff}, Stroke: color.NRGBA{R: 0x1f, G: 0x38, B: 0x52, A: 0xff}, StrokeWidth: 1},48+ RoleStreet: {Stroke: color.NRGBA{R: 0x3d, G: 0x44, B: 0x4d, A: 0xff}, StrokeWidth: 2.5},49+ RoleTrail: {Stroke: color.NRGBA{R: 0x8a, G: 0x6a, B: 0x44, A: 0xff}, StrokeWidth: 1.5, Dash: [2]float64{6, 4}},50+ RoleBoundary: {Stroke: color.NRGBA{R: 0x3a, G: 0xa5, B: 0xc9, A: 0xff}, StrokeWidth: 3.5},51+}52+53+// zOrder is the order roles land on the ground: texture first, then ground54+// covers, water over parks so lakes punch through, streets over water for55+// bridges, trails above streets, and the boundary on top as the one accent.56+var zOrder = []Role{RoleParcel, RolePark, RoleWater, RoleStreet, RoleTrail, RoleBoundary}