├── .gitignore ├── renovate.json ├── util.go ├── go.mod ├── examples ├── multiline-attribution │ └── multiline-attribution.go ├── overlay │ └── main.go ├── idl │ └── main.go ├── stars │ └── stars.go └── text-marker │ └── text-marker.go ├── Makefile ├── color.go ├── map_object.go ├── LICENSE ├── tile_cache.go ├── context_test.go ├── color_test.go ├── bbox.go ├── .github └── workflows │ └── ci.yml ├── area.go ├── path.go ├── image_marker.go ├── circle.go ├── tile_fetcher.go ├── marker.go ├── go.sum ├── create-static-map └── create-static-map.go ├── tile_provider.go ├── README.md └── context.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.png 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchPackageNames": ["^golang\\.org/.*$", "^google\\.golang\\.org/.*$"], 9 | "automerge": true 10 | }, 11 | { 12 | "matchPackageNames": ["github\\.com/flopp/.*$"], 13 | "automerge": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "strings" 10 | ) 11 | 12 | // hasPrefix checks if 's' has prefix 'prefix'; returns 'true' and the remainder on success, and 'false', 's' otherwise. 13 | func hasPrefix(s string, prefix string) (bool, string) { 14 | if strings.HasPrefix(s, prefix) { 15 | return true, strings.TrimPrefix(s, prefix) 16 | } 17 | return false, s 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flopp/go-staticmaps 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/flopp/go-coordsparser v0.0.0-20250311184423-61a7ff62d17c 9 | github.com/fogleman/gg v1.3.0 10 | github.com/golang/geo v0.0.0-20250627182359-f4b81656db99 11 | github.com/jessevdk/go-flags v1.6.1 12 | github.com/mazznoer/csscolorparser v0.1.6 13 | github.com/tkrajina/gpxgo v1.4.0 14 | golang.org/x/image v0.28.0 15 | ) 16 | 17 | require ( 18 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 19 | golang.org/x/net v0.33.0 // indirect 20 | golang.org/x/sys v0.28.0 // indirect 21 | golang.org/x/text v0.26.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /examples/multiline-attribution/multiline-attribution.go: -------------------------------------------------------------------------------- 1 | // This is an example on how to use a multiline attribution string. 2 | 3 | package main 4 | 5 | import ( 6 | sm "github.com/flopp/go-staticmaps" 7 | "github.com/fogleman/gg" 8 | "github.com/golang/geo/s2" 9 | ) 10 | 11 | func main() { 12 | ctx := sm.NewContext() 13 | ctx.SetSize(400, 300) 14 | ctx.OverrideAttribution("This is a\nmulti-line\nattribution string.") 15 | ctx.SetCenter(s2.LatLngFromDegrees(48, 7.9)) 16 | ctx.SetZoom(13) 17 | 18 | img, err := ctx.Render() 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | if err := gg.SavePNG("multiline-attribution.png", img); err != nil { 24 | panic(err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/overlay/main.go: -------------------------------------------------------------------------------- 1 | // This is an example on how to use a map overlay. 2 | 3 | package main 4 | 5 | import ( 6 | sm "github.com/flopp/go-staticmaps" 7 | "github.com/fogleman/gg" 8 | "github.com/golang/geo/s2" 9 | ) 10 | 11 | func main() { 12 | ctx := sm.NewContext() 13 | ctx.SetSize(1600, 1200) 14 | 15 | ctx.SetCenter(s2.LatLngFromDegrees(48.78110, -3.59638)) 16 | ctx.SetZoom(15) 17 | 18 | // base map 19 | ctx.SetTileProvider(sm.NewTileProviderOpenStreetMaps()) 20 | // OpenSeaMap as a overlay to the base map 21 | ctx.AddOverlay(sm.NewTileProviderOpenSeaMap()) 22 | 23 | img, err := ctx.Render() 24 | if err != nil { 25 | panic(err) 26 | } 27 | 28 | if err := gg.SavePNG("overlay.png", img); err != nil { 29 | panic(err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup-test 2 | setup-test: 3 | @go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest 4 | @go install golang.org/x/tools/cmd/goimports@latest 5 | @go install honnef.co/go/tools/cmd/staticcheck@latest 6 | @go install golang.org/x/lint/golint@latest 7 | @go install github.com/nishanths/exhaustive/...@latest 8 | @go install github.com/fzipp/gocyclo/cmd/gocyclo@latest 9 | 10 | .PHONY: test 11 | test: setup-test 12 | @go vet ./... 13 | ###@go vet -vettool=$(HOME)/go/bin/shadow ./... 14 | @$(HOME)/go/bin/goimports -d ./ 15 | @$(HOME)/go/bin/staticcheck ./... 16 | @$(HOME)/go/bin/golint -min_confidence 1 ./... 17 | @$(HOME)/go/bin/exhaustive -default-signifies-exhaustive ./... 18 | @$(HOME)/go/bin/gocyclo -over 19 $(shell find . -type f -name "*.go") 19 | @gofmt -s -l $(shell find . -type f -name "*.go") -------------------------------------------------------------------------------- /color.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "image/color" 10 | 11 | "github.com/mazznoer/csscolorparser" 12 | ) 13 | 14 | // ParseColorString parses hex color strings (i.e. `#RRGGBB`, `RRGGBBAA`, `#RRGGBBAA`), and named colors (e.g. 'black', 'blue', ...) 15 | func ParseColorString(s string) (color.Color, error) { 16 | col, err := csscolorparser.Parse(s) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | r, g, b, a := col.RGBA255() 22 | return color.RGBA{r, g, b, a}, nil 23 | } 24 | 25 | // Luminance computes the luminance (~ brightness) of the given color. Range: 0.0 for black to 1.0 for white. 26 | func Luminance(col color.Color) float64 { 27 | r, g, b, _ := col.RGBA() 28 | return (float64(r)*0.299 + float64(g)*0.587 + float64(b)*0.114) / float64(0xffff) 29 | } 30 | -------------------------------------------------------------------------------- /examples/idl/main.go: -------------------------------------------------------------------------------- 1 | // This is an example on how to use a multiline attribution string. 2 | 3 | package main 4 | 5 | import ( 6 | "image/color" 7 | 8 | sm "github.com/flopp/go-staticmaps" 9 | "github.com/fogleman/gg" 10 | "github.com/golang/geo/s2" 11 | ) 12 | 13 | func main() { 14 | ctx := sm.NewContext() 15 | ctx.SetSize(1920, 1080) 16 | 17 | newyork := sm.NewMarker(s2.LatLngFromDegrees(40.641766, -73.780968), color.RGBA{255, 0, 0, 255}, 16.0) 18 | hongkong := sm.NewMarker(s2.LatLngFromDegrees(22.308046, 113.918480), color.RGBA{0, 0, 255, 255}, 16.0) 19 | ctx.AddObject(newyork) 20 | ctx.AddObject(hongkong) 21 | path := make([]s2.LatLng, 0, 2) 22 | path = append(path, newyork.Position) 23 | path = append(path, hongkong.Position) 24 | ctx.AddObject(sm.NewPath(path, color.RGBA{0, 255, 0, 255}, 4.0)) 25 | 26 | img, err := ctx.Render() 27 | if err != nil { 28 | panic(err) 29 | } 30 | 31 | if err := gg.SavePNG("idl.png", img); err != nil { 32 | panic(err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /map_object.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "github.com/fogleman/gg" 10 | "github.com/golang/geo/s2" 11 | ) 12 | 13 | // MapObject is the interface for all objects on the map 14 | type MapObject interface { 15 | // Bounds returns the geographical boundary rect (excluding the actual pixel dimensions). 16 | Bounds() s2.Rect 17 | 18 | // ExtraMarginPixels returns the left, top, right, bottom pixel margin of the object. 19 | ExtraMarginPixels() (float64, float64, float64, float64) 20 | 21 | // Draw draws the object in the given graphical context. 22 | Draw(dc *gg.Context, trans *Transformer) 23 | } 24 | 25 | // CanDisplay checks if pos is generally displayable (i.e. its latitude is in [-85,85]) 26 | func CanDisplay(pos s2.LatLng) bool { 27 | const minLatitude float64 = -85.0 28 | const maxLatitude float64 = 85.0 29 | return (minLatitude <= pos.Lat.Degrees()) && (pos.Lat.Degrees() <= maxLatitude) 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Florian Pigorsch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tile_cache.go: -------------------------------------------------------------------------------- 1 | package sm 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // TileCache provides cache information to the tile fetcher 8 | type TileCache interface { 9 | // Root path to store cached tiles in with no trailing slash. 10 | Path() string 11 | // Permission to set when creating missing cache directories. 12 | Perm() os.FileMode 13 | } 14 | 15 | // TileCacheStaticPath provides a static path to the tile fetcher. 16 | type TileCacheStaticPath struct { 17 | path string 18 | perm os.FileMode 19 | } 20 | 21 | // Path to the cache. 22 | func (c *TileCacheStaticPath) Path() string { 23 | return c.path 24 | } 25 | 26 | // Perm instructs the permission to set when creating missing cache directories. 27 | func (c *TileCacheStaticPath) Perm() os.FileMode { 28 | return c.perm 29 | } 30 | 31 | // NewTileCache stores cache files in a static path. 32 | func NewTileCache(rootPath string, perm os.FileMode) *TileCacheStaticPath { 33 | return &TileCacheStaticPath{ 34 | path: rootPath, 35 | perm: perm, 36 | } 37 | } 38 | 39 | // NewTileCacheFromUserCache stores cache files in a user-specific cache directory. 40 | func NewTileCacheFromUserCache(perm os.FileMode) *TileCacheStaticPath { 41 | path, err := os.UserCacheDir() 42 | if err != nil { 43 | path += "/go-staticmaps" 44 | } 45 | return &TileCacheStaticPath{ 46 | path: path, 47 | perm: perm, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package sm 2 | 3 | import ( 4 | "image/color" 5 | "testing" 6 | 7 | "github.com/golang/geo/s2" 8 | ) 9 | 10 | func TestRenderEverything(t *testing.T) { 11 | width := 640 12 | height := 480 13 | 14 | ctx := NewContext() 15 | ctx.SetSize(width, height) 16 | ctx.SetTileProvider(NewTileProviderNone()) 17 | ctx.SetBackground(color.RGBA{255, 255, 255, 255}) 18 | 19 | coords1 := s2.LatLngFromDegrees(48.123, 7.0) 20 | coords2 := s2.LatLngFromDegrees(48.987, 8.0) 21 | coords3 := s2.LatLngFromDegrees(47.987, 7.5) 22 | coords4 := s2.LatLngFromDegrees(48.123, 9.0) 23 | 24 | p := make([]s2.LatLng, 0, 3) 25 | p = append(p, coords1) 26 | p = append(p, coords2) 27 | p = append(p, coords3) 28 | path := NewPath(p, color.RGBA{0, 0, 255, 255}, 4.0) 29 | 30 | a := make([]s2.LatLng, 0, 3) 31 | a = append(a, coords1) 32 | a = append(a, coords3) 33 | a = append(a, coords4) 34 | area := NewArea(a, color.RGBA{255, 0, 0, 255}, color.RGBA{255, 255, 0, 50}, 3.0) 35 | 36 | marker := NewMarker(coords1, color.RGBA{255, 0, 0, 255}, 16.0) 37 | circle := NewCircle(coords2, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 255, 0, 100}, 10000.0, 2.0) 38 | 39 | ctx.AddObject(area) 40 | ctx.AddObject(path) 41 | ctx.AddObject(marker) 42 | ctx.AddObject(circle) 43 | 44 | img, err := ctx.Render() 45 | if err != nil { 46 | t.Errorf("failed to render: %v", err) 47 | } 48 | 49 | if img.Bounds().Dx() != width || img.Bounds().Dy() != height { 50 | t.Errorf("unexpected image size: %d x %d; expected %d x %d", img.Bounds().Dx(), img.Bounds().Dy(), width, height) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /color_test.go: -------------------------------------------------------------------------------- 1 | package sm 2 | 3 | import ( 4 | "image/color" 5 | "testing" 6 | ) 7 | 8 | type string_color_err struct { 9 | input string 10 | expected_color color.Color 11 | expected_error bool 12 | } 13 | 14 | func TestParseColor(t *testing.T) { 15 | for _, test := range []string_color_err{ 16 | {"WHITE", color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}, false}, 17 | {"white", color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}, false}, 18 | {"yellow", color.RGBA{0xFF, 0xFF, 0x00, 0xFF}, false}, 19 | {"transparent", color.RGBA{0x00, 0x00, 0x00, 0x00}, false}, 20 | {"#FF00FF42", color.RGBA{0xFF, 0x00, 0xFF, 0x42}, false}, 21 | {"#ff00ff42", color.RGBA{0xFF, 0x00, 0xFF, 0x42}, false}, 22 | {"#ff00ff", color.RGBA{0xFF, 0x00, 0xFF, 0xFF}, false}, 23 | {"#f0f", color.RGBA{0xFF, 0x00, 0xFF, 0xFF}, false}, 24 | {"FF00FF42", color.RGBA{0xFF, 0x00, 0xFF, 0x42}, false}, 25 | {"ff00ff42", color.RGBA{0xFF, 0x00, 0xFF, 0x42}, false}, 26 | {"ff00ff", color.RGBA{0xFF, 0x00, 0xFF, 0xFF}, false}, 27 | {"f0f", color.RGBA{0xFF, 0x00, 0xFF, 0xFF}, false}, 28 | {"bad-name", color.RGBA{0x00, 0x00, 0x00, 0x00}, true}, 29 | {"#FF00F", color.RGBA{0x00, 0x00, 0x00, 0x00}, true}, 30 | {"#GGGGGG", color.RGBA{0x00, 0x00, 0x00, 0x00}, true}, 31 | {"", color.RGBA{0x00, 0x00, 0x00, 0x00}, true}, 32 | } { 33 | c, err := ParseColorString(test.input) 34 | if test.expected_error { 35 | if err == nil { 36 | t.Errorf("error expected when parsing '%s'", test.input) 37 | } 38 | } else { 39 | if err != nil { 40 | t.Errorf("unexpected error when parsing '%s': %v", test.input, err) 41 | } 42 | if c != test.expected_color { 43 | t.Errorf("unexpected color when parsing '%s': %v expected: %v", test.input, c, test.expected_color) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /bbox.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "fmt" 10 | "math" 11 | 12 | "github.com/golang/geo/s1" 13 | "github.com/golang/geo/s2" 14 | ) 15 | 16 | // CreateBBox creates a bounding box from a north-western point 17 | // (lat/lng in degrees) and a south-eastern point (lat/lng in degrees). 18 | // Note that you can create a bounding box wrapping over the antimeridian at 19 | // lng=+-/180° by nwlng > selng. 20 | func CreateBBox(nwlat float64, nwlng float64, selat float64, selng float64) (*s2.Rect, error) { 21 | if nwlat < -90 || nwlat > 90 { 22 | return nil, fmt.Errorf("out of range nwlat (%f) must be in [-90, 90]", nwlat) 23 | } 24 | if nwlng < -180 || nwlng > 180 { 25 | return nil, fmt.Errorf("out of range nwlng (%f) must be in [-180, 180]", nwlng) 26 | } 27 | 28 | if selat < -90 || selat > 90 { 29 | return nil, fmt.Errorf("out of range selat (%f) must be in [-90, 90]", selat) 30 | } 31 | if selng < -180 || selng > 180 { 32 | return nil, fmt.Errorf("out of range selng (%f) must be in [-180, 180]", selng) 33 | } 34 | 35 | if nwlat == selat { 36 | return nil, fmt.Errorf("nwlat and selat must not be equal") 37 | } 38 | if nwlng == selng { 39 | return nil, fmt.Errorf("nwlng and selng must not be equal") 40 | } 41 | 42 | bbox := new(s2.Rect) 43 | if selat < nwlat { 44 | bbox.Lat.Lo = selat * math.Pi / 180.0 45 | bbox.Lat.Hi = nwlat * math.Pi / 180.0 46 | } else { 47 | bbox.Lat.Lo = nwlat * math.Pi / 180.0 48 | bbox.Lat.Hi = selat * math.Pi / 180.0 49 | } 50 | bbox.Lng = s1.IntervalFromEndpoints(nwlng*math.Pi/180.0, selng*math.Pi/180.0) 51 | 52 | return bbox, nil 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: golang/static 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: build 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | go-version: [1.24.x] 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-go@v5 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - run: go mod download 16 | - run: go build -v . 17 | tests: 18 | name: tests 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: 1.24 25 | - run: go mod download 26 | - run: go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest 27 | - run: go install golang.org/x/tools/cmd/goimports@latest 28 | - run: go install honnef.co/go/tools/cmd/staticcheck@latest 29 | - run: go install golang.org/x/lint/golint@latest 30 | - run: go install github.com/nishanths/exhaustive/...@latest 31 | - run: go install github.com/fzipp/gocyclo/cmd/gocyclo@latest 32 | - name: vet 33 | run: go vet ./... 34 | - name: shadow 35 | run: go vet -vettool=$HOME/go/bin/shadow ./... 36 | - name: imports 37 | run: d="$($HOME/go/bin/goimports -d ./)" && if [ -n "$d" ]; then echo "goimports generated output:" ; echo "$d"; exit 1; fi 38 | - name: staticheck 39 | run: $HOME/go/bin/staticcheck ./... 40 | - name: lint 41 | run: set +e ; d="$($HOME/go/bin/golint -min_confidence 1 ./... | grep -v comment)" ; if [ -z "$d" ]; then exit 0 ; else echo "golint check output:" ; echo "$d" ; exit 1 ; fi ; set -e 42 | - name: exhaustive 43 | run: $HOME/go/bin/exhaustive -default-signifies-exhaustive ./... 44 | - name: cyclo 45 | run: $HOME/go/bin/gocyclo -over 19 $(find . -iname '*.go' -type f) 46 | - name: fmt 47 | run: test -z $(gofmt -s -l $(find . -iname '*.go' -type f)) 48 | - name: test 49 | run: go test ./... 50 | -------------------------------------------------------------------------------- /examples/stars/stars.go: -------------------------------------------------------------------------------- 1 | // This is an example on how to use custom object types (here: 5-pointed stars). 2 | 3 | package main 4 | 5 | import ( 6 | "image/color" 7 | "math" 8 | "math/rand" 9 | 10 | sm "github.com/flopp/go-staticmaps" 11 | "github.com/fogleman/gg" 12 | "github.com/golang/geo/s2" 13 | ) 14 | 15 | // Star represents a 5-pointed star on the map 16 | type Star struct { 17 | sm.MapObject 18 | Position s2.LatLng 19 | Size float64 20 | } 21 | 22 | // NewStar creates a new Star 23 | func NewStar(pos s2.LatLng, size float64) *Star { 24 | s := new(Star) 25 | s.Position = pos 26 | s.Size = size 27 | return s 28 | } 29 | 30 | // ExtraMarginPixels returns the left, top, right, bottom pixel margin of the Star object. 31 | func (s *Star) ExtraMarginPixels() (float64, float64, float64, float64) { 32 | return s.Size * 0.5, s.Size * 0.5, s.Size * 0.5, s.Size * 0.5 33 | } 34 | 35 | // Bounds returns the bounding rectangle of the Star object, which just contains the center point. 36 | func (s *Star) Bounds() s2.Rect { 37 | r := s2.EmptyRect() 38 | r = r.AddPoint(s.Position) 39 | return r 40 | } 41 | 42 | // Draw draws the object. 43 | func (s *Star) Draw(gc *gg.Context, trans *sm.Transformer) { 44 | if !sm.CanDisplay(s.Position) { 45 | return 46 | } 47 | 48 | x, y := trans.LatLngToXY(s.Position) 49 | gc.ClearPath() 50 | gc.SetLineWidth(1) 51 | gc.SetLineCap(gg.LineCapRound) 52 | gc.SetLineJoin(gg.LineJoinRound) 53 | for i := 0; i <= 10; i++ { 54 | a := float64(i) * 2 * math.Pi / 10.0 55 | if i%2 == 0 { 56 | gc.LineTo(x+s.Size*math.Cos(a), y+s.Size*math.Sin(a)) 57 | } else { 58 | gc.LineTo(x+s.Size*0.5*math.Cos(a), y+s.Size*0.5*math.Sin(a)) 59 | } 60 | } 61 | gc.SetColor(color.RGBA{0xff, 0xff, 0x00, 0xff}) 62 | gc.FillPreserve() 63 | gc.SetColor(color.RGBA{0xff, 0x00, 0x00, 0xff}) 64 | gc.Stroke() 65 | } 66 | 67 | func main() { 68 | ctx := sm.NewContext() 69 | ctx.SetSize(400, 300) 70 | 71 | for i := 0; i < 10; i++ { 72 | star := NewStar( 73 | s2.LatLngFromDegrees(40+rand.Float64()*10, rand.Float64()*10), 74 | 10+rand.Float64()*10, 75 | ) 76 | ctx.AddObject(star) 77 | } 78 | 79 | img, err := ctx.Render() 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | if err := gg.SavePNG("stars.png", img); err != nil { 85 | panic(err) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /area.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "image/color" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/flopp/go-coordsparser" 14 | "github.com/fogleman/gg" 15 | "github.com/golang/geo/s2" 16 | ) 17 | 18 | // Area represents a area or area on the map 19 | type Area struct { 20 | MapObject 21 | Positions []s2.LatLng 22 | Color color.Color 23 | Fill color.Color 24 | Weight float64 25 | } 26 | 27 | // NewArea creates a new Area 28 | func NewArea(positions []s2.LatLng, col color.Color, fill color.Color, weight float64) *Area { 29 | a := new(Area) 30 | a.Positions = positions 31 | a.Color = col 32 | a.Fill = fill 33 | a.Weight = weight 34 | 35 | return a 36 | } 37 | 38 | // ParseAreaString parses a string and returns an area 39 | func ParseAreaString(s string) (*Area, error) { 40 | area := new(Area) 41 | area.Color = color.RGBA{0xff, 0, 0, 0xff} 42 | area.Fill = color.Transparent 43 | area.Weight = 5.0 44 | 45 | for _, ss := range strings.Split(s, "|") { 46 | if ok, suffix := hasPrefix(ss, "color:"); ok { 47 | var err error 48 | area.Color, err = ParseColorString(suffix) 49 | if err != nil { 50 | return nil, err 51 | } 52 | } else if ok, suffix := hasPrefix(ss, "fill:"); ok { 53 | var err error 54 | area.Fill, err = ParseColorString(suffix) 55 | if err != nil { 56 | return nil, err 57 | } 58 | } else if ok, suffix := hasPrefix(ss, "weight:"); ok { 59 | var err error 60 | area.Weight, err = strconv.ParseFloat(suffix, 64) 61 | if err != nil { 62 | return nil, err 63 | } 64 | } else { 65 | lat, lng, err := coordsparser.Parse(ss) 66 | if err != nil { 67 | return nil, err 68 | } 69 | area.Positions = append(area.Positions, s2.LatLngFromDegrees(lat, lng)) 70 | } 71 | } 72 | return area, nil 73 | } 74 | 75 | // ExtraMarginPixels returns the left, top, right, bottom pixel margin of the Area object, which is exactly the line width. 76 | func (p *Area) ExtraMarginPixels() (float64, float64, float64, float64) { 77 | return p.Weight, p.Weight, p.Weight, p.Weight 78 | } 79 | 80 | // Bounds returns the geographical boundary rect (excluding the actual pixel dimensions). 81 | func (p *Area) Bounds() s2.Rect { 82 | r := s2.EmptyRect() 83 | for _, ll := range p.Positions { 84 | r = r.AddPoint(ll) 85 | } 86 | return r 87 | } 88 | 89 | // Draw draws the object in the given graphical context. 90 | func (p *Area) Draw(gc *gg.Context, trans *Transformer) { 91 | if len(p.Positions) <= 1 { 92 | return 93 | } 94 | 95 | gc.ClearPath() 96 | gc.SetLineWidth(p.Weight) 97 | gc.SetLineCap(gg.LineCapRound) 98 | gc.SetLineJoin(gg.LineJoinRound) 99 | for _, ll := range p.Positions { 100 | gc.LineTo(trans.LatLngToXY(ll)) 101 | } 102 | gc.ClosePath() 103 | gc.SetColor(p.Fill) 104 | gc.FillPreserve() 105 | gc.SetColor(p.Color) 106 | gc.Stroke() 107 | } 108 | -------------------------------------------------------------------------------- /examples/text-marker/text-marker.go: -------------------------------------------------------------------------------- 1 | // This is an example on how to create a custom text marker. 2 | 3 | package main 4 | 5 | import ( 6 | "image/color" 7 | "math" 8 | 9 | sm "github.com/flopp/go-staticmaps" 10 | "github.com/fogleman/gg" 11 | "github.com/golang/geo/s2" 12 | "golang.org/x/image/font" 13 | "golang.org/x/image/font/basicfont" 14 | ) 15 | 16 | // TextMarker is an MapObject that displays a text and has a pointy tip: 17 | // 18 | // +------------+ 19 | // | text label | 20 | // +----\ /----+ 21 | // \/ 22 | type TextMarker struct { 23 | sm.MapObject 24 | Position s2.LatLng 25 | Text string 26 | TextWidth float64 27 | TextHeight float64 28 | TipSize float64 29 | } 30 | 31 | // NewTextMarker creates a new TextMarker 32 | func NewTextMarker(pos s2.LatLng, text string) *TextMarker { 33 | s := new(TextMarker) 34 | s.Position = pos 35 | s.Text = text 36 | s.TipSize = 8.0 37 | 38 | d := &font.Drawer{ 39 | Face: basicfont.Face7x13, 40 | } 41 | s.TextWidth = float64(d.MeasureString(s.Text) >> 6) 42 | s.TextHeight = 13.0 43 | return s 44 | } 45 | 46 | // ExtraMarginPixels returns the left, top, right, bottom pixel margin of the TextMarker object. 47 | func (s *TextMarker) ExtraMarginPixels() (float64, float64, float64, float64) { 48 | w := math.Max(4.0+s.TextWidth, 2*s.TipSize) 49 | h := s.TipSize + s.TextHeight + 4.0 50 | return w * 0.5, h, w * 0.5, 0.0 51 | } 52 | 53 | // Bounds returns the bounding rectangle of the TextMarker object, which is just the tip position. 54 | func (s *TextMarker) Bounds() s2.Rect { 55 | r := s2.EmptyRect() 56 | r = r.AddPoint(s.Position) 57 | return r 58 | } 59 | 60 | // Draw draws the object. 61 | func (s *TextMarker) Draw(gc *gg.Context, trans *sm.Transformer) { 62 | if !sm.CanDisplay(s.Position) { 63 | return 64 | } 65 | 66 | w := math.Max(4.0+s.TextWidth, 2*s.TipSize) 67 | h := s.TextHeight + 4.0 68 | x, y := trans.LatLngToXY(s.Position) 69 | gc.ClearPath() 70 | gc.SetLineWidth(1) 71 | gc.SetLineCap(gg.LineCapRound) 72 | gc.SetLineJoin(gg.LineJoinRound) 73 | gc.LineTo(x, y) 74 | gc.LineTo(x-s.TipSize, y-s.TipSize) 75 | gc.LineTo(x-w*0.5, y-s.TipSize) 76 | gc.LineTo(x-w*0.5, y-s.TipSize-h) 77 | gc.LineTo(x+w*0.5, y-s.TipSize-h) 78 | gc.LineTo(x+w*0.5, y-s.TipSize) 79 | gc.LineTo(x+s.TipSize, y-s.TipSize) 80 | gc.LineTo(x, y) 81 | gc.SetColor(color.RGBA{0xff, 0xff, 0xff, 0xff}) 82 | gc.FillPreserve() 83 | gc.SetColor(color.RGBA{0x00, 0x00, 0x00, 0xff}) 84 | gc.Stroke() 85 | 86 | gc.SetRGBA(0.0, 0.0, 0.0, 1.0) 87 | gc.DrawString(s.Text, x-s.TextWidth*0.5, y-s.TipSize-4.0) 88 | } 89 | 90 | func main() { 91 | ctx := sm.NewContext() 92 | ctx.SetSize(400, 300) 93 | 94 | berlin := NewTextMarker(s2.LatLngFromDegrees(52.517037, 13.388860), "Berlin") 95 | london := NewTextMarker(s2.LatLngFromDegrees(51.507322, 0.127647), "London") 96 | paris := NewTextMarker(s2.LatLngFromDegrees(48.856697, 2.351462), "Paris") 97 | ctx.AddObject(berlin) 98 | ctx.AddObject(london) 99 | ctx.AddObject(paris) 100 | 101 | img, err := ctx.Render() 102 | if err != nil { 103 | panic(err) 104 | } 105 | 106 | if err := gg.SavePNG("text-markers.png", img); err != nil { 107 | panic(err) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /path.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "image/color" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/flopp/go-coordsparser" 14 | "github.com/fogleman/gg" 15 | "github.com/golang/geo/s2" 16 | "github.com/tkrajina/gpxgo/gpx" 17 | ) 18 | 19 | // Path represents a path or area on the map 20 | type Path struct { 21 | MapObject 22 | Positions []s2.LatLng 23 | Color color.Color 24 | Weight float64 25 | } 26 | 27 | // NewPath creates a new Path 28 | func NewPath(positions []s2.LatLng, col color.Color, weight float64) *Path { 29 | p := new(Path) 30 | p.Positions = positions 31 | p.Color = col 32 | p.Weight = weight 33 | 34 | return p 35 | } 36 | 37 | // ParsePathString parses a string and returns a path 38 | func ParsePathString(s string) ([]*Path, error) { 39 | paths := make([]*Path, 0) 40 | currentPath := new(Path) 41 | currentPath.Color = color.RGBA{0xff, 0, 0, 0xff} 42 | currentPath.Weight = 5.0 43 | 44 | for _, ss := range strings.Split(s, "|") { 45 | if ok, suffix := hasPrefix(ss, "color:"); ok { 46 | var err error 47 | if currentPath.Color, err = ParseColorString(suffix); err != nil { 48 | return nil, err 49 | } 50 | } else if ok, suffix := hasPrefix(ss, "weight:"); ok { 51 | var err error 52 | if currentPath.Weight, err = strconv.ParseFloat(suffix, 64); err != nil { 53 | return nil, err 54 | } 55 | } else if ok, suffix := hasPrefix(ss, "gpx:"); ok { 56 | gpxData, err := gpx.ParseFile(suffix) 57 | if err != nil { 58 | return nil, err 59 | } 60 | for _, trk := range gpxData.Tracks { 61 | for _, seg := range trk.Segments { 62 | p := new(Path) 63 | p.Color = currentPath.Color 64 | p.Weight = currentPath.Weight 65 | for _, pt := range seg.Points { 66 | p.Positions = append(p.Positions, s2.LatLngFromDegrees(pt.GetLatitude(), pt.GetLongitude())) 67 | } 68 | if len(p.Positions) > 0 { 69 | paths = append(paths, p) 70 | } 71 | } 72 | } 73 | } else { 74 | lat, lng, err := coordsparser.Parse(ss) 75 | if err != nil { 76 | return nil, err 77 | } 78 | currentPath.Positions = append(currentPath.Positions, s2.LatLngFromDegrees(lat, lng)) 79 | } 80 | } 81 | if len(currentPath.Positions) > 0 { 82 | paths = append(paths, currentPath) 83 | } 84 | return paths, nil 85 | } 86 | 87 | // ExtraMarginPixels returns the left, top, right, bottom pixel margin of the Path object, which is exactly the line width. 88 | func (p *Path) ExtraMarginPixels() (float64, float64, float64, float64) { 89 | return p.Weight, p.Weight, p.Weight, p.Weight 90 | } 91 | 92 | // Bounds returns the geographical boundary rect (excluding the actual pixel dimensions). 93 | func (p *Path) Bounds() s2.Rect { 94 | r := s2.EmptyRect() 95 | for _, ll := range p.Positions { 96 | r = r.AddPoint(ll) 97 | } 98 | return r 99 | } 100 | 101 | // Draw draws the object in the given graphical context. 102 | func (p *Path) Draw(gc *gg.Context, trans *Transformer) { 103 | if len(p.Positions) <= 1 { 104 | return 105 | } 106 | 107 | gc.ClearPath() 108 | gc.SetLineWidth(p.Weight) 109 | gc.SetLineCap(gg.LineCapRound) 110 | gc.SetLineJoin(gg.LineJoinRound) 111 | for _, ll := range p.Positions { 112 | gc.LineTo(trans.LatLngToXY(ll)) 113 | } 114 | gc.SetColor(p.Color) 115 | gc.Stroke() 116 | } 117 | -------------------------------------------------------------------------------- /image_marker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "fmt" 10 | "image" 11 | _ "image/jpeg" // to be able to decode jpegs 12 | _ "image/png" // to be able to decode pngs 13 | "log" 14 | "os" 15 | "strconv" 16 | "strings" 17 | 18 | "github.com/flopp/go-coordsparser" 19 | "github.com/fogleman/gg" 20 | "github.com/golang/geo/s2" 21 | ) 22 | 23 | // ImageMarker represents an image marker on the map 24 | type ImageMarker struct { 25 | MapObject 26 | Position s2.LatLng 27 | Img image.Image 28 | OffsetX float64 29 | OffsetY float64 30 | } 31 | 32 | // NewImageMarker creates a new ImageMarker 33 | func NewImageMarker(pos s2.LatLng, img image.Image, offsetX, offsetY float64) *ImageMarker { 34 | m := new(ImageMarker) 35 | m.Position = pos 36 | m.Img = img 37 | m.OffsetX = offsetX 38 | m.OffsetY = offsetY 39 | 40 | return m 41 | } 42 | 43 | // ParseImageMarkerString parses a string and returns an array of image markers 44 | func ParseImageMarkerString(s string) ([]*ImageMarker, error) { 45 | markers := make([]*ImageMarker, 0) 46 | 47 | var img image.Image = nil 48 | offsetX := 0.0 49 | offsetY := 0.0 50 | 51 | for _, ss := range strings.Split(s, "|") { 52 | if ok, suffix := hasPrefix(ss, "image:"); ok { 53 | file, err := os.Open(suffix) 54 | if err != nil { 55 | return nil, err 56 | } 57 | defer file.Close() 58 | 59 | img, _, err = image.Decode(file) 60 | if err != nil { 61 | return nil, err 62 | } 63 | } else if ok, suffix := hasPrefix(ss, "offsetx:"); ok { 64 | var err error 65 | offsetX, err = strconv.ParseFloat(suffix, 64) 66 | if err != nil { 67 | return nil, err 68 | } 69 | } else if ok, suffix := hasPrefix(ss, "offsety:"); ok { 70 | var err error 71 | offsetY, err = strconv.ParseFloat(suffix, 64) 72 | if err != nil { 73 | return nil, err 74 | } 75 | } else { 76 | lat, lng, err := coordsparser.Parse(ss) 77 | if err != nil { 78 | return nil, err 79 | } 80 | if img == nil { 81 | return nil, fmt.Errorf("cannot create an ImageMarker without an image: %s", s) 82 | } 83 | m := NewImageMarker(s2.LatLngFromDegrees(lat, lng), img, offsetX, offsetY) 84 | markers = append(markers, m) 85 | } 86 | } 87 | return markers, nil 88 | } 89 | 90 | // SetImage sets the marker's image 91 | func (m *ImageMarker) SetImage(img image.Image) { 92 | m.Img = img 93 | } 94 | 95 | // SetOffsetX sets the marker's x offset 96 | func (m *ImageMarker) SetOffsetX(offset float64) { 97 | m.OffsetX = offset 98 | } 99 | 100 | // SetOffsetY sets the marker's y offset 101 | func (m *ImageMarker) SetOffsetY(offset float64) { 102 | m.OffsetY = offset 103 | } 104 | 105 | // ExtraMarginPixels return the marker's left, top, right, bottom pixel extent. 106 | func (m *ImageMarker) ExtraMarginPixels() (float64, float64, float64, float64) { 107 | size := m.Img.Bounds().Size() 108 | return m.OffsetX, m.OffsetY, float64(size.X) - m.OffsetX, float64(size.Y) - m.OffsetY 109 | } 110 | 111 | // Bounds returns single point rect containing the marker's geographical position. 112 | func (m *ImageMarker) Bounds() s2.Rect { 113 | r := s2.EmptyRect() 114 | r = r.AddPoint(m.Position) 115 | return r 116 | } 117 | 118 | // Draw draws the object in the given graphical context. 119 | func (m *ImageMarker) Draw(gc *gg.Context, trans *Transformer) { 120 | if !CanDisplay(m.Position) { 121 | log.Printf("ImageMarker coordinates not displayable: %f/%f", m.Position.Lat.Degrees(), m.Position.Lng.Degrees()) 122 | return 123 | } 124 | 125 | x, y := trans.LatLngToXY(m.Position) 126 | gc.DrawImage(m.Img, int(x-m.OffsetX), int(y-m.OffsetY)) 127 | } 128 | -------------------------------------------------------------------------------- /circle.go: -------------------------------------------------------------------------------- 1 | package sm 2 | 3 | import ( 4 | "image/color" 5 | "log" 6 | "math" 7 | "strings" 8 | 9 | "strconv" 10 | 11 | "github.com/flopp/go-coordsparser" 12 | "github.com/fogleman/gg" 13 | "github.com/golang/geo/s1" 14 | "github.com/golang/geo/s2" 15 | ) 16 | 17 | // Circle represents a circle on the map 18 | type Circle struct { 19 | MapObject 20 | Position s2.LatLng 21 | Color color.Color 22 | Fill color.Color 23 | Weight float64 24 | Radius float64 // in m. 25 | } 26 | 27 | // NewCircle creates a new circle 28 | func NewCircle(pos s2.LatLng, col, fill color.Color, radius, weight float64) *Circle { 29 | return &Circle{ 30 | Position: pos, 31 | Color: col, 32 | Fill: fill, 33 | Weight: weight, 34 | Radius: radius, 35 | } 36 | } 37 | 38 | // ParseCircleString parses a string and returns an array of circles 39 | func ParseCircleString(s string) (circles []*Circle, err error) { 40 | circles = make([]*Circle, 0) 41 | 42 | var col color.Color = color.RGBA{0xff, 0, 0, 0xff} 43 | var fill color.Color = color.Transparent 44 | radius := 100.0 45 | weight := 5.0 46 | 47 | for _, ss := range strings.Split(s, "|") { 48 | if ok, suffix := hasPrefix(ss, "color:"); ok { 49 | col, err = ParseColorString(suffix) 50 | if err != nil { 51 | return nil, err 52 | } 53 | } else if ok, suffix := hasPrefix(ss, "fill:"); ok { 54 | fill, err = ParseColorString(suffix) 55 | if err != nil { 56 | return nil, err 57 | } 58 | } else if ok, suffix := hasPrefix(ss, "radius:"); ok { 59 | if radius, err = strconv.ParseFloat(suffix, 64); err != nil { 60 | return nil, err 61 | } 62 | } else if ok, suffix := hasPrefix(ss, "weight:"); ok { 63 | if weight, err = strconv.ParseFloat(suffix, 64); err != nil { 64 | return nil, err 65 | } 66 | } else { 67 | lat, lng, err := coordsparser.Parse(ss) 68 | if err != nil { 69 | return nil, err 70 | } 71 | c := NewCircle(s2.LatLngFromDegrees(lat, lng), col, fill, radius, weight) 72 | circles = append(circles, c) 73 | } 74 | } 75 | return circles, nil 76 | } 77 | 78 | func (m *Circle) getLatLng(plus bool) s2.LatLng { 79 | const ( 80 | R = 6371000.0 81 | ) 82 | th := m.Radius / R 83 | br := 0 / float64(s1.Degree) 84 | if !plus { 85 | th *= -1 86 | } 87 | lat := m.Position.Lat.Radians() 88 | lat1 := math.Asin(math.Sin(lat)*math.Cos(th) + math.Cos(lat)*math.Sin(th)*math.Cos(br)) 89 | lng1 := m.Position.Lng.Radians() + 90 | math.Atan2(math.Sin(br)*math.Sin(th)*math.Cos(lat), 91 | math.Cos(th)-math.Sin(lat)*math.Sin(lat1)) 92 | return s2.LatLng{ 93 | Lat: s1.Angle(lat1), 94 | Lng: s1.Angle(lng1), 95 | } 96 | } 97 | 98 | // ExtraMarginPixels returns the left, top, right, bottom pixel margin of the Circle object, which is exactly the line width. 99 | func (m *Circle) ExtraMarginPixels() (float64, float64, float64, float64) { 100 | return m.Weight, m.Weight, m.Weight, m.Weight 101 | } 102 | 103 | // Bounds returns the geographical boundary rect (excluding the actual pixel dimensions). 104 | func (m *Circle) Bounds() s2.Rect { 105 | r := s2.EmptyRect() 106 | r = r.AddPoint(m.getLatLng(false)) 107 | r = r.AddPoint(m.getLatLng(true)) 108 | return r 109 | } 110 | 111 | // Draw draws the object in the given graphical context. 112 | func (m *Circle) Draw(gc *gg.Context, trans *Transformer) { 113 | if !CanDisplay(m.Position) { 114 | log.Printf("Circle coordinates not displayable: %f/%f", m.Position.Lat.Degrees(), m.Position.Lng.Degrees()) 115 | return 116 | } 117 | 118 | ll := m.getLatLng(true) 119 | x, y := trans.LatLngToXY(m.Position) 120 | x1, y1 := trans.LatLngToXY(ll) 121 | radius := math.Sqrt(math.Pow(x1-x, 2) + math.Pow(y1-y, 2)) 122 | gc.ClearPath() 123 | gc.SetLineWidth(m.Weight) 124 | gc.SetLineCap(gg.LineCapRound) 125 | gc.SetLineJoin(gg.LineJoinRound) 126 | gc.DrawCircle(x, y, radius) 127 | gc.SetColor(m.Fill) 128 | gc.FillPreserve() 129 | gc.SetColor(m.Color) 130 | gc.Stroke() 131 | } 132 | -------------------------------------------------------------------------------- /tile_fetcher.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "bytes" 10 | "errors" 11 | "fmt" 12 | "image" 13 | _ "image/jpeg" // to be able to decode jpegs 14 | _ "image/png" // to be able to decode pngs 15 | "io" 16 | "log" 17 | "net/http" 18 | "os" 19 | "path" 20 | "path/filepath" 21 | "strconv" 22 | ) 23 | 24 | var errTileNotFound = errors.New("error 404: tile not found") 25 | 26 | // TileFetcher downloads map tile images from a TileProvider 27 | type TileFetcher struct { 28 | tileProvider *TileProvider 29 | cache TileCache 30 | userAgent string 31 | online bool 32 | } 33 | 34 | // Tile defines a single map tile 35 | type Tile struct { 36 | Img image.Image 37 | X, Y, Zoom int 38 | } 39 | 40 | // NewTileFetcher creates a new Tilefetcher struct 41 | func NewTileFetcher(tileProvider *TileProvider, cache TileCache, online bool) *TileFetcher { 42 | t := new(TileFetcher) 43 | t.tileProvider = tileProvider 44 | t.cache = cache 45 | t.userAgent = "Mozilla/5.0+(compatible; go-staticmaps/0.1; https://github.com/flopp/go-staticmaps)" 46 | t.online = online 47 | return t 48 | } 49 | 50 | // SetUserAgent sets the HTTP user agent string used when downloading map tiles 51 | func (t *TileFetcher) SetUserAgent(a string) { 52 | t.userAgent = a 53 | } 54 | 55 | func (t *TileFetcher) url(zoom, x, y int) string { 56 | shard := "" 57 | ss := len(t.tileProvider.Shards) 58 | if len(t.tileProvider.Shards) > 0 { 59 | shard = t.tileProvider.Shards[(x+y)%ss] 60 | } 61 | return t.tileProvider.getURL(shard, zoom, x, y, t.tileProvider.APIKey) 62 | } 63 | 64 | func cacheFileName(cache TileCache, providerName string, zoom, x, y int) string { 65 | return path.Join( 66 | cache.Path(), 67 | providerName, 68 | strconv.Itoa(zoom), 69 | strconv.Itoa(x), 70 | strconv.Itoa(y), 71 | ) 72 | } 73 | 74 | // Fetch download (or retrieves from the cache) a tile image for the specified zoom level and tile coordinates 75 | func (t *TileFetcher) Fetch(tile *Tile) error { 76 | if t.cache != nil { 77 | fileName := cacheFileName(t.cache, t.tileProvider.Name, tile.Zoom, tile.X, tile.Y) 78 | cachedImg, err := t.loadCache(fileName) 79 | if err == nil { 80 | tile.Img = cachedImg 81 | return nil 82 | } 83 | } 84 | 85 | if !t.online { 86 | return errTileNotFound 87 | } 88 | 89 | url := t.url(tile.Zoom, tile.X, tile.Y) 90 | data, err := t.download(url) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | img, _, err := image.Decode(bytes.NewBuffer(data)) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | if t.cache != nil { 101 | fileName := cacheFileName(t.cache, t.tileProvider.Name, tile.Zoom, tile.X, tile.Y) 102 | if err := t.storeCache(fileName, data); err != nil { 103 | log.Printf("Failed to store map tile as '%s': %s", fileName, err) 104 | } 105 | } 106 | 107 | tile.Img = img 108 | return nil 109 | } 110 | 111 | func (t *TileFetcher) download(url string) ([]byte, error) { 112 | req, _ := http.NewRequest("GET", url, nil) 113 | req.Header.Set("User-Agent", t.userAgent) 114 | 115 | resp, err := http.DefaultClient.Do(req) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | switch resp.StatusCode { 121 | case http.StatusOK: 122 | // Great! Nothing to do. 123 | 124 | case http.StatusNotFound: 125 | return nil, errTileNotFound 126 | 127 | default: 128 | return nil, fmt.Errorf("GET %s: %s", url, resp.Status) 129 | } 130 | 131 | defer resp.Body.Close() 132 | 133 | contents, err := io.ReadAll(resp.Body) 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | return contents, nil 139 | } 140 | 141 | func (t *TileFetcher) loadCache(fileName string) (image.Image, error) { 142 | file, err := os.Open(fileName) 143 | if err != nil { 144 | return nil, err 145 | } 146 | defer file.Close() 147 | 148 | img, _, err := image.Decode(file) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | return img, nil 154 | } 155 | 156 | func (t *TileFetcher) createCacheDir(path string) error { 157 | src, err := os.Stat(path) 158 | if err != nil { 159 | if os.IsNotExist(err) { 160 | return os.MkdirAll(path, t.cache.Perm()) 161 | } 162 | return err 163 | } 164 | if src.IsDir() { 165 | return nil 166 | } 167 | 168 | return fmt.Errorf("file exists but is not a directory: %s", path) 169 | } 170 | 171 | func (t *TileFetcher) storeCache(fileName string, data []byte) error { 172 | dir, _ := filepath.Split(fileName) 173 | 174 | if err := t.createCacheDir(dir); err != nil { 175 | return err 176 | } 177 | 178 | // Create file using the configured directory create permission with the 179 | // 'x' bit removed. 180 | file, err := os.OpenFile( 181 | fileName, 182 | os.O_RDWR|os.O_CREATE|os.O_TRUNC, 183 | t.cache.Perm()&0666, 184 | ) 185 | if err != nil { 186 | return err 187 | } 188 | defer file.Close() 189 | 190 | if _, err = io.Copy(file, bytes.NewBuffer(data)); err != nil { 191 | return err 192 | } 193 | 194 | return nil 195 | } 196 | -------------------------------------------------------------------------------- /marker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import ( 9 | "fmt" 10 | "image/color" 11 | "log" 12 | "math" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/flopp/go-coordsparser" 17 | "github.com/fogleman/gg" 18 | "github.com/golang/geo/s2" 19 | ) 20 | 21 | // Marker represents a marker on the map 22 | type Marker struct { 23 | MapObject 24 | Position s2.LatLng 25 | Color color.Color 26 | Size float64 27 | Label string 28 | LabelColor color.Color 29 | LabelXOffset float64 30 | LabelYOffset float64 31 | } 32 | 33 | // NewMarker creates a new Marker 34 | func NewMarker(pos s2.LatLng, col color.Color, size float64) *Marker { 35 | m := new(Marker) 36 | m.Position = pos 37 | m.Color = col 38 | m.Size = size 39 | m.Label = "" 40 | if Luminance(m.Color) >= 0.5 { 41 | m.LabelColor = color.RGBA{0x00, 0x00, 0x00, 0xff} 42 | } else { 43 | m.LabelColor = color.RGBA{0xff, 0xff, 0xff, 0xff} 44 | } 45 | m.LabelXOffset = 0.5 46 | m.LabelYOffset = 0.5 47 | 48 | return m 49 | } 50 | 51 | func parseSizeString(s string) (float64, error) { 52 | switch { 53 | case s == "mid": 54 | return 16.0, nil 55 | case s == "small": 56 | return 12.0, nil 57 | case s == "tiny": 58 | return 8.0, nil 59 | } 60 | 61 | if floatValue, err := strconv.ParseFloat(s, 64); err == nil && floatValue > 0 { 62 | return floatValue, nil 63 | } 64 | 65 | return 0.0, fmt.Errorf("cannot parse size string: '%s'", s) 66 | } 67 | 68 | func parseLabelOffset(s string) (float64, error) { 69 | 70 | // todo: add a way to specify offset with up, down, right, left 71 | 72 | if floatValue, err := strconv.ParseFloat(s, 64); err == nil && floatValue > 0 { 73 | return floatValue, nil 74 | } 75 | 76 | return 0.5, fmt.Errorf("cannot parse label offset: '%s'", s) 77 | } 78 | 79 | // ParseMarkerString parses a string and returns an array of markers 80 | func ParseMarkerString(s string) ([]*Marker, error) { 81 | markers := make([]*Marker, 0) 82 | 83 | var markerColor color.Color = color.RGBA{0xff, 0, 0, 0xff} 84 | size := 16.0 85 | label := "" 86 | labelXOffset := 0.5 87 | labelYOffset := 0.5 88 | var labelColor color.Color 89 | 90 | for _, ss := range strings.Split(s, "|") { 91 | if ok, suffix := hasPrefix(ss, "color:"); ok { 92 | var err error 93 | markerColor, err = ParseColorString(suffix) 94 | if err != nil { 95 | return nil, err 96 | } 97 | } else if ok, suffix := hasPrefix(ss, "label:"); ok { 98 | label = suffix 99 | } else if ok, suffix := hasPrefix(ss, "size:"); ok { 100 | var err error 101 | size, err = parseSizeString(suffix) 102 | if err != nil { 103 | return nil, err 104 | } 105 | } else if ok, suffix := hasPrefix(ss, "labelcolor:"); ok { 106 | var err error 107 | labelColor, err = ParseColorString(suffix) 108 | if err != nil { 109 | return nil, err 110 | } 111 | } else if ok, suffix := hasPrefix(ss, "labelxoffset:"); ok { 112 | var err error 113 | labelXOffset, err = parseLabelOffset(suffix) 114 | if err != nil { 115 | return nil, err 116 | } 117 | } else if ok, suffix := hasPrefix(ss, "labelyoffset:"); ok { 118 | var err error 119 | labelYOffset, err = parseLabelOffset(suffix) 120 | if err != nil { 121 | return nil, err 122 | } 123 | } else { 124 | lat, lng, err := coordsparser.Parse(ss) 125 | if err != nil { 126 | return nil, err 127 | } 128 | m := NewMarker(s2.LatLngFromDegrees(lat, lng), markerColor, size) 129 | m.Label = label 130 | if labelColor != nil { 131 | m.SetLabelColor(labelColor) 132 | } 133 | m.LabelXOffset = labelXOffset 134 | m.LabelYOffset = labelYOffset 135 | markers = append(markers, m) 136 | } 137 | } 138 | return markers, nil 139 | } 140 | 141 | // SetLabelColor sets the color of the marker's text label 142 | func (m *Marker) SetLabelColor(col color.Color) { 143 | m.LabelColor = col 144 | } 145 | 146 | // ExtraMarginPixels return the marker's left, top, right, bottom pixel extent. 147 | func (m *Marker) ExtraMarginPixels() (float64, float64, float64, float64) { 148 | return 0.5*m.Size + 1.0, 1.5*m.Size + 1.0, 0.5*m.Size + 1.0, 1.0 149 | } 150 | 151 | // Bounds returns single point rect containing the marker's geographical position. 152 | func (m *Marker) Bounds() s2.Rect { 153 | r := s2.EmptyRect() 154 | r = r.AddPoint(m.Position) 155 | return r 156 | } 157 | 158 | // Draw draws the object in the given graphical context. 159 | func (m *Marker) Draw(gc *gg.Context, trans *Transformer) { 160 | if !CanDisplay(m.Position) { 161 | log.Printf("Marker coordinates not displayable: %f/%f", m.Position.Lat.Degrees(), m.Position.Lng.Degrees()) 162 | return 163 | } 164 | 165 | gc.ClearPath() 166 | gc.SetLineJoin(gg.LineJoinRound) 167 | gc.SetLineWidth(1.0) 168 | 169 | radius := 0.5 * m.Size 170 | x, y := trans.LatLngToXY(m.Position) 171 | gc.DrawArc(x, y-m.Size, radius, (90.0+60.0)*math.Pi/180.0, (360.0+90.0-60.0)*math.Pi/180.0) 172 | gc.LineTo(x, y) 173 | gc.ClosePath() 174 | gc.SetColor(m.Color) 175 | gc.FillPreserve() 176 | gc.SetRGB(0, 0, 0) 177 | gc.Stroke() 178 | 179 | if m.Label != "" { 180 | gc.SetColor(m.LabelColor) 181 | gc.DrawStringAnchored(m.Label, x, y-m.Size, m.LabelXOffset, m.LabelYOffset) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/flopp/go-coordsparser v0.0.0-20240403152942-4891dc40d0a7 h1:ZYEbOgGPFGLZwkLxRjV9zxZAIR1lExrNfFnObG/peek= 4 | github.com/flopp/go-coordsparser v0.0.0-20240403152942-4891dc40d0a7/go.mod h1:7y/2PxXfR1mGtIQFNtFE1daHIka2e8J480Bsm+MiCpk= 5 | github.com/flopp/go-coordsparser v0.0.0-20250311184423-61a7ff62d17c h1:HNRXT/BVRhDaHuFjFQ81mHd+DAmkRJXIELEL05LCDpk= 6 | github.com/flopp/go-coordsparser v0.0.0-20250311184423-61a7ff62d17c/go.mod h1:7y/2PxXfR1mGtIQFNtFE1daHIka2e8J480Bsm+MiCpk= 7 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= 8 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 9 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 10 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 11 | github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I= 12 | github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U= 13 | github.com/golang/geo v0.0.0-20250317153850-29de3e103b13 h1:e2S7U1amR2jQJiyI1QkSPSRmieYNj6msVKDJpg+rRKg= 14 | github.com/golang/geo v0.0.0-20250317153850-29de3e103b13/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= 15 | github.com/golang/geo v0.0.0-20250319031623-61885490c193 h1:gL4cAiIU/8f9doSTUFkZ8ejX1czxaCSKF18E1ELTRxY= 16 | github.com/golang/geo v0.0.0-20250319031623-61885490c193/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= 17 | github.com/golang/geo v0.0.0-20250324010448-bc23e40121c4 h1:OrFwCvn/b4/QGIM7f7kRQALkfti98unGN+eC8Ecz+uQ= 18 | github.com/golang/geo v0.0.0-20250324010448-bc23e40121c4/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= 19 | github.com/golang/geo v0.0.0-20250328065203-0b6e08c212fb h1:eqdj1jSZjgmPdSl2lr3rAwJykSe9jHxPN1zLuasKVh0= 20 | github.com/golang/geo v0.0.0-20250328065203-0b6e08c212fb/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= 21 | github.com/golang/geo v0.0.0-20250403143024-b4895f722f25 h1:Xh/kky7r78vEKmbqIP4ickCNs0ukkuEQBvu3tU/RHKY= 22 | github.com/golang/geo v0.0.0-20250403143024-b4895f722f25/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= 23 | github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 h1:8COTSTFIIXnaD81+kfCw4dRANNAKuCp06EdYLqwX30g= 24 | github.com/golang/geo v0.0.0-20250404181303-07d601f131f3/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= 25 | github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7 h1:HNykSFq2QowNxC/zZc1IEbRuj30sMiY4aCSLb4EK/zA= 26 | github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA= 27 | github.com/golang/geo v0.0.0-20250417192230-a483f6ae7110 h1:buKTa/TfZH6Kf/HL22L9NpBpzglHL1aspZjRCuoS6kw= 28 | github.com/golang/geo v0.0.0-20250417192230-a483f6ae7110/go.mod h1:DaoiJOlOKPwoy8qrxeMEsEFBPB1P+vCktZlA+qARoWg= 29 | github.com/golang/geo v0.0.0-20250613135800-9e8e59d779cc h1:HeTandKJybEWXv6wW9o3ybXTNqGGV4Q6kW3S+8RsXbk= 30 | github.com/golang/geo v0.0.0-20250613135800-9e8e59d779cc/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA= 31 | github.com/golang/geo v0.0.0-20250627182359-f4b81656db99 h1:JBrPhKsd24GeJJjRtchOCfCmMHumV0H3/RJRvwxlYYk= 32 | github.com/golang/geo v0.0.0-20250627182359-f4b81656db99/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA= 33 | github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= 34 | github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= 35 | github.com/mazznoer/csscolorparser v0.1.5 h1:Wr4uNIE+pHWN3TqZn2SGpA2nLRG064gB7WdSfSS5cz4= 36 | github.com/mazznoer/csscolorparser v0.1.5/go.mod h1:OQRVvgCyHDCAquR1YWfSwwaDcM0LhnSffGnlbOew/3I= 37 | github.com/mazznoer/csscolorparser v0.1.6 h1:uK6p5zBA8HaQZJSInHgHVmkVBodUAy+6snSmKJG7pqA= 38 | github.com/mazznoer/csscolorparser v0.1.6/go.mod h1:OQRVvgCyHDCAquR1YWfSwwaDcM0LhnSffGnlbOew/3I= 39 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 43 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 | github.com/tkrajina/gpxgo v1.4.0 h1:cSD5uSwy3VZuNFieTEZLyRnuIwhonQEkGPkPGW4XNag= 45 | github.com/tkrajina/gpxgo v1.4.0/go.mod h1:BXSMfUAvKiEhMEXAFM2NvNsbjsSvp394mOvdcNjettg= 46 | golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= 47 | golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 48 | golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= 49 | golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= 50 | golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= 51 | golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= 52 | golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= 53 | golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= 54 | golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= 55 | golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= 56 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 57 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 58 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 59 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 60 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 61 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 62 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 64 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 65 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 66 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 67 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 68 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 69 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 70 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 71 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 72 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 73 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 74 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 75 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 78 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 79 | -------------------------------------------------------------------------------- /create-static-map/create-static-map.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "log" 11 | "os" 12 | "sort" 13 | "strings" 14 | 15 | "github.com/flopp/go-coordsparser" 16 | sm "github.com/flopp/go-staticmaps" 17 | "github.com/fogleman/gg" 18 | "github.com/golang/geo/s2" 19 | "github.com/jessevdk/go-flags" 20 | ) 21 | 22 | func handleTypeOption(ctx *sm.Context, parameter string, thunderforestAPIKey string) { 23 | tileProviders := sm.GetTileProviders(thunderforestAPIKey) 24 | tp := tileProviders[parameter] 25 | if tp != nil { 26 | ctx.SetTileProvider(tp) 27 | return 28 | } 29 | 30 | if parameter != "list" { 31 | fmt.Println("Bad map type:", parameter) 32 | } 33 | fmt.Println("Possible map types (to be used with --type/-t):") 34 | // print sorted keys 35 | keys := make([]string, 0, len(tileProviders)) 36 | for k := range tileProviders { 37 | keys = append(keys, k) 38 | } 39 | sort.Strings(keys) 40 | for _, k := range keys { 41 | fmt.Println(k) 42 | } 43 | os.Exit(0) 44 | } 45 | 46 | func handleCenterOption(ctx *sm.Context, parameter string) { 47 | lat, lng, err := coordsparser.Parse(parameter) 48 | if err != nil { 49 | log.Fatal(err) 50 | } else { 51 | ctx.SetCenter(s2.LatLngFromDegrees(lat, lng)) 52 | } 53 | } 54 | 55 | func handleBboxOption(ctx *sm.Context, parameter string) { 56 | pair := strings.Split(parameter, "|") 57 | if len(pair) != 2 { 58 | log.Fatalf("Bad NW|SE coordinates pair: %s", parameter) 59 | } 60 | 61 | var err error 62 | var nwlat float64 63 | var nwlng float64 64 | var selat float64 65 | var selng float64 66 | nwlat, nwlng, err = coordsparser.Parse(pair[0]) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | selat, selng, err = coordsparser.Parse(pair[1]) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | 75 | var bbox *s2.Rect 76 | bbox, err = sm.CreateBBox(nwlat, nwlng, selat, selng) 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | 81 | ctx.SetBoundingBox(*bbox) 82 | } 83 | 84 | func handleBackgroundOption(ctx *sm.Context, parameter string) { 85 | color, err := sm.ParseColorString(parameter) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | ctx.SetBackground(color) 91 | } 92 | 93 | func handleMarkersOption(ctx *sm.Context, parameters []string) { 94 | for _, s := range parameters { 95 | markers, err := sm.ParseMarkerString(s) 96 | if err != nil { 97 | log.Fatal(err) 98 | } else { 99 | for _, marker := range markers { 100 | ctx.AddObject(marker) 101 | } 102 | } 103 | } 104 | } 105 | 106 | func handleImageMarkersOption(ctx *sm.Context, parameters []string) { 107 | for _, s := range parameters { 108 | markers, err := sm.ParseImageMarkerString(s) 109 | if err != nil { 110 | log.Fatal(err) 111 | } else { 112 | for _, marker := range markers { 113 | ctx.AddObject(marker) 114 | } 115 | } 116 | } 117 | } 118 | 119 | func handlePathsOption(ctx *sm.Context, parameters []string) { 120 | for _, s := range parameters { 121 | paths, err := sm.ParsePathString(s) 122 | if err != nil { 123 | log.Fatal(err) 124 | } else { 125 | for _, path := range paths { 126 | ctx.AddObject(path) 127 | } 128 | } 129 | } 130 | } 131 | 132 | func handleAreasOption(ctx *sm.Context, parameters []string) { 133 | for _, s := range parameters { 134 | area, err := sm.ParseAreaString(s) 135 | if err != nil { 136 | log.Fatal(err) 137 | } else { 138 | ctx.AddObject(area) 139 | } 140 | } 141 | } 142 | 143 | func handleCirclesOption(ctx *sm.Context, parameters []string) { 144 | for _, s := range parameters { 145 | circles, err := sm.ParseCircleString(s) 146 | if err != nil { 147 | log.Fatal(err) 148 | } else { 149 | for _, circle := range circles { 150 | ctx.AddObject(circle) 151 | } 152 | } 153 | } 154 | } 155 | 156 | func main() { 157 | var opts struct { 158 | // ClearCache bool `long:"clear-cache" description:"Clears the tile cache"` 159 | Width int `long:"width" description:"Width of the generated static map image" value-name:"PIXELS" default:"512"` 160 | Height int `long:"height" description:"Height of the generated static map image" value-name:"PIXELS" default:"512"` 161 | Output string `short:"o" long:"output" description:"Output file name" value-name:"FILENAME" default:"map.png"` 162 | Type string `short:"t" long:"type" description:"Select the map type; list possible map types with '--type list'" value-name:"MAPTYPE"` 163 | Center string `short:"c" long:"center" description:"Center coordinates (lat,lng) of the static map" value-name:"LATLNG"` 164 | Zoom int `short:"z" long:"zoom" description:"Zoom factor" value-name:"ZOOMLEVEL"` 165 | BBox string `short:"b" long:"bbox" description:"Bounding box of the static map" value-name:"nwLATLNG|seLATLNG"` 166 | Background string `long:"background" description:"Background color" value-name:"COLOR" default:"transparent"` 167 | UserAgent string `short:"u" long:"useragent" description:"Overwrite the default HTTP user agent string" value-name:"USERAGENT"` 168 | Markers []string `short:"m" long:"marker" description:"Add a marker to the static map" value-name:"MARKER"` 169 | ImageMarkers []string `short:"i" long:"imagemarker" description:"Add an image marker to the static map" value-name:"MARKER"` 170 | Paths []string `short:"p" long:"path" description:"Add a path to the static map" value-name:"PATH"` 171 | Areas []string `short:"a" long:"area" description:"Add an area to the static map" value-name:"AREA"` 172 | Circles []string `short:"C" long:"circle" description:"Add a circle to the static map" value-name:"CIRCLE"` 173 | ThunderforstAPIKey string `long:"thunderforestapikey" description:"API key to use with Thunderforst tile servers" value-name:"APIKEY" default:"NONE"` 174 | } 175 | 176 | parser := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash) 177 | parser.LongDescription = `Creates a static map` 178 | _, err := parser.Parse() 179 | if err != nil { 180 | log.Fatal(err) 181 | } 182 | 183 | if parser.FindOptionByLongName("help").IsSet() { 184 | parser.WriteHelp(os.Stdout) 185 | os.Exit(0) 186 | } 187 | 188 | ctx := sm.NewContext() 189 | 190 | if parser.FindOptionByLongName("type").IsSet() { 191 | handleTypeOption(ctx, opts.Type, opts.ThunderforstAPIKey) 192 | } 193 | 194 | ctx.SetSize(opts.Width, opts.Height) 195 | 196 | if parser.FindOptionByLongName("zoom").IsSet() { 197 | ctx.SetZoom(opts.Zoom) 198 | } 199 | 200 | if parser.FindOptionByLongName("center").IsSet() { 201 | handleCenterOption(ctx, opts.Center) 202 | } 203 | 204 | if parser.FindOptionByLongName("bbox").IsSet() { 205 | handleBboxOption(ctx, opts.BBox) 206 | } 207 | 208 | if parser.FindOptionByLongName("background").IsSet() { 209 | handleBackgroundOption(ctx, opts.Background) 210 | } 211 | 212 | if parser.FindOptionByLongName("useragent").IsSet() { 213 | ctx.SetUserAgent(opts.UserAgent) 214 | } 215 | 216 | handleAreasOption(ctx, opts.Areas) 217 | handleMarkersOption(ctx, opts.Markers) 218 | handleImageMarkersOption(ctx, opts.ImageMarkers) 219 | handleCirclesOption(ctx, opts.Circles) 220 | handlePathsOption(ctx, opts.Paths) 221 | 222 | img, err := ctx.Render() 223 | if err != nil { 224 | log.Fatal(err) 225 | } 226 | 227 | if err = gg.SavePNG(opts.Output, img); err != nil { 228 | log.Fatal(err) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /tile_provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | package sm 7 | 8 | import "fmt" 9 | 10 | // TileProvider encapsulates all infos about a map tile provider service (name, url scheme, attribution, etc.) 11 | type TileProvider struct { 12 | Name string 13 | Attribution string 14 | IgnoreNotFound bool 15 | TileSize int 16 | URLPattern string // "%[1]s" => shard, "%[2]d" => zoom, "%[3]d" => x, "%[4]d" => y, "%[5]s" => API key 17 | Shards []string 18 | APIKey string 19 | } 20 | 21 | // IsNone returns true if t is an empyt TileProvider (e.g. no configured Url) 22 | func (t TileProvider) IsNone() bool { 23 | return len(t.URLPattern) == 0 24 | } 25 | 26 | func (t *TileProvider) getURL(shard string, zoom, x, y int, apikey string) string { 27 | if t.IsNone() { 28 | return "" 29 | } 30 | return fmt.Sprintf(t.URLPattern, shard, zoom, x, y, apikey) 31 | } 32 | 33 | // NewTileProviderOpenStreetMaps creates a TileProvider struct for OSM's tile service 34 | func NewTileProviderOpenStreetMaps() *TileProvider { 35 | t := new(TileProvider) 36 | t.Name = "osm" 37 | t.Attribution = "Maps and Data (c) openstreetmap.org and contributors, ODbL" 38 | t.TileSize = 256 39 | t.URLPattern = "https://%[1]s.tile.openstreetmap.org/%[2]d/%[3]d/%[4]d.png" 40 | t.Shards = []string{"a", "b", "c"} 41 | return t 42 | } 43 | 44 | func newTileProviderThunderforest(name string, apikey string) *TileProvider { 45 | t := new(TileProvider) 46 | t.Name = fmt.Sprintf("thunderforest-%s", name) 47 | t.Attribution = "Maps (c) Thundeforest; Data (c) OSM and contributors, ODbL" 48 | t.TileSize = 256 49 | t.APIKey = apikey 50 | t.URLPattern = "https://%[1]s.tile.thunderforest.com/" + name + "/%[2]d/%[3]d/%[4]d.png?apikey=%[5]s" 51 | t.Shards = []string{"a", "b", "c"} 52 | return t 53 | } 54 | 55 | // NewTileProviderThunderforestLandscape creates a TileProvider struct for thundeforests's 'landscape' tile service 56 | func NewTileProviderThunderforestLandscape(thunderforestApiKey string) *TileProvider { 57 | return newTileProviderThunderforest("landscape", thunderforestApiKey) 58 | } 59 | 60 | // NewTileProviderThunderforestOutdoors creates a TileProvider struct for thundeforests's 'outdoors' tile service 61 | func NewTileProviderThunderforestOutdoors(thunderforestApiKey string) *TileProvider { 62 | return newTileProviderThunderforest("outdoors", thunderforestApiKey) 63 | } 64 | 65 | // NewTileProviderThunderforestTransport creates a TileProvider struct for thundeforests's 'transport' tile service 66 | func NewTileProviderThunderforestTransport(thunderforestApiKey string) *TileProvider { 67 | return newTileProviderThunderforest("transport", thunderforestApiKey) 68 | } 69 | 70 | // NewTileProviderStamenToner creates a TileProvider struct for stamens' 'toner' tile service 71 | func NewTileProviderStamenToner() *TileProvider { 72 | t := new(TileProvider) 73 | t.Name = "stamen-toner" 74 | t.Attribution = "Maps (c) Stamen; Data (c) OSM and contributors, ODbL" 75 | t.TileSize = 256 76 | t.URLPattern = "http://%[1]s.tile.stamen.com/toner/%[2]d/%[3]d/%[4]d.png" 77 | t.Shards = []string{"a", "b", "c", "d"} 78 | return t 79 | } 80 | 81 | // NewTileProviderStamenTerrain creates a TileProvider struct for stamens' 'terrain' tile service 82 | func NewTileProviderStamenTerrain() *TileProvider { 83 | t := new(TileProvider) 84 | t.Name = "stamen-terrain" 85 | t.Attribution = "Maps (c) Stamen; Data (c) OSM and contributors, ODbL" 86 | t.TileSize = 256 87 | t.URLPattern = "http://%[1]s.tile.stamen.com/terrain/%[2]d/%[3]d/%[4]d.png" 88 | t.Shards = []string{"a", "b", "c", "d"} 89 | return t 90 | } 91 | 92 | // NewTileProviderOpenTopoMap creates a TileProvider struct for opentopomap's tile service 93 | func NewTileProviderOpenTopoMap() *TileProvider { 94 | t := new(TileProvider) 95 | t.Name = "opentopomap" 96 | t.Attribution = "Maps (c) OpenTopoMap [CC-BY-SA]; Data (c) OSM and contributors [ODbL]; Data (c) SRTM" 97 | t.TileSize = 256 98 | t.URLPattern = "http://%[1]s.tile.opentopomap.org/%[2]d/%[3]d/%[4]d.png" 99 | t.Shards = []string{"a", "b", "c"} 100 | return t 101 | } 102 | 103 | // NewTileProviderWikimedia creates a TileProvider struct for Wikimedia's tile service 104 | func NewTileProviderWikimedia() *TileProvider { 105 | t := new(TileProvider) 106 | t.Name = "wikimedia" 107 | t.Attribution = "Map (c) Wikimedia; Data (c) OSM and contributors, ODbL." 108 | t.TileSize = 256 109 | t.URLPattern = "https://maps.wikimedia.org/osm-intl/%[2]d/%[3]d/%[4]d.png" 110 | t.Shards = []string{} 111 | return t 112 | } 113 | 114 | // NewTileProviderOpenCycleMap creates a TileProvider struct for OpenCycleMap's tile service 115 | func NewTileProviderOpenCycleMap() *TileProvider { 116 | t := new(TileProvider) 117 | t.Name = "cycle" 118 | t.Attribution = "Maps and Data (c) openstreetmaps.org and contributors, ODbL" 119 | t.TileSize = 256 120 | t.URLPattern = "http://%[1]s.tile.opencyclemap.org/cycle/%[2]d/%[3]d/%[4]d.png" 121 | t.Shards = []string{"a", "b"} 122 | return t 123 | } 124 | 125 | // NewTileProviderOpenSeaMap creates a TileProvider struct for OpenSeaMap's tile service 126 | func NewTileProviderOpenSeaMap() *TileProvider { 127 | t := new(TileProvider) 128 | t.Name = "sea" 129 | t.Attribution = "Maps and Data (c) openstreetmaps.org and contributors, ODbL" 130 | t.TileSize = 256 131 | t.URLPattern = "http://t1.openseamap.org/seamark/%[2]d/%[3]d/%[4]d.png" 132 | t.Shards = []string{} 133 | return t 134 | } 135 | 136 | // NewTileProviderCarto creates a TileProvider struct for Carto's tile service 137 | // See https://github.com/CartoDB/basemap-styles?tab=readme-ov-file#1-web-raster-basemaps for available names 138 | func NewTileProviderCarto(name string) *TileProvider { 139 | t := new(TileProvider) 140 | t.Name = fmt.Sprintf("carto-%s", name) 141 | t.Attribution = "Map (c) Carto [CC BY 3.0] Data (c) OSM and contributors, ODbL." 142 | t.TileSize = 256 143 | t.URLPattern = "https://cartodb-basemaps-%[1]s.global.ssl.fastly.net/" + name + "/%[2]d/%[3]d/%[4]d.png" 144 | t.Shards = []string{"a", "b", "c", "d"} 145 | return t 146 | } 147 | 148 | // NewTileProviderCartoLight creates a TileProvider struct for Carto's tile service (light variant) 149 | func NewTileProviderCartoLight() *TileProvider { 150 | return NewTileProviderCarto("light_all") 151 | } 152 | 153 | // NewTileProviderCartoDark creates a TileProvider struct for Carto's tile service (dark variant) 154 | func NewTileProviderCartoDark() *TileProvider { 155 | return NewTileProviderCarto("dark_all") 156 | } 157 | 158 | // NewTileProviderArcgisWorldImagery creates a TileProvider struct for Arcgis' WorldImagery tiles 159 | func NewTileProviderArcgisWorldImagery() *TileProvider { 160 | t := new(TileProvider) 161 | t.Name = "arcgis-worldimagery" 162 | t.Attribution = "Source: Esri, Maxar, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community" 163 | t.TileSize = 256 164 | t.URLPattern = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/%[2]d/%[4]d/%[3]d" 165 | t.Shards = []string{} 166 | return t 167 | } 168 | 169 | // NewTileProviderNone creates a TileProvider struct that does not provide any tiles 170 | func NewTileProviderNone() *TileProvider { 171 | t := new(TileProvider) 172 | t.Name = "none" 173 | t.Attribution = "" 174 | t.TileSize = 256 175 | t.URLPattern = "" 176 | t.Shards = []string{} 177 | return t 178 | } 179 | 180 | // GetTileProviders returns a map of all available TileProviders 181 | func GetTileProviders(thunderforestApiKey string) map[string]*TileProvider { 182 | m := make(map[string]*TileProvider) 183 | 184 | list := []*TileProvider{ 185 | NewTileProviderThunderforestLandscape(thunderforestApiKey), 186 | NewTileProviderThunderforestOutdoors(thunderforestApiKey), 187 | NewTileProviderThunderforestTransport(thunderforestApiKey), 188 | NewTileProviderStamenToner(), 189 | NewTileProviderStamenTerrain(), 190 | NewTileProviderOpenTopoMap(), 191 | NewTileProviderOpenStreetMaps(), 192 | NewTileProviderOpenCycleMap(), 193 | NewTileProviderOpenSeaMap(), 194 | NewTileProviderCarto("rastertiles/voyager"), 195 | NewTileProviderCartoLight(), 196 | NewTileProviderCartoDark(), 197 | NewTileProviderArcgisWorldImagery(), 198 | NewTileProviderWikimedia(), 199 | NewTileProviderNone(), 200 | } 201 | 202 | for _, tp := range list { 203 | m[tp.Name] = tp 204 | } 205 | 206 | return m 207 | } 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/flopp/go-staticmaps)](https://pkg.go.dev/github.com/flopp/go-staticmaps) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/flopp/go-staticmaps)](https://goreportcard.com/report/flopp/go-staticmaps) 3 | ![golang/static](https://github.com/flopp/go-staticmaps/workflows/golang/static/badge.svg) 4 | [![License MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](https://github.com/flopp/go-staticmaps/) 5 | 6 | # go-staticmaps 7 | A go (golang) library and command line tool to render static map images using OpenStreetMap tiles. 8 | 9 | ## What? 10 | go-staticmaps is a golang library that allows you to create nice static map images from OpenStreetMap tiles, along with markers of different size and color, as well as paths and colored areas. 11 | 12 | For a Python version with a similar interface, take a look at [py-staticmaps](https://github.com/flopp/py-staticmaps). 13 | 14 | go-staticmaps comes with a command line tool called `create-static-map` for use in shell scripts, etc. 15 | 16 | ![Static map of the Berlin Marathon](https://raw.githubusercontent.com/flopp/flopp.github.io/master/go-staticmaps/berlin-marathon.png) 17 | 18 | ## How? 19 | 20 | ### Installation 21 | 22 | Installing go-staticmaps is as easy as 23 | 24 | ```bash 25 | go get -u github.com/flopp/go-staticmaps 26 | ``` 27 | 28 | For the command line tool, use 29 | ```bash 30 | go get -u github.com/flopp/go-staticmaps/create-static-map 31 | ``` 32 | 33 | Of course, your local Go installation must be setup up properly. 34 | 35 | ### Library Usage 36 | 37 | Create a 400x300 pixel map with a red marker: 38 | 39 | ```go 40 | package main 41 | 42 | import ( 43 | "image/color" 44 | 45 | sm "github.com/flopp/go-staticmaps" 46 | "github.com/fogleman/gg" 47 | "github.com/golang/geo/s2" 48 | ) 49 | 50 | func main() { 51 | ctx := sm.NewContext() 52 | ctx.SetSize(400, 300) 53 | ctx.SetZoom(14) 54 | ctx.AddObject( 55 | sm.NewMarker( 56 | s2.LatLngFromDegrees(52.514536, 13.350151), 57 | color.RGBA{0xff, 0, 0, 0xff}, 58 | 16.0, 59 | ), 60 | ) 61 | 62 | img, err := ctx.Render() 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | if err := gg.SavePNG("my-map.png", img); err != nil { 68 | panic(err) 69 | } 70 | } 71 | ``` 72 | 73 | 74 | See [PkgGoDev](https://pkg.go.dev/github.com/flopp/go-staticmaps) for a complete documentation and the source code of the [command line tool](https://github.com/flopp/go-staticmaps/blob/master/create-static-map/create-static-map.go) for an example how to use the package. 75 | 76 | 77 | ### Command Line Usage 78 | 79 | Usage: 80 | create-static-map [OPTIONS] 81 | 82 | Creates a static map 83 | 84 | Application Options: 85 | --width=PIXELS Width of the generated static map image (default: 512) 86 | --height=PIXELS Height of the generated static map image (default: 512) 87 | -o, --output=FILENAME Output file name (default: map.png) 88 | -t, --type=MAPTYPE Select the map type; list possible map types with '--type list' 89 | -c, --center=LATLNG Center coordinates (lat,lng) of the static map 90 | -z, --zoom=ZOOMLEVEL Zoom factor 91 | -b, --bbox=nwLATLNG|seLATLNG Bounding box of the static map 92 | --background=COLOR Background color (default: transparent) 93 | -u, --useragent=USERAGENT Overwrite the default HTTP user agent string 94 | -m, --marker=MARKER Add a marker to the static map 95 | -i, --imagemarker=MARKER Add an image marker to the static map 96 | -p, --path=PATH Add a path to the static map 97 | -a, --area=AREA Add an area to the static map 98 | -C, --circle=CIRCLE Add a circle to the static map 99 | 100 | Help Options: 101 | -h, --help Show this help message 102 | 103 | ### General 104 | The command line interface tries to resemble [Google's Static Maps API](https://developers.google.com/maps/documentation/static-maps/intro). 105 | If neither `--bbox`, `--center`, nor `--zoom` are given, the map extent is determined from the specified markers, paths and areas. 106 | 107 | `--background` lets you specify a color used for map areas that are not covered by map tiles (areas north of 85°/south of -85°). 108 | 109 | ### Markers 110 | The `--marker` option defines one or more map markers of the same style. Use multiple `--marker` options to add markers of different styles. 111 | 112 | --marker MARKER_STYLES|LATLNG|LATLNG|... 113 | 114 | `LATLNG` is a comma separated pair of latitude and longitude, e.g. `52.5153,13.3564`. 115 | 116 | `MARKER_STYLES` consists of a set of style descriptors separated by the pipe character `|`: 117 | 118 | - `color:COLOR` - where `COLOR` is either of the form `0xRRGGBB`, `0xRRGGBBAA`, or one of `black`, `blue`, `brown`, `green`, `orange`, `purple`, `red`, `yellow`, `white` (default: `red`) 119 | - `size:SIZE` - where `SIZE` is one of `mid`, `small`, `tiny`, or some number > 0 (default: `mid`) 120 | - `label:LABEL` - where `LABEL` is an alpha numeric character, i.e. `A`-`Z`, `a`-`z`, `0`-`9`; (default: no label) 121 | - `labelcolor:COLOR` - where `COLOR` is either of the form `0xRRGGBB`, `0xRRGGBBAA`, or one of `black`, `blue`, `brown`, `green`, `orange`, `purple`, `red`, `yellow`, `white` (default: `black` or `white`, depending on the marker color) 122 | 123 | Using the `--imagemarker` option, you can use custom images as markers: 124 | 125 | --imagemarker image:IMAGEFILE|offsetx:OFFSETX|offsety:OFFSETY|LATLNG|LATLNG|... 126 | 127 | `IMAGEFILE` is the file name of a PNG or JPEG file, 128 | 129 | `OFFSETX` and `OFFSETY` are the pixel offsets of the reference point from the top-left corner of the image. 130 | 131 | ### Paths 132 | The `--path` option defines a path on the map. Use multiple `--path` options to add multiple paths to the map. 133 | 134 | --path PATH_STYLES|LATLNG|LATLNG|... 135 | 136 | or 137 | 138 | --path PATH_STYLES|gpx:my_gpx_file.gpx 139 | 140 | `PATH_STYLES` consists of a set of style descriptors separated by the pipe character `|`: 141 | 142 | - `color:COLOR` - where `COLOR` is either of the form `0xRRGGBB`, `0xRRGGBBAA`, or one of `black`, `blue`, `brown`, `green`, `orange`, `purple`, `red`, `yellow`, `white` (default: `red`) 143 | - `weight:WEIGHT` - where `WEIGHT` is the line width in pixels (defaut: `5`) 144 | 145 | ### Areas 146 | The `--area` option defines a closed area on the map. Use multiple `--area` options to add multiple areas to the map. 147 | 148 | --area AREA_STYLES|LATLNG|LATLNG|... 149 | 150 | `AREA_STYLES` consists of a set of style descriptors separated by the pipe character `|`: 151 | 152 | - `color:COLOR` - where `COLOR` is either of the form `0xRRGGBB`, `0xRRGGBBAA`, or one of `black`, `blue`, `brown`, `green`, `orange`, `purple`, `red`, `yellow`, `white` (default: `red`) 153 | - `weight:WEIGHT` - where `WEIGHT` is the line width in pixels (defaut: `5`) 154 | - `fill:COLOR` - where `COLOR` is either of the form `0xRRGGBB`, `0xRRGGBBAA`, or one of `black`, `blue`, `brown`, `green`, `orange`, `purple`, `red`, `yellow`, `white` (default: none) 155 | 156 | 157 | ### Circles 158 | The `--circles` option defines one or more circles of the same style. Use multiple `--circle` options to add circles of different styles. 159 | 160 | --circle CIRCLE_STYLES|LATLNG|LATLNG|... 161 | 162 | `LATLNG` is a comma separated pair of latitude and longitude, e.g. `52.5153,13.3564`. 163 | 164 | `CIRCLE_STYLES` consists of a set of style descriptors separated by the pipe character `|`: 165 | 166 | - `color:COLOR` - where `COLOR` is either of the form `0xRRGGBB`, `0xRRGGBBAA`, or one of `black`, `blue`, `brown`, `green`, `orange`, `purple`, `red`, `yellow`, `white` (default: `red`) 167 | - `fill:COLOR` - where `COLOR` is either of the form `0xRRGGBB`, `0xRRGGBBAA`, or one of `black`, `blue`, `brown`, `green`, `orange`, `purple`, `red`, `yellow`, `white` (default: no fill color) 168 | - `radius:RADIUS` - where `RADIUS` is te circle radius in meters (default: `100.0`) 169 | - `weight:WEIGHT` - where `WEIGHT` is the line width in pixels (defaut: `5`) 170 | 171 | 172 | ## Examples 173 | 174 | ### Basic Maps 175 | 176 | Centered at "N 52.514536 E 13.350151" with zoom level 10: 177 | 178 | ```bash 179 | $ create-static-map --width 600 --height 400 -o map1.png -c "52.514536,13.350151" -z 10 180 | ``` 181 | ![Example 1](https://raw.githubusercontent.com/flopp/flopp.github.io/master/go-staticmaps/map1.png) 182 | 183 | A map with a marker at "N 52.514536 E 13.350151" with zoom level 14 (no need to specify the map's center - it is automatically computed from the marker(s)): 184 | 185 | ```bash 186 | $ create-static-map --width 600 --height 400 -o map2.png -z 14 -m "52.514536,13.350151" 187 | ``` 188 | 189 | ![Example 2](https://raw.githubusercontent.com/flopp/flopp.github.io/master/go-staticmaps/map2.png) 190 | 191 | A map with two markers (red and green). If there are more than two markers in the map, a *good* zoom level can be determined automatically: 192 | 193 | ```bash 194 | $ create-static-map --width 600 --height 400 -o map3.png -m "color:red|52.514536,13.350151" -m "color:green|52.516285,13.377746" 195 | ``` 196 | 197 | ![Example 3](https://raw.githubusercontent.com/flopp/flopp.github.io/master/go-staticmaps/map3.png) 198 | 199 | 200 | ### Create a map of the Berlin Marathon 201 | 202 | create-static-map --width 800 --height 600 \ 203 | --marker "color:green|52.5153,13.3564" \ 204 | --marker "color:red|52.5160,13.3711" \ 205 | --output "berlin-marathon.png" \ 206 | --path "color:blue|weight:2|gpx:berlin-marathon.gpx" 207 | 208 | ![Static map of the Berlin Marathon](https://raw.githubusercontent.com/flopp/flopp.github.io/master/go-staticmaps/berlin-marathon.png) 209 | 210 | ### Create a map of the US capitals 211 | 212 | create-static-map --width 800 --height 400 \ 213 | --output "us-capitals.png" \ 214 | --marker "color:blue|size:tiny|32.3754,-86.2996|58.3637,-134.5721|33.4483,-112.0738|34.7244,-92.2789|\ 215 | 38.5737,-121.4871|39.7551,-104.9881|41.7665,-72.6732|39.1615,-75.5136|30.4382,-84.2806|33.7545,-84.3897|\ 216 | 21.2920,-157.8219|43.6021,-116.2125|39.8018,-89.6533|39.7670,-86.1563|41.5888,-93.6203|39.0474,-95.6815|\ 217 | 38.1894,-84.8715|30.4493,-91.1882|44.3294,-69.7323|38.9693,-76.5197|42.3589,-71.0568|42.7336,-84.5466|\ 218 | 44.9446,-93.1027|32.3122,-90.1780|38.5698,-92.1941|46.5911,-112.0205|40.8136,-96.7026|39.1501,-119.7519|\ 219 | 43.2314,-71.5597|40.2202,-74.7642|35.6816,-105.9381|42.6517,-73.7551|35.7797,-78.6434|46.8084,-100.7694|\ 220 | 39.9622,-83.0007|35.4931,-97.4591|44.9370,-123.0272|40.2740,-76.8849|41.8270,-71.4087|34.0007,-81.0353|\ 221 | 44.3776,-100.3177|36.1589,-86.7821|30.2687,-97.7452|40.7716,-111.8882|44.2627,-72.5716|37.5408,-77.4339|\ 222 | 47.0449,-122.9016|38.3533,-81.6354|43.0632,-89.4007|41.1389,-104.8165" 223 | 224 | ![Static map of the US capitals](https://raw.githubusercontent.com/flopp/flopp.github.io/master/go-staticmaps/us-capitals.png) 225 | 226 | ### Create a map of Australia 227 | ...where the Northern Territory is highlighted and the capital Canberra is marked. 228 | 229 | create-static-map --width 800 --height 600 \ 230 | --center="-26.284973,134.303764" \ 231 | --output "australia.png" \ 232 | --marker "color:blue|-35.305200,149.121574" \ 233 | --area "color:0x00FF00|fill:0x00FF007F|weight:2|-25.994024,129.013847|-25.994024,137.989677|-16.537670,138.011649|\ 234 | -14.834820,135.385917|-12.293236,137.033866|-11.174554,130.398124|-12.925791,130.167411|-14.866678,129.002860" 235 | 236 | ![Static map of Australia](https://raw.githubusercontent.com/flopp/flopp.github.io/master/go-staticmaps/australia.png) 237 | 238 | ## Acknowledgements 239 | Besides the go standard library, go-staticmaps uses 240 | 241 | - [OpenStreetMap](http://openstreetmap.org/), [Thunderforest](http://www.thunderforest.com/), [OpenTopoMap](http://www.opentopomap.org/), [Stamen](http://maps.stamen.com/) and [Carto](http://carto.com) as map tile providers 242 | - [Go Graphics](https://github.com/fogleman/gg) for 2D drawing 243 | - [S2 geometry library](https://github.com/golang/geo) for spherical geometry calculations 244 | - [gpxgo](github.com/tkrajina/gpxgo) for loading GPX files 245 | - [go-coordsparser](https://github.com/flopp/go-coordsparser) for parsing geo coordinates 246 | 247 | ## Contributors 248 | - [Kooper](https://github.com/Kooper): fixed *library usage examples* 249 | - [felix](https://github.com/felix): added *more tile servers* 250 | - [wiless](https://github.com/wiless): suggested to add user definable *marker label colors* 251 | - [noki](https://github.com/Noki): suggested to add a user definable *bounding box* 252 | - [digitocero](https://github.com/digitocero): reported and fixed *type mismatch error* 253 | - [bcicen](https://github.com/bcicen): reported and fixed *syntax error in examples* 254 | - [pshevtsov](https://github.com/pshevtsov): fixed *drawing of empty attribution strings* 255 | - [Luzifer](https://github.com/Luzifer): added *overwritable user agent strings* to comply with the OSM tile usage policy 256 | - [Jason Fox](https://github.com/jasonpfox): added `RenderWithBounds` function 257 | - [Alexander A. Kapralov](https://github.com/alnkapa): initial *circles* implementation 258 | - [tsukumaru](https://github.com/tsukumaru): added `NewArea` and `NewPath` functions 259 | 260 | ## License 261 | Copyright 2016, 2017 Florian Pigorsch & Contributors. All rights reserved. 262 | 263 | Use of this source code is governed by a MIT-style license that can be found in the LICENSE file. 264 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016, 2017 Florian Pigorsch. All rights reserved. 2 | // 3 | // Use of this source code is governed by a MIT-style 4 | // license that can be found in the LICENSE file. 5 | 6 | // Package sm (~ static maps) renders static map images from OSM tiles with markers, paths, and filled areas. 7 | package sm 8 | 9 | import ( 10 | "errors" 11 | "image" 12 | "image/color" 13 | "image/draw" 14 | "log" 15 | "math" 16 | "strings" 17 | "sync" 18 | 19 | "github.com/fogleman/gg" 20 | "github.com/golang/geo/r2" 21 | "github.com/golang/geo/s1" 22 | "github.com/golang/geo/s2" 23 | ) 24 | 25 | // Context holds all information about the map image that is to be rendered 26 | type Context struct { 27 | width int 28 | height int 29 | 30 | hasZoom bool 31 | zoom int 32 | maxZoom int 33 | 34 | hasCenter bool 35 | center s2.LatLng 36 | 37 | hasBoundingBox bool 38 | boundingBox s2.Rect 39 | 40 | background color.Color 41 | 42 | objects []MapObject 43 | overlays []*TileProvider 44 | 45 | userAgent string 46 | online bool 47 | tileProvider *TileProvider 48 | cache TileCache 49 | 50 | overrideAttribution *string 51 | } 52 | 53 | // NewContext creates a new instance of Context 54 | func NewContext() *Context { 55 | t := new(Context) 56 | t.width = 512 57 | t.height = 512 58 | t.hasZoom = false 59 | t.maxZoom = 30 60 | t.hasCenter = false 61 | t.hasBoundingBox = false 62 | t.background = nil 63 | t.userAgent = "" 64 | t.online = true 65 | t.tileProvider = NewTileProviderOpenStreetMaps() 66 | t.cache = NewTileCacheFromUserCache(0777) 67 | return t 68 | } 69 | 70 | // SetTileProvider sets the TileProvider to be used 71 | func (m *Context) SetTileProvider(t *TileProvider) { 72 | m.tileProvider = t 73 | } 74 | 75 | // SetCache takes a nil argument to disable caching 76 | func (m *Context) SetCache(cache TileCache) { 77 | m.cache = cache 78 | } 79 | 80 | // SetOnline enables/disables online 81 | // TileFetcher will only fetch tiles from cache if online = false 82 | func (m *Context) SetOnline(online bool) { 83 | m.online = online 84 | } 85 | 86 | // SetUserAgent sets the HTTP user agent string used when downloading map tiles 87 | func (m *Context) SetUserAgent(a string) { 88 | m.userAgent = a 89 | } 90 | 91 | // SetSize sets the size of the generated image 92 | func (m *Context) SetSize(width, height int) { 93 | m.width = width 94 | m.height = height 95 | } 96 | 97 | // SetZoom sets the zoom level 98 | func (m *Context) SetZoom(zoom int) { 99 | m.zoom = zoom 100 | m.hasZoom = true 101 | } 102 | 103 | // SetMaxZoom sets the upper zoom level limit when using dynamic zoom 104 | func (m *Context) SetMaxZoom(n int) { 105 | m.maxZoom = n 106 | } 107 | 108 | // SetCenter sets the center coordinates 109 | func (m *Context) SetCenter(center s2.LatLng) { 110 | m.center = center 111 | m.hasCenter = true 112 | } 113 | 114 | // SetBoundingBox sets the bounding box 115 | func (m *Context) SetBoundingBox(bbox s2.Rect) { 116 | m.boundingBox = bbox 117 | m.hasBoundingBox = true 118 | } 119 | 120 | // SetBackground sets the background color (used as a fallback for areas without map tiles) 121 | func (m *Context) SetBackground(col color.Color) { 122 | m.background = col 123 | } 124 | 125 | // AddMarker adds a marker to the Context 126 | // 127 | // Deprecated: AddMarker is deprecated. Use the more general AddObject. 128 | func (m *Context) AddMarker(marker *Marker) { 129 | m.AddObject(marker) 130 | } 131 | 132 | // ClearMarkers removes all markers from the Context 133 | func (m *Context) ClearMarkers() { 134 | filtered := []MapObject{} 135 | for _, object := range m.objects { 136 | switch object.(type) { 137 | case *Marker: 138 | // skip 139 | default: 140 | filtered = append(filtered, object) 141 | } 142 | } 143 | m.objects = filtered 144 | } 145 | 146 | // AddPath adds a path to the Context 147 | // 148 | // Deprecated: AddPath is deprecated. Use the more general AddObject. 149 | func (m *Context) AddPath(path *Path) { 150 | m.AddObject(path) 151 | } 152 | 153 | // ClearPaths removes all paths from the Context 154 | func (m *Context) ClearPaths() { 155 | filtered := []MapObject{} 156 | for _, object := range m.objects { 157 | switch object.(type) { 158 | case *Path: 159 | // skip 160 | default: 161 | filtered = append(filtered, object) 162 | } 163 | } 164 | m.objects = filtered 165 | } 166 | 167 | // AddArea adds an area to the Context 168 | // 169 | // Deprecated: AddArea is deprecated. Use the more general AddObject. 170 | func (m *Context) AddArea(area *Area) { 171 | m.AddObject(area) 172 | } 173 | 174 | // ClearAreas removes all areas from the Context 175 | func (m *Context) ClearAreas() { 176 | filtered := []MapObject{} 177 | for _, object := range m.objects { 178 | switch object.(type) { 179 | case *Area: 180 | // skip 181 | default: 182 | filtered = append(filtered, object) 183 | } 184 | } 185 | m.objects = filtered 186 | } 187 | 188 | // AddCircle adds an circle to the Context 189 | // 190 | // Deprecated: AddCircle is deprecated. Use the more general AddObject. 191 | func (m *Context) AddCircle(circle *Circle) { 192 | m.AddObject(circle) 193 | } 194 | 195 | // ClearCircles removes all circles from the Context 196 | func (m *Context) ClearCircles() { 197 | filtered := []MapObject{} 198 | for _, object := range m.objects { 199 | switch object.(type) { 200 | case *Circle: 201 | // skip 202 | default: 203 | filtered = append(filtered, object) 204 | } 205 | } 206 | m.objects = filtered 207 | } 208 | 209 | // AddObject adds an object to the Context 210 | func (m *Context) AddObject(object MapObject) { 211 | m.objects = append(m.objects, object) 212 | } 213 | 214 | // ClearObjects removes all objects from the Context 215 | func (m *Context) ClearObjects() { 216 | m.objects = nil 217 | } 218 | 219 | // AddOverlay adds an overlay to the Context 220 | func (m *Context) AddOverlay(overlay *TileProvider) { 221 | m.overlays = append(m.overlays, overlay) 222 | } 223 | 224 | // ClearOverlays removes all overlays from the Context 225 | func (m *Context) ClearOverlays() { 226 | m.overlays = nil 227 | } 228 | 229 | // OverrideAttribution sets a custom attribution string (or none if empty) 230 | // 231 | // If the attribution string contains newline characters ("\n") it will printed across multiple lines. 232 | // Pay attention: you might be violating the terms of usage for the 233 | // selected map provider - only use the function if you are aware of this! 234 | func (m *Context) OverrideAttribution(attribution string) { 235 | m.overrideAttribution = &attribution 236 | } 237 | 238 | // Attribution returns the current attribution string - either the overridden 239 | // version (using OverrideAttribution) or the one set by the selected 240 | // TileProvider. 241 | func (m *Context) Attribution() string { 242 | if m.overrideAttribution != nil { 243 | return *m.overrideAttribution 244 | } 245 | return m.tileProvider.Attribution 246 | } 247 | 248 | func (m *Context) determineBounds() s2.Rect { 249 | r := s2.EmptyRect() 250 | for _, object := range m.objects { 251 | r = r.Union(object.Bounds()) 252 | } 253 | return r 254 | } 255 | 256 | func (m *Context) determineExtraMarginPixels() (float64, float64, float64, float64) { 257 | maxL := 0.0 258 | maxT := 0.0 259 | maxR := 0.0 260 | maxB := 0.0 261 | if m.Attribution() != "" { 262 | maxB = 12.0 263 | } 264 | for _, object := range m.objects { 265 | l, t, r, b := object.ExtraMarginPixels() 266 | maxL = math.Max(maxL, l) 267 | maxT = math.Max(maxT, t) 268 | maxR = math.Max(maxR, r) 269 | maxB = math.Max(maxB, b) 270 | } 271 | return maxL, maxT, maxR, maxB 272 | } 273 | 274 | func (m *Context) determineZoom(bounds s2.Rect, center s2.LatLng) int { 275 | b := bounds.AddPoint(center) 276 | if b.IsEmpty() || b.IsPoint() { 277 | return 15 278 | } 279 | 280 | tileSize := m.tileProvider.TileSize 281 | marginL, marginT, marginR, marginB := m.determineExtraMarginPixels() 282 | w := (float64(m.width) - marginL - marginR) / float64(tileSize) 283 | h := (float64(m.height) - marginT - marginB) / float64(tileSize) 284 | if w <= 0 || h <= 0 { 285 | log.Printf("Object margins are bigger than the target image size => ignoring object margins for calculation of the zoom level") 286 | w = float64(m.width) / float64(tileSize) 287 | h = float64(m.height) / float64(tileSize) 288 | } 289 | minX := (b.Lo().Lng.Degrees() + 180.0) / 360.0 290 | maxX := (b.Hi().Lng.Degrees() + 180.0) / 360.0 291 | minY := (1.0 - math.Log(math.Tan(b.Lo().Lat.Radians())+(1.0/math.Cos(b.Lo().Lat.Radians())))/math.Pi) / 2.0 292 | maxY := (1.0 - math.Log(math.Tan(b.Hi().Lat.Radians())+(1.0/math.Cos(b.Hi().Lat.Radians())))/math.Pi) / 2.0 293 | 294 | dx := maxX - minX 295 | for dx < 0 { 296 | dx = dx + 1 297 | } 298 | for dx > 1 { 299 | dx = dx - 1 300 | } 301 | dy := math.Abs(maxY - minY) 302 | 303 | zoom := 1 304 | for zoom < m.maxZoom { 305 | tiles := float64(uint(1) << uint(zoom)) 306 | if dx*tiles > w || dy*tiles > h { 307 | return zoom - 1 308 | } 309 | zoom = zoom + 1 310 | } 311 | 312 | return m.maxZoom 313 | } 314 | 315 | // determineCenter computes a point that is visually centered in Mercator projection 316 | func (m *Context) determineCenter(bounds s2.Rect) s2.LatLng { 317 | latLo := bounds.Lo().Lat.Radians() 318 | latHi := bounds.Hi().Lat.Radians() 319 | yLo := math.Log((1+math.Sin(latLo))/(1-math.Sin(latLo))) / 2 320 | yHi := math.Log((1+math.Sin(latHi))/(1-math.Sin(latHi))) / 2 321 | lat := s1.Angle(math.Atan(math.Sinh((yLo + yHi) / 2))) 322 | lng := bounds.Center().Lng 323 | return s2.LatLng{Lat: lat, Lng: lng} 324 | } 325 | 326 | // adjustCenter adjust the center such that the map objects are properly centerd in the view wrt. their pixel margins. 327 | func (m *Context) adjustCenter(center s2.LatLng, zoom int) s2.LatLng { 328 | if len(m.objects) == 0 { 329 | return center 330 | } 331 | 332 | transformer := newTransformer(m.width, m.height, zoom, center, m.tileProvider.TileSize) 333 | 334 | first := true 335 | minX := 0.0 336 | maxX := 0.0 337 | minY := 0.0 338 | maxY := 0.0 339 | for _, object := range m.objects { 340 | bounds := object.Bounds() 341 | nwX, nwY := transformer.LatLngToXY(bounds.Vertex(3)) 342 | seX, seY := transformer.LatLngToXY(bounds.Vertex(1)) 343 | l, t, r, b := object.ExtraMarginPixels() 344 | if first { 345 | minX = nwX - l 346 | maxX = seX + r 347 | minY = nwY - t 348 | maxY = seY + b 349 | first = false 350 | } else { 351 | minX = math.Min(minX, nwX-l) 352 | maxX = math.Max(maxX, seX+r) 353 | minY = math.Min(minY, nwY-t) 354 | maxY = math.Max(maxY, seY+b) 355 | } 356 | } 357 | 358 | if (maxX-minX) > float64(m.width) || (maxY-minY) > float64(m.height) { 359 | log.Printf("Object margins are bigger than the target image size => ignoring object margins for adjusting the center") 360 | return center 361 | } 362 | 363 | centerX := (maxX + minX) * 0.5 364 | centerY := (maxY + minY) * 0.5 365 | 366 | return transformer.XYToLatLng(centerX, centerY) 367 | } 368 | 369 | func (m *Context) determineZoomCenter() (int, s2.LatLng, error) { 370 | if m.hasBoundingBox && !m.boundingBox.IsEmpty() { 371 | center := m.determineCenter(m.boundingBox) 372 | return m.determineZoom(m.boundingBox, center), center, nil 373 | } 374 | 375 | if m.hasCenter { 376 | if m.hasZoom { 377 | return m.zoom, m.center, nil 378 | } 379 | return m.determineZoom(m.determineBounds(), m.center), m.center, nil 380 | } 381 | 382 | bounds := m.determineBounds() 383 | if !bounds.IsEmpty() { 384 | center := m.determineCenter(bounds) 385 | zoom := m.zoom 386 | if !m.hasZoom { 387 | zoom = m.determineZoom(bounds, center) 388 | } 389 | return zoom, m.adjustCenter(center, zoom), nil 390 | } 391 | 392 | return 0, s2.LatLngFromDegrees(0, 0), errors.New("cannot determine map extent: no center coordinates given, no bounding box given, no content (markers, paths, areas) given") 393 | } 394 | 395 | // Transformer implements coordinate transformation from latitude longitude to image pixel coordinates. 396 | type Transformer struct { 397 | zoom int 398 | numTiles float64 // number of tiles per dimension at this zoom level 399 | tileSize int // tile size in pixels from this provider 400 | pWidth, pHeight int // pixel size of returned set of tiles 401 | pCenterX, pCenterY int // pixel location of requested center in set of tiles 402 | tCountX, tCountY int // download area in tile units 403 | tCenterX, tCenterY float64 // tile index to requested center 404 | tOriginX, tOriginY int // bottom left tile to download 405 | pMinX, pMaxX int 406 | proj s2.Projection 407 | } 408 | 409 | // Transformer returns an initialized Transformer instance. 410 | func (m *Context) Transformer() (*Transformer, error) { 411 | zoom, center, err := m.determineZoomCenter() 412 | if err != nil { 413 | return nil, err 414 | } 415 | 416 | return newTransformer(m.width, m.height, zoom, center, m.tileProvider.TileSize), nil 417 | } 418 | 419 | func newTransformer(width int, height int, zoom int, llCenter s2.LatLng, tileSize int) *Transformer { 420 | t := new(Transformer) 421 | 422 | t.zoom = zoom 423 | t.numTiles = math.Exp2(float64(t.zoom)) 424 | t.tileSize = tileSize 425 | // mercator projection from -0.5 to 0.5 426 | t.proj = s2.NewMercatorProjection(0.5) 427 | 428 | // fractional tile index to center of requested area 429 | t.tCenterX, t.tCenterY = t.ll2t(llCenter) 430 | 431 | ww := float64(width) / float64(tileSize) 432 | hh := float64(height) / float64(tileSize) 433 | 434 | // origin tile to fulfill request 435 | t.tOriginX = int(math.Floor(t.tCenterX - 0.5*ww)) 436 | t.tOriginY = int(math.Floor(t.tCenterY - 0.5*hh)) 437 | 438 | // tiles in each axis to fulfill request 439 | t.tCountX = 1 + int(math.Floor(t.tCenterX+0.5*ww)) - t.tOriginX 440 | t.tCountY = 1 + int(math.Floor(t.tCenterY+0.5*hh)) - t.tOriginY 441 | 442 | // final pixel dimensions of area returned 443 | t.pWidth = t.tCountX * tileSize 444 | t.pHeight = t.tCountY * tileSize 445 | 446 | // Pixel location in returned image for center of requested area 447 | t.pCenterX = int((t.tCenterX - float64(t.tOriginX)) * float64(tileSize)) 448 | t.pCenterY = int((t.tCenterY - float64(t.tOriginY)) * float64(tileSize)) 449 | 450 | t.pMinX = t.pCenterX - width/2 451 | t.pMaxX = t.pMinX + width 452 | 453 | return t 454 | } 455 | 456 | // ll2t returns fractional tile index for a lat/lng points 457 | func (t *Transformer) ll2t(ll s2.LatLng) (float64, float64) { 458 | p := t.proj.FromLatLng(ll) 459 | return t.numTiles * (p.X + 0.5), t.numTiles * (1 - (p.Y + 0.5)) 460 | } 461 | 462 | // LatLngToXY transforms a latitude longitude pair into image x, y coordinates. 463 | func (t *Transformer) LatLngToXY(ll s2.LatLng) (float64, float64) { 464 | x, y := t.ll2t(ll) 465 | x = float64(t.pCenterX) + (x-t.tCenterX)*float64(t.tileSize) 466 | y = float64(t.pCenterY) + (y-t.tCenterY)*float64(t.tileSize) 467 | 468 | offset := t.numTiles * float64(t.tileSize) 469 | if x < float64(t.pMinX) { 470 | for x < float64(t.pMinX) { 471 | x = x + offset 472 | } 473 | } else if x >= float64(t.pMaxX) { 474 | for x >= float64(t.pMaxX) { 475 | x = x - offset 476 | } 477 | } 478 | return x, y 479 | } 480 | 481 | // XYToLatLng transforms image x, y coordinates to a latitude longitude pair. 482 | func (t *Transformer) XYToLatLng(x float64, y float64) s2.LatLng { 483 | xx := ((((x - float64(t.pCenterX)) / float64(t.tileSize)) + t.tCenterX) / t.numTiles) - 0.5 484 | yy := 0.5 - (((y-float64(t.pCenterY))/float64(t.tileSize))+t.tCenterY)/t.numTiles 485 | return t.proj.ToLatLng(r2.Point{X: xx, Y: yy}) 486 | } 487 | 488 | // Rect returns an s2.Rect bounding box around the set of tiles described by Transformer. 489 | func (t *Transformer) Rect() (bbox s2.Rect) { 490 | // transform from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Go 491 | invNumTiles := 1.0 / t.numTiles 492 | // Get latitude bounds 493 | n := math.Pi - 2.0*math.Pi*float64(t.tOriginY)*invNumTiles 494 | bbox.Lat.Hi = math.Atan(0.5 * (math.Exp(n) - math.Exp(-n))) 495 | n = math.Pi - 2.0*math.Pi*float64(t.tOriginY+t.tCountY)*invNumTiles 496 | bbox.Lat.Lo = math.Atan(0.5 * (math.Exp(n) - math.Exp(-n))) 497 | // Get longtitude bounds, much easier 498 | bbox.Lng.Lo = float64(t.tOriginX)*invNumTiles*2.0*math.Pi - math.Pi 499 | bbox.Lng.Hi = float64(t.tOriginX+t.tCountX)*invNumTiles*2.0*math.Pi - math.Pi 500 | return bbox 501 | } 502 | 503 | // Render actually renders the map image including all map objects (markers, paths, areas) 504 | func (m *Context) Render() (image.Image, error) { 505 | zoom, center, err := m.determineZoomCenter() 506 | if err != nil { 507 | return nil, err 508 | } 509 | 510 | tileSize := m.tileProvider.TileSize 511 | trans := newTransformer(m.width, m.height, zoom, center, tileSize) 512 | img := image.NewRGBA(image.Rect(0, 0, trans.pWidth, trans.pHeight)) 513 | gc := gg.NewContextForRGBA(img) 514 | if m.background != nil { 515 | draw.Draw(img, img.Bounds(), &image.Uniform{m.background}, image.Point{}, draw.Src) 516 | } 517 | 518 | // fetch and draw tiles to img 519 | layers := []*TileProvider{m.tileProvider} 520 | if m.overlays != nil { 521 | layers = append(layers, m.overlays...) 522 | } 523 | 524 | for _, layer := range layers { 525 | if err := m.renderLayer(gc, zoom, trans, tileSize, layer); err != nil { 526 | return nil, err 527 | } 528 | } 529 | 530 | // draw map objects 531 | for _, object := range m.objects { 532 | object.Draw(gc, trans) 533 | } 534 | 535 | // crop image 536 | croppedImg := image.NewRGBA(image.Rect(0, 0, int(m.width), int(m.height))) 537 | draw.Draw(croppedImg, image.Rect(0, 0, int(m.width), int(m.height)), 538 | img, image.Point{trans.pCenterX - int(m.width)/2, trans.pCenterY - int(m.height)/2}, 539 | draw.Src) 540 | 541 | // draw attribution 542 | attribution := m.Attribution() 543 | if attribution == "" { 544 | return croppedImg, nil 545 | } 546 | lines := strings.Split(attribution, "\n") 547 | lineHeight := 0.0 548 | for _, line := range lines { 549 | _, h := gc.MeasureString(line) 550 | if h > float64(lineHeight) { 551 | lineHeight = h 552 | } 553 | } 554 | margin := 2.0 555 | spacing := 2.0 556 | boxHeight := lineHeight*float64(len(lines)) + 2*margin + spacing*float64(len(lines)-1) 557 | gc = gg.NewContextForRGBA(croppedImg) 558 | gc.SetRGBA(0.0, 0.0, 0.0, 0.5) 559 | gc.DrawRectangle(0.0, float64(m.height)-boxHeight, float64(m.width), boxHeight) 560 | gc.Fill() 561 | gc.SetRGBA(1.0, 1.0, 1.0, 0.75) 562 | y := float64(m.height) - boxHeight 563 | for _, line := range lines { 564 | gc.DrawStringAnchored(line, margin, y, 0, 1) 565 | y += spacing + lineHeight 566 | } 567 | 568 | return croppedImg, nil 569 | } 570 | 571 | // RenderWithTransformer actually renders the map image including all map objects (markers, paths, areas). 572 | // The returned image covers requested area as well as any tiles necessary to cover that area, which may 573 | // be larger than the request. 574 | // 575 | // A Transformer is returned to support image registration with other data. 576 | func (m *Context) RenderWithTransformer() (image.Image, *Transformer, error) { 577 | zoom, center, err := m.determineZoomCenter() 578 | if err != nil { 579 | return nil, nil, err 580 | } 581 | 582 | tileSize := m.tileProvider.TileSize 583 | trans := newTransformer(m.width, m.height, zoom, center, tileSize) 584 | img := image.NewRGBA(image.Rect(0, 0, trans.pWidth, trans.pHeight)) 585 | gc := gg.NewContextForRGBA(img) 586 | if m.background != nil { 587 | draw.Draw(img, img.Bounds(), &image.Uniform{m.background}, image.Point{}, draw.Src) 588 | } 589 | 590 | // fetch and draw tiles to img 591 | layers := []*TileProvider{m.tileProvider} 592 | if m.overlays != nil { 593 | layers = append(layers, m.overlays...) 594 | } 595 | 596 | for _, layer := range layers { 597 | if err := m.renderLayer(gc, zoom, trans, tileSize, layer); err != nil { 598 | return nil, nil, err 599 | } 600 | } 601 | 602 | // draw map objects 603 | for _, object := range m.objects { 604 | object.Draw(gc, trans) 605 | } 606 | 607 | // draw attribution 608 | if m.tileProvider.Attribution == "" { 609 | return img, trans, nil 610 | } 611 | _, textHeight := gc.MeasureString(m.tileProvider.Attribution) 612 | boxHeight := textHeight + 4.0 613 | gc.SetRGBA(0.0, 0.0, 0.0, 0.5) 614 | gc.DrawRectangle(0.0, float64(trans.pHeight)-boxHeight, float64(trans.pWidth), boxHeight) 615 | gc.Fill() 616 | gc.SetRGBA(1.0, 1.0, 1.0, 0.75) 617 | gc.DrawString(m.tileProvider.Attribution, 4.0, float64(m.height)-4.0) 618 | 619 | return img, trans, nil 620 | } 621 | 622 | // RenderWithBounds actually renders the map image including all map objects (markers, paths, areas). 623 | // The returned image covers requested area as well as any tiles necessary to cover that area, which may 624 | // be larger than the request. 625 | // 626 | // Specific bounding box of returned image is provided to support image registration with other data 627 | func (m *Context) RenderWithBounds() (image.Image, s2.Rect, error) { 628 | img, trans, err := m.RenderWithTransformer() 629 | if err != nil { 630 | return nil, s2.Rect{}, err 631 | 632 | } 633 | return img, trans.Rect(), nil 634 | } 635 | 636 | func (m *Context) renderLayer(gc *gg.Context, zoom int, trans *Transformer, tileSize int, provider *TileProvider) error { 637 | if provider.IsNone() { 638 | return nil 639 | } 640 | 641 | var wg sync.WaitGroup 642 | tiles := (1 << uint(zoom)) 643 | fetchedTiles := make(chan *Tile) 644 | t := NewTileFetcher(provider, m.cache, m.online) 645 | if m.userAgent != "" { 646 | t.SetUserAgent(m.userAgent) 647 | } 648 | 649 | go func() { 650 | for xx := 0; xx < trans.tCountX; xx++ { 651 | x := trans.tOriginX + xx 652 | if x < 0 { 653 | x = x + tiles 654 | } else if x >= tiles { 655 | x = x - tiles 656 | } 657 | if x < 0 || x >= tiles { 658 | log.Printf("Skipping out of bounds tile column %d/?", x) 659 | continue 660 | } 661 | for yy := 0; yy < trans.tCountY; yy++ { 662 | y := trans.tOriginY + yy 663 | if y < 0 || y >= tiles { 664 | log.Printf("Skipping out of bounds tile %d/%d", x, y) 665 | continue 666 | } 667 | wg.Add(1) 668 | tile := &Tile{Zoom: zoom, X: x, Y: y} 669 | go func(wg *sync.WaitGroup, tile *Tile, xx, yy int) { 670 | defer wg.Done() 671 | if err := t.Fetch(tile); err == nil { 672 | tile.X = xx * tileSize 673 | tile.Y = yy * tileSize 674 | fetchedTiles <- tile 675 | } else if err == errTileNotFound && provider.IgnoreNotFound { 676 | log.Printf("Error downloading tile file: %s (Ignored)", err) 677 | } else { 678 | log.Printf("Error downloading tile file: %s", err) 679 | } 680 | }(&wg, tile, xx, yy) 681 | } 682 | } 683 | wg.Wait() 684 | close(fetchedTiles) 685 | }() 686 | 687 | for tile := range fetchedTiles { 688 | gc.DrawImage(tile.Img, tile.X, tile.Y) 689 | } 690 | 691 | return nil 692 | } 693 | --------------------------------------------------------------------------------