diff --git a/carto/README.md b/carto/README.md new file mode 100644 index 00000000..f2d6015d --- /dev/null +++ b/carto/README.md @@ -0,0 +1,81 @@ +# `carto` package + +[![Documentation](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat)](https://pkg.go.dev/github.com/peterstace/simplefeatures/carto?tab=doc) + +Package carto provides cartography functionality for working with and making +maps. + +This includes: + + - Various projections between angular coordinates (longitude and latitude) + and planar coordinates (x and y). + + - Earth radius definitions. + +See +[godoc](https://pkg.go.dev/github.com/peterstace/simplefeatures/carto?tab=doc) +for the full package documentation. + +--- + +The following section shows supported projections. The code used to generate +the images in this section can be found +[here](https://github.com/peterstace/simplefeatures/tree/master/internal/cartodemo). + +[**Equirectangular projection**](https://en.wikipedia.org/wiki/Equirectangular_projection) + +Standard parallels are set to 36°N and 36°S. This configuration of the +Equirectangular projection is also known as the Marinus (of Tyre) projection. + +![Equirectangular projection](../internal/cartodemo/testdata/marinus.png) + +[**Web Mercator projection**](https://en.wikipedia.org/wiki/Web_Mercator_projection) + +This is the full zoom 0 tile of the Web Mercator projection. + +![Web Mercator projection](../internal/cartodemo/testdata/web_mercator.png) + +[**Lambert Cylindrical Equal Area projection**](https://en.wikipedia.org/wiki/Lambert_cylindrical_equal-area_projection) + +The central meridian is set to 0°E. + +![Lambert Cylindrical Equal Area projection](../internal/cartodemo/testdata/lambert_cylindrical_equal_area.png) + +[**Sinusoidal projection**](https://en.wikipedia.org/wiki/Sinusoidal_projection) + +The central meridian is set to 0°E. + +![Sinusoidal projection](../internal/cartodemo/testdata/sinusoidal.png) + +[**Orthographic projection**](https://en.wikipedia.org/wiki/Orthographic_projection) + +Centered on North America at 45°N, 105°W. + +![Orthographic projection](../internal/cartodemo/testdata/orthographic_north_america.png) + +[**Azimuthal Equidistant projection**](https://en.wikipedia.org/wiki/Azimuthal_equidistant_projection) + +Centered at Sydney, Australia at 151°E, 34°S. + +![Azimuthal Equidistant projection](../internal/cartodemo/testdata/azimuthal_equidistant_sydney.png) + +[**Equidistant Conic projection**](https://en.wikipedia.org/wiki/Equidistant_conic_projection) + +Standard parallels are set to 30°N and 60°N. The central meridian is set to +0°E. + +![Equidistant Conic projection](../internal/cartodemo/testdata/equidistant_conic.png) + +[**Albers Equal Area Conic projection**](https://en.wikipedia.org/wiki/Albers_projection) + +Standard parallels are set to 30°N and 60°N. The central meridian is set to +0°E. + +![Albers Equal Area Conic projection](../internal/cartodemo/testdata/albers_equal_area_conic.png) + +[**Lambert Conformal Conic projection**](https://en.wikipedia.org/wiki/Lambert_conformal_conic_projection) + +Standard parallels are set to 30°N and 60°N. The central meridian is set to +0°E. + +![Lambert Conformal Conic projection](../internal/cartodemo/testdata/lambert_conformal_conic.png) diff --git a/go.mod b/go.mod index d6fb731a..5f04eadb 100644 --- a/go.mod +++ b/go.mod @@ -5,3 +5,5 @@ go 1.17 retract v0.45.0 // Due to bug: https://github.com/peterstace/simplefeatures/pull/554 require github.com/lib/pq v1.1.1 + +require golang.org/x/image v0.23.0 // indirect diff --git a/go.sum b/go.sum index c731fe1f..55bfa55f 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= diff --git a/internal/cartodemo/cartodemo_test.go b/internal/cartodemo/cartodemo_test.go new file mode 100644 index 00000000..f1938b49 --- /dev/null +++ b/internal/cartodemo/cartodemo_test.go @@ -0,0 +1,440 @@ +// Package cartodemo_test contains demo code for the +// github.com/peterstace/simplefeatures/carto package. +// +// It's provided as a set of executable tests. +package cartodemo_test + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "image" + "image/color" + "image/draw" + "image/png" + "io" + "math" + "os" + "testing" + + "github.com/peterstace/simplefeatures/carto" + "github.com/peterstace/simplefeatures/geom" + "github.com/peterstace/simplefeatures/internal/cartodemo/rasterize" +) + +const ( + pxWide = 512 + pxPadding = 2 + earthRadius = 6371000 + earthCircum = 2 * pi * earthRadius + pi = math.Pi +) + +var fullWorldMask = rectangle( + xy(-180, +90), + xy(+180, -90), +) + +func xy(x, y float64) geom.XY { + return geom.XY{X: x, Y: y} +} + +func TestDrawMapEquirectangularMarinus(t *testing.T) { + p := carto.NewEquirectangular(earthRadius) + p.SetStandardParallels(36) + cos36 := math.Cos(36 * pi / 180) + f := &worldProjectionFixture{ + proj: p.Forward, + worldMask: fullWorldMask, + mapMask: rectangle( + xy(-0.5*earthCircum*cos36, +0.25*earthCircum), + xy(+0.5*earthCircum*cos36, -0.25*earthCircum), + ), + } + f.build(t, "testdata/marinus.png") +} + +func TestDrawMapWebMercator(t *testing.T) { + f := &worldProjectionFixture{ + proj: carto.NewWebMercator(0).Forward, + worldMask: fullWorldMask, + mapMask: rectangle(xy(0, 0), xy(1, 1)), + mapFlipY: true, + } + f.build(t, "testdata/web_mercator.png") +} + +func TestDrawMapLambertCylindricalEqualArea(t *testing.T) { + f := &worldProjectionFixture{ + proj: carto.NewLambertCylindricalEqualArea(earthRadius).Forward, + worldMask: fullWorldMask, + mapMask: rectangle( + xy(-0.5*earthCircum, +0.25*earthCircum*2/pi), + xy(+0.5*earthCircum, -0.25*earthCircum*2/pi), + ), + } + f.build(t, "testdata/lambert_cylindrical_equal_area.png") +} + +func TestDrawMapSinusoidal(t *testing.T) { + var lhs, rhs []float64 + for lat := -90.0; lat <= 90; lat++ { + lhs = append(lhs, + -0.5*earthCircum*math.Cos(lat*pi/180), + lat/90*0.25*earthCircum, + ) + rhs = append(rhs, + +0.5*earthCircum*math.Cos(lat*pi/180), + -lat/90*0.25*earthCircum, + ) + } + all := append(lhs, rhs...) //nolint:gocritic + mapMask := geom.NewPolygonXY(all) + + f := &worldProjectionFixture{ + proj: carto.NewSinusoidal(earthRadius).Forward, + worldMask: fullWorldMask, + mapMask: mapMask, + } + f.build(t, "testdata/sinusoidal.png") +} + +func TestDrawMapOrthographicNorthAmerica(t *testing.T) { + const centralMeridian = -105 + var worldMaskCoords []float64 + latAtLon := func(lon float64) float64 { + return 45 * math.Cos((285+lon)*pi/180) + } + for lon := -180.0; lon <= 180.0; lon++ { + lat := latAtLon(lon) + worldMaskCoords = append(worldMaskCoords, lon, lat) + } + worldMaskCoords = append( + worldMaskCoords, + 180, latAtLon(180), + 180, 90, + -180, 90, + -180, latAtLon(-180), + ) + worldMask := geom.NewPolygonXY(worldMaskCoords) + + proj := carto.NewOrthographic(earthRadius) + proj.SetCenter(geom.XY{X: centralMeridian, Y: 45}) + + f := &worldProjectionFixture{ + proj: proj.Forward, + worldMask: worldMask, + mapMask: circle(xy(0, 0), earthRadius), + } + f.build(t, "testdata/orthographic_north_america.png") +} + +func TestDrawMapAzimuthalEquidistantSydney(t *testing.T) { + p := carto.NewAzimuthalEquidistant(earthRadius) + p.SetCenter(geom.XY{X: 151, Y: -34}) + f := &worldProjectionFixture{ + proj: p.Forward, + worldMask: fullWorldMask, + mapMask: circle(xy(0, 0), earthCircum/2), + } + f.build(t, "testdata/azimuthal_equidistant_sydney.png") +} + +const ( + stdParallel1 = 30 + stdParallel2 = 60 +) + +func TestDrawEquidistantConic(t *testing.T) { + p := carto.NewEquidistantConic(earthRadius) + p.SetStandardParallels(stdParallel1, stdParallel2) + + const eps = 0.1 + mapMask := rectangle( + xy(-180+eps, -90+eps), + xy(+180-eps, +90-eps), + ) + mapMask = mapMask.Densify(0.1) + mapMask = mapMask.TransformXY(p.Forward) + + f := &worldProjectionFixture{ + proj: p.Forward, + worldMask: fullWorldMask, + mapMask: mapMask, + } + f.build(t, "testdata/equidistant_conic.png") +} + +func TestDrawLambertConformalConic(t *testing.T) { + p := carto.NewLambertConformalConic(earthRadius) + p.SetStandardParallels(stdParallel1, stdParallel2) + + const ( + minLat = -45 + maxLat = 90 + ) + worldMask := rectangle( + xy(-180, minLat), + xy(+180, maxLat), + ) + mapMask := worldMask.Densify(0.1).TransformXY(p.Forward) + + f := &worldProjectionFixture{ + proj: p.Forward, + worldMask: worldMask, + mapMask: mapMask, + } + f.build(t, "testdata/lambert_conformal_conic.png") +} + +func TestDrawAlbersEqualAreaConic(t *testing.T) { + p := carto.NewAlbersEqualAreaConic(earthRadius) + p.SetStandardParallels(stdParallel1, stdParallel2) + + worldMask := rectangle( + xy(-180, -90), + xy(+180, +90), + ) + mapMask := worldMask.Densify(0.1).TransformXY(p.Forward) + + f := &worldProjectionFixture{ + proj: p.Forward, + worldMask: worldMask, + mapMask: mapMask, + } + f.build(t, "testdata/albers_equal_area_conic.png") +} + +type worldProjectionFixture struct { + proj func(geom.XY) geom.XY // Convert lon/lat to projected coordinates. + worldMask geom.Polygon // Parts of the world (in lon/lat) to include. + mapMask geom.Polygon // Parts of the map (in projected coordinates) to include. + mapFlipY bool // True iff the map coordinates increase from top to bottom. +} + +func (f *worldProjectionFixture) build(t *testing.T, outputPath string) { + t.Helper() + var ( + waterColor = color.RGBA{R: 144, G: 218, B: 238, A: 255} + landColor = color.RGBA{R: 188, G: 236, B: 216, A: 255} + iceColor = color.RGBA{R: 252, G: 251, B: 250, A: 255} + + land = loadGeom(t, "testdata/ne_50m_land.geojson.gz") + lakes = loadGeom(t, "testdata/ne_50m_lakes.geojson.gz") + glaciers = loadGeom(t, "testdata/ne_50m_glaciated_areas.geojson.gz") + iceshelves = loadGeom(t, "testdata/ne_50m_antarctic_ice_shelves_polys.geojson.gz") + ) + + f.worldMask = f.worldMask.Densify(1) + for _, g := range []*geom.Geometry{&land, &lakes, &glaciers, &iceshelves} { + clipped, err := geom.Intersection(*g, f.worldMask.AsGeometry()) + *g = clipped + expectNoErr(t, err) + } + + var graticules []geom.LineString + for lon := -180.0; lon < 180; lon += 30 { + grat := geom.NewLineStringXY(lon, -90, lon, +90) + grat = grat.Densify(0.1) + graticules = append(graticules, grat) + } + for lat := -60.0; lat <= 60; lat += 30 { + grat := geom.NewLineStringXY(-180, lat, +180, lat) + grat = grat.Densify(0.1) + graticules = append(graticules, grat) + } + + var clippedGraticules []geom.LineString + for i := range graticules { + clipped, err := geom.Intersection(graticules[i].AsGeometry(), f.worldMask.AsGeometry()) + clippedGraticules = append(clippedGraticules, extractLinearParts(clipped)...) + expectNoErr(t, err) + } + graticules = clippedGraticules + + mapMaskEnv := f.mapMask.Envelope() + mapMaskRatio := mapMaskEnv.Width() / mapMaskEnv.Height() + pxHigh := int(pxWide / mapMaskRatio) + mapMaskCenter, ok := mapMaskEnv.Center().XY() + expectTrue(t, ok) + + mapUnitsPerPixel := f.mapMask.Envelope().Width() / float64(pxWide) + + imgDims := geom.XY{X: float64(pxWide), Y: float64(pxHigh)} + mapCoordsToImgCoords := func(mapCoords geom.XY) geom.XY { + imgCoords := mapCoords. + Sub(mapMaskCenter). + Scale(1 / mapUnitsPerPixel). + Add(imgDims.Scale(0.5)) + if !f.mapFlipY { + imgCoords.Y = imgDims.Y - imgCoords.Y + } + imgCoords = imgCoords.Add(geom.XY{X: pxPadding, Y: pxPadding}) + return imgCoords + } + lonLatToImgCoords := func(lonLat geom.XY) geom.XY { + mapCoords := f.proj(lonLat) + return mapCoordsToImgCoords(mapCoords) + } + + for _, g := range []*geom.Geometry{&land, &lakes, &glaciers, &iceshelves} { + *g = g.TransformXY(lonLatToImgCoords) + } + for i := range graticules { + graticules[i] = graticules[i].TransformXY(lonLatToImgCoords) + } + + imgRect := image.Rect(0, 0, pxWide+2*pxPadding, pxHigh+2*pxPadding) + rast := rasterize.NewRasterizer(imgRect.Dx(), imgRect.Dy()) + + mapMaskImage := image.NewAlpha(imgRect) + rast.Reset() + mapMaskInImgCoords := f.mapMask.TransformXY(mapCoordsToImgCoords) + rast.Polygon(mapMaskInImgCoords) + rast.Draw(mapMaskImage, mapMaskImage.Bounds(), image.NewUniform(color.Opaque), image.Point{}) + + img := image.NewRGBA(imgRect) + draw.DrawMask(img, img.Bounds(), image.NewUniform(waterColor), image.Point{}, mapMaskImage, image.Point{}, draw.Src) + + rasterisePolygons := func(g geom.Geometry) { + for _, p := range extractPolygonalParts(g) { + rast.Polygon(p) + } + } + + rast.Reset() + rasterisePolygons(land) + rast.Draw(img, img.Bounds(), image.NewUniform(landColor), image.Point{}) + + rast.Reset() + rasterisePolygons(lakes) + rast.Draw(img, img.Bounds(), image.NewUniform(waterColor), image.Point{}) + + rast.Reset() + rasterisePolygons(glaciers) + rasterisePolygons(iceshelves) + rast.Draw(img, img.Bounds(), image.NewUniform(iceColor), image.Point{}) + + for _, line := range graticules { + rast.Reset() + rast.LineString(line) + rast.Draw(img, img.Bounds(), image.NewUniform(color.Gray{Y: 0xb0}), image.Point{}) + } + + rast.Reset() + mapOutline := mapMaskInImgCoords.Boundary() + rast.MultiLineString(mapOutline) + rast.Draw(img, img.Bounds(), image.NewUniform(color.Black), image.Point{}) + + err := os.WriteFile(outputPath, imageToPNG(t, img), 0o600) + expectNoErr(t, err) +} + +func imageToPNG(t *testing.T, img image.Image) []byte { + t.Helper() + buf := new(bytes.Buffer) + err := png.Encode(buf, img) + expectNoErr(t, err) + return buf.Bytes() +} + +func loadGeom(t *testing.T, filename string) geom.Geometry { + t.Helper() + zippedBuf, err := os.ReadFile(filename) + expectNoErr(t, err) + + gzipReader, err := gzip.NewReader(bytes.NewReader(zippedBuf)) + expectNoErr(t, err) + + unzippedBuf, err := io.ReadAll(gzipReader) + expectNoErr(t, err) + + // TODO: There is currently no way to disable a GeoJSON GeometryCollection + // without validation directly. See + // https://github.com/peterstace/simplefeatures/issues/638. For now, we + // unmarshal the GeoJSON FeatureCollection "manually" to avoid validation. + var collection struct { + Features []struct { + Geometry json.RawMessage `json:"geometry"` + } `json:"features"` + } + err = json.Unmarshal(unzippedBuf, &collection) + expectNoErr(t, err) + var gs []geom.Geometry + for _, rawFeat := range collection.Features { + g, err := geom.UnmarshalGeoJSON(rawFeat.Geometry, geom.NoValidate{}) + expectNoErr(t, err) + if err := g.Validate(); err != nil { + continue + } + gs = append(gs, g) + } + + all, err := geom.UnionMany(gs) + expectNoErr(t, err) + + return all +} + +func extractPolygonalParts(g geom.Geometry) []geom.Polygon { + switch gt := g.Type(); gt { + case geom.TypeGeometryCollection: + var ps []geom.Polygon + for _, g := range g.Dump() { + ps = append(ps, extractPolygonalParts(g)...) + } + return ps + case geom.TypeMultiPolygon: + return g.MustAsMultiPolygon().Dump() + case geom.TypePolygon: + return []geom.Polygon{g.MustAsPolygon()} + default: + return nil + } +} + +func extractLinearParts(g geom.Geometry) []geom.LineString { + switch gt := g.Type(); gt { + case geom.TypeGeometryCollection: + var lss []geom.LineString + for _, g := range g.Dump() { + lss = append(lss, extractLinearParts(g)...) + } + return lss + case geom.TypeMultiLineString: + return g.MustAsMultiLineString().Dump() + case geom.TypeLineString: + return []geom.LineString{g.MustAsLineString()} + default: + return nil + } +} + +func circle(c geom.XY, r float64) geom.Polygon { + var coords []float64 + for i := 0.0; i <= 360; i++ { + coords = append(coords, + c.X+r*math.Cos(i*pi/180), + c.Y+r*math.Sin(i*pi/180), + ) + } + return geom.NewPolygonXY(coords) +} + +func rectangle(tl, br geom.XY) geom.Polygon { + return geom.NewEnvelope(tl, br).AsGeometry().MustAsPolygon() +} + +func expectNoErr(tb testing.TB, err error) { + tb.Helper() + if err != nil { + tb.Fatalf("unexpected error: %v", err) + } +} + +func expectTrue(tb testing.TB, b bool) { + tb.Helper() + if !b { + tb.Fatalf("expected true, got false") + } +} diff --git a/internal/cartodemo/rasterize/docs.go b/internal/cartodemo/rasterize/docs.go new file mode 100644 index 00000000..e68bf112 --- /dev/null +++ b/internal/cartodemo/rasterize/docs.go @@ -0,0 +1,4 @@ +// Package rasterize rasterizes simplefeatures geometries to raster images. +// +// It is currently experimental/unfinished, so is internal only. +package rasterize diff --git a/internal/cartodemo/rasterize/draw_test.go b/internal/cartodemo/rasterize/draw_test.go new file mode 100644 index 00000000..5adc3acc --- /dev/null +++ b/internal/cartodemo/rasterize/draw_test.go @@ -0,0 +1,24 @@ +package rasterize_test + +import ( + "image" + "image/color" + "os" + "testing" + + "github.com/peterstace/simplefeatures/geom" + "github.com/peterstace/simplefeatures/internal/cartodemo/rasterize" +) + +func TestDrawLine(t *testing.T) { + g, err := geom.UnmarshalWKT("LINESTRING(4 4, 12 8, 4 12)") + expectNoErr(t, err) + + img := image.NewRGBA(image.Rect(0, 0, 16, 16)) + rast := rasterize.NewRasterizer(16, 16) + rast.LineString(g.MustAsLineString()) + rast.Draw(img, img.Bounds(), image.NewUniform(color.Black), image.Point{}) + + err = os.WriteFile("testdata/line.png", imageToPNG(t, img), 0o600) + expectNoErr(t, err) +} diff --git a/internal/cartodemo/rasterize/rasterizer.go b/internal/cartodemo/rasterize/rasterizer.go new file mode 100644 index 00000000..9c1908d8 --- /dev/null +++ b/internal/cartodemo/rasterize/rasterizer.go @@ -0,0 +1,95 @@ +package rasterize + +import ( + "image" + "image/draw" + + "github.com/peterstace/simplefeatures/geom" + "golang.org/x/image/vector" +) + +type Rasterizer struct { + rast *vector.Rasterizer +} + +func NewRasterizer(widthPx, heightPx int) *Rasterizer { + return &Rasterizer{rast: vector.NewRasterizer(widthPx, heightPx)} +} + +func (r *Rasterizer) Reset() { + b := r.rast.Bounds() + r.rast.Reset(b.Dx(), b.Dy()) +} + +func (r *Rasterizer) Draw(dst draw.Image, rec image.Rectangle, src image.Image, sp image.Point) { + r.rast.Draw(dst, rec, src, sp) +} + +func (r *Rasterizer) LineString(ls geom.LineString) { + const strokeWidth = 1.0 // TODO: Make stroke width configurable. + + seq := ls.Coordinates() + for i := 0; i+1 < seq.Length(); i++ { + p0 := seq.GetXY(i) + p1 := seq.GetXY(i + 1) + if p0 == p1 { + continue + } + + // TODO: This is a pretty basic/stupid way to draw a line. Consider + // something more accurate, and possibly faster. + mainAxis := p1.Sub(p0) + sideAxis := rotateCCW90(mainAxis).Scale(0.5 * strokeWidth / mainAxis.Length()) + + v0 := p0.Add(sideAxis) + v1 := p1.Add(sideAxis) + v2 := p1.Sub(sideAxis) + v3 := p0.Sub(sideAxis) + + r.rast.MoveTo(float32(v0.X), float32(v0.Y)) + r.rast.LineTo(float32(v1.X), float32(v1.Y)) + r.rast.LineTo(float32(v2.X), float32(v2.Y)) + r.rast.LineTo(float32(v3.X), float32(v3.Y)) + r.rast.ClosePath() + } +} + +func (r *Rasterizer) MultiLineString(mls geom.MultiLineString) { + for _, ls := range mls.Dump() { + r.LineString(ls) + } +} + +func (r *Rasterizer) Polygon(p geom.Polygon) { + // TODO: Support holes. + ext := p.ExteriorRing() + seq := ext.Coordinates() + n := seq.Length() + if n == 0 { + return + } + r.moveTo(seq.GetXY(0)) + for i := 1; i < n; i++ { + r.lineTo(seq.GetXY(i)) + } + r.rast.ClosePath() // Usually not necessary, but just in case. +} + +func (r *Rasterizer) MultiPolygon(mp geom.MultiPolygon) { + for _, p := range mp.Dump() { + r.Polygon(p) + } +} + +func (r *Rasterizer) moveTo(pt geom.XY) { + r.rast.MoveTo(float32(pt.X), float32(pt.Y)) +} + +func (r *Rasterizer) lineTo(pt geom.XY) { + r.rast.LineTo(float32(pt.X), float32(pt.Y)) +} + +// TODO: This is duplicated from geom/xy.go. Could be a better solution? +func rotateCCW90(v geom.XY) geom.XY { + return geom.XY{X: -v.Y, Y: v.X} +} diff --git a/internal/cartodemo/rasterize/rasterizer_test.go b/internal/cartodemo/rasterize/rasterizer_test.go new file mode 100644 index 00000000..6a051e8a --- /dev/null +++ b/internal/cartodemo/rasterize/rasterizer_test.go @@ -0,0 +1,26 @@ +package rasterize_test + +import ( + "image" + "image/color" + "os" + "testing" + + "github.com/peterstace/simplefeatures/geom" + "github.com/peterstace/simplefeatures/internal/cartodemo/rasterize" +) + +func TestRasterizer(t *testing.T) { + const sz = 16 + rast := rasterize.NewRasterizer(sz, sz) + + ls, err := geom.UnmarshalWKT("LINESTRING(4 4, 12 8, 4 12)") + expectNoErr(t, err) + rast.LineString(ls.MustAsLineString()) + + img := image.NewRGBA(image.Rect(0, 0, sz, sz)) + rast.Draw(img, img.Bounds(), image.NewUniform(color.Black), image.Point{}) + + err = os.WriteFile("testdata/line.png", imageToPNG(t, img), 0o600) + expectNoErr(t, err) +} diff --git a/internal/cartodemo/rasterize/testdata/line.png b/internal/cartodemo/rasterize/testdata/line.png new file mode 100644 index 00000000..382a646b Binary files /dev/null and b/internal/cartodemo/rasterize/testdata/line.png differ diff --git a/internal/cartodemo/rasterize/util_test.go b/internal/cartodemo/rasterize/util_test.go new file mode 100644 index 00000000..29539c1e --- /dev/null +++ b/internal/cartodemo/rasterize/util_test.go @@ -0,0 +1,23 @@ +package rasterize_test + +import ( + "bytes" + "image" + "image/png" + "testing" +) + +func expectNoErr(tb testing.TB, err error) { + tb.Helper() + if err != nil { + tb.Fatalf("unexpected error: %v", err) + } +} + +func imageToPNG(t *testing.T, img image.Image) []byte { + t.Helper() + buf := new(bytes.Buffer) + err := png.Encode(buf, img) + expectNoErr(t, err) + return buf.Bytes() +} diff --git a/internal/cartodemo/testdata/.gitignore b/internal/cartodemo/testdata/.gitignore new file mode 100644 index 00000000..e33609d2 --- /dev/null +++ b/internal/cartodemo/testdata/.gitignore @@ -0,0 +1 @@ +*.png diff --git a/internal/cartodemo/testdata/albers_equal_area_conic.png b/internal/cartodemo/testdata/albers_equal_area_conic.png new file mode 100644 index 00000000..54a9be0f Binary files /dev/null and b/internal/cartodemo/testdata/albers_equal_area_conic.png differ diff --git a/internal/cartodemo/testdata/azimuthal_equidistant_sydney.png b/internal/cartodemo/testdata/azimuthal_equidistant_sydney.png new file mode 100644 index 00000000..c4fa9fc6 Binary files /dev/null and b/internal/cartodemo/testdata/azimuthal_equidistant_sydney.png differ diff --git a/internal/cartodemo/testdata/equidistant_conic.png b/internal/cartodemo/testdata/equidistant_conic.png new file mode 100644 index 00000000..98511132 Binary files /dev/null and b/internal/cartodemo/testdata/equidistant_conic.png differ diff --git a/internal/cartodemo/testdata/lambert_conformal_conic.png b/internal/cartodemo/testdata/lambert_conformal_conic.png new file mode 100644 index 00000000..d6c81b26 Binary files /dev/null and b/internal/cartodemo/testdata/lambert_conformal_conic.png differ diff --git a/internal/cartodemo/testdata/lambert_cylindrical_equal_area.png b/internal/cartodemo/testdata/lambert_cylindrical_equal_area.png new file mode 100644 index 00000000..19c9d434 Binary files /dev/null and b/internal/cartodemo/testdata/lambert_cylindrical_equal_area.png differ diff --git a/internal/cartodemo/testdata/marinus.png b/internal/cartodemo/testdata/marinus.png new file mode 100644 index 00000000..6fee06f0 Binary files /dev/null and b/internal/cartodemo/testdata/marinus.png differ diff --git a/internal/cartodemo/testdata/ne_50m_antarctic_ice_shelves_polys.geojson.gz b/internal/cartodemo/testdata/ne_50m_antarctic_ice_shelves_polys.geojson.gz new file mode 100644 index 00000000..fae13b30 Binary files /dev/null and b/internal/cartodemo/testdata/ne_50m_antarctic_ice_shelves_polys.geojson.gz differ diff --git a/internal/cartodemo/testdata/ne_50m_glaciated_areas.geojson.gz b/internal/cartodemo/testdata/ne_50m_glaciated_areas.geojson.gz new file mode 100644 index 00000000..5caf4deb Binary files /dev/null and b/internal/cartodemo/testdata/ne_50m_glaciated_areas.geojson.gz differ diff --git a/internal/cartodemo/testdata/ne_50m_lakes.geojson.gz b/internal/cartodemo/testdata/ne_50m_lakes.geojson.gz new file mode 100644 index 00000000..52150c6d Binary files /dev/null and b/internal/cartodemo/testdata/ne_50m_lakes.geojson.gz differ diff --git a/internal/cartodemo/testdata/ne_50m_land.geojson.gz b/internal/cartodemo/testdata/ne_50m_land.geojson.gz new file mode 100644 index 00000000..787d6ed1 Binary files /dev/null and b/internal/cartodemo/testdata/ne_50m_land.geojson.gz differ diff --git a/internal/cartodemo/testdata/orthographic_north_america.png b/internal/cartodemo/testdata/orthographic_north_america.png new file mode 100644 index 00000000..ae481e0e Binary files /dev/null and b/internal/cartodemo/testdata/orthographic_north_america.png differ diff --git a/internal/cartodemo/testdata/sinusoidal.png b/internal/cartodemo/testdata/sinusoidal.png new file mode 100644 index 00000000..0fb25d0a Binary files /dev/null and b/internal/cartodemo/testdata/sinusoidal.png differ diff --git a/internal/cartodemo/testdata/web_mercator.png b/internal/cartodemo/testdata/web_mercator.png new file mode 100644 index 00000000..41959d2c Binary files /dev/null and b/internal/cartodemo/testdata/web_mercator.png differ