feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -24,9 +24,3 @@ func ShortestCircleArc(circle []GeoPoint, from, to GeoPoint) []GeoPoint {
|
||||
func SameGeoPoint(a, b GeoPoint) bool {
|
||||
return geodata.SameGeoPoint(a, b)
|
||||
}
|
||||
|
||||
// VisibleHemispherePolygons 返回以指定中心为中心的半球多边形 / VisibleHemispherePolygons returns polygons for the hemisphere centered on
|
||||
// 中心的半球多边形,并裁剪到请求的地图投影 / center, clipped to the requested map projection.
|
||||
func VisibleHemispherePolygons(center GeoPoint, projection Projection, samples int) [][]GeoPoint {
|
||||
return geodata.VisibleHemispherePolygons(center, projection, samples)
|
||||
}
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
// Command mapgen 将 Natural Earth 陆地多边形转换为供共享世界地图渲染器使用的紧凑 SVG 路径 /
|
||||
// Command mapgen converts Natural Earth land polygons into compact SVG paths for the shared world-map renderer.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
naturalEarthURL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_50m_land.geojson"
|
||||
naturalEarthSHA = "e874b27a51d146452be360cafb3cc50c86001074a67d534113e6534682f9826b"
|
||||
worldWidth = 5760
|
||||
worldHeight = 2880
|
||||
polarSize = 2880
|
||||
)
|
||||
|
||||
type featureCollection struct {
|
||||
Features []feature `json:"features"`
|
||||
}
|
||||
|
||||
type feature struct {
|
||||
Geometry geometry `json:"geometry"`
|
||||
}
|
||||
|
||||
type geometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates json.RawMessage `json:"coordinates"`
|
||||
}
|
||||
|
||||
type geoPoint struct {
|
||||
lon float64
|
||||
lat float64
|
||||
}
|
||||
|
||||
type point struct {
|
||||
x int
|
||||
y int
|
||||
}
|
||||
|
||||
type projection struct {
|
||||
name string
|
||||
hemisphere int
|
||||
project func(geoPoint) point
|
||||
}
|
||||
|
||||
func main() {
|
||||
source := flag.String("source", "", "optional local Natural Earth GeoJSON source")
|
||||
outputDir := flag.String("output-dir", ".", "generated SVG path destination directory")
|
||||
flag.Parse()
|
||||
|
||||
data, err := readSource(*source)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := verifySource(data); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
rings, err := decodeRings(data)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
projections := []projection{
|
||||
{name: "land_equirectangular.path", project: projectEquirectangular},
|
||||
{name: "land_north_polar.path", hemisphere: 1, project: func(value geoPoint) point { return projectPolar(value, 1) }},
|
||||
{name: "land_south_polar.path", hemisphere: -1, project: func(value geoPoint) point { return projectPolar(value, -1) }},
|
||||
}
|
||||
for _, current := range projections {
|
||||
path, ringCount, pointCount := buildPath(rings, current)
|
||||
output := filepath.Join(*outputDir, current.name)
|
||||
if err := os.WriteFile(output, path, 0644); err != nil {
|
||||
fatal(fmt.Errorf("write %s: %w", output, err))
|
||||
}
|
||||
fmt.Printf("generated %s: %d rings, %d quantized points, %d bytes\n", output, ringCount, pointCount, len(path))
|
||||
}
|
||||
}
|
||||
|
||||
func readSource(path string) ([]byte, error) {
|
||||
if path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
response, err := client.Get(naturalEarthURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download Natural Earth data: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("download Natural Earth data: %s", response.Status)
|
||||
}
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Natural Earth response: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func verifySource(data []byte) error {
|
||||
sum := sha256.Sum256(data)
|
||||
actual := hex.EncodeToString(sum[:])
|
||||
if actual != naturalEarthSHA {
|
||||
return fmt.Errorf("Natural Earth source checksum = %s, want %s", actual, naturalEarthSHA)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRings(data []byte) ([][]geoPoint, error) {
|
||||
var collection featureCollection
|
||||
if err := json.Unmarshal(data, &collection); err != nil {
|
||||
return nil, fmt.Errorf("decode Natural Earth GeoJSON: %w", err)
|
||||
}
|
||||
var rings [][]geoPoint
|
||||
appendPolygon := func(polygon [][][]float64) {
|
||||
for _, rawRing := range polygon {
|
||||
ring := make([]geoPoint, 0, len(rawRing))
|
||||
for _, coordinate := range rawRing {
|
||||
if len(coordinate) >= 2 {
|
||||
ring = append(ring, geoPoint{lon: coordinate[0], lat: coordinate[1]})
|
||||
}
|
||||
}
|
||||
if len(ring) >= 3 {
|
||||
rings = append(rings, ring)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index, current := range collection.Features {
|
||||
switch current.Geometry.Type {
|
||||
case "Polygon":
|
||||
var polygon [][][]float64
|
||||
if err := json.Unmarshal(current.Geometry.Coordinates, &polygon); err != nil {
|
||||
return nil, fmt.Errorf("decode feature %d polygon: %w", index, err)
|
||||
}
|
||||
appendPolygon(polygon)
|
||||
case "MultiPolygon":
|
||||
var multiPolygon [][][][]float64
|
||||
if err := json.Unmarshal(current.Geometry.Coordinates, &multiPolygon); err != nil {
|
||||
return nil, fmt.Errorf("decode feature %d multipolygon: %w", index, err)
|
||||
}
|
||||
for _, polygon := range multiPolygon {
|
||||
appendPolygon(polygon)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("feature %d has unsupported geometry %q", index, current.Geometry.Type)
|
||||
}
|
||||
}
|
||||
if len(rings) == 0 {
|
||||
return nil, fmt.Errorf("Natural Earth source contains no usable rings")
|
||||
}
|
||||
return rings, nil
|
||||
}
|
||||
|
||||
func buildPath(rings [][]geoPoint, current projection) ([]byte, int, int) {
|
||||
path := make([]byte, 0, 256000)
|
||||
ringCount := 0
|
||||
pointCount := 0
|
||||
for _, sourceRing := range rings {
|
||||
ring := sourceRing
|
||||
if current.hemisphere != 0 {
|
||||
ring = clipHemisphere(ring, current.hemisphere)
|
||||
}
|
||||
projected := projectRing(ring, current)
|
||||
var added int
|
||||
path, added = appendRing(path, projected)
|
||||
if added > 0 {
|
||||
ringCount++
|
||||
pointCount += added
|
||||
}
|
||||
}
|
||||
return path, ringCount, pointCount
|
||||
}
|
||||
|
||||
func clipHemisphere(points []geoPoint, hemisphere int) []geoPoint {
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
inside := func(value geoPoint) bool { return value.lat*float64(hemisphere) >= 0 }
|
||||
intersection := func(a, b geoPoint) geoPoint {
|
||||
dLon := normalizeLongitudeDelta(b.lon - a.lon)
|
||||
fraction := -a.lat / (b.lat - a.lat)
|
||||
return geoPoint{lon: normalizeLongitude(a.lon + fraction*dLon), lat: 0}
|
||||
}
|
||||
result := make([]geoPoint, 0, len(points)+2)
|
||||
previous := points[len(points)-1]
|
||||
previousInside := inside(previous)
|
||||
for _, current := range points {
|
||||
currentInside := inside(current)
|
||||
if currentInside != previousInside {
|
||||
result = append(result, intersection(previous, current))
|
||||
}
|
||||
if currentInside {
|
||||
result = append(result, current)
|
||||
}
|
||||
previous = current
|
||||
previousInside = currentInside
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func projectRing(ring []geoPoint, current projection) []point {
|
||||
if len(ring) < 3 {
|
||||
return nil
|
||||
}
|
||||
if current.hemisphere == 0 {
|
||||
result := make([]point, 0, len(ring))
|
||||
for _, value := range ring {
|
||||
result = appendUniquePoint(result, current.project(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
result := make([]point, 0, len(ring)*2)
|
||||
for index, start := range ring {
|
||||
end := ring[(index+1)%len(ring)]
|
||||
dLon := normalizeLongitudeDelta(end.lon - start.lon)
|
||||
dLat := end.lat - start.lat
|
||||
steps := int(math.Ceil(math.Max(math.Abs(dLon), math.Abs(dLat)) / 1.5))
|
||||
if steps < 1 {
|
||||
steps = 1
|
||||
}
|
||||
for step := 0; step < steps; step++ {
|
||||
fraction := float64(step) / float64(steps)
|
||||
value := geoPoint{
|
||||
lon: normalizeLongitude(start.lon + fraction*dLon),
|
||||
lat: start.lat + fraction*dLat,
|
||||
}
|
||||
result = appendUniquePoint(result, current.project(value))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func appendUniquePoint(points []point, value point) []point {
|
||||
if len(points) == 0 || points[len(points)-1] != value {
|
||||
return append(points, value)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func appendRing(path []byte, points []point) ([]byte, int) {
|
||||
if len(points) > 1 && points[0] == points[len(points)-1] {
|
||||
points = points[:len(points)-1]
|
||||
}
|
||||
if len(points) < 3 {
|
||||
return path, 0
|
||||
}
|
||||
path = append(path, 'M')
|
||||
path = appendNumber(path, points[0].x)
|
||||
path = appendNumber(path, points[0].y)
|
||||
previous := points[0]
|
||||
previousCommand := byte(0)
|
||||
for _, current := range points[1:] {
|
||||
dx := current.x - previous.x
|
||||
dy := current.y - previous.y
|
||||
command := byte('l')
|
||||
if dx == 0 {
|
||||
command = 'v'
|
||||
} else if dy == 0 {
|
||||
command = 'h'
|
||||
}
|
||||
if command != previousCommand {
|
||||
path = append(path, command)
|
||||
previousCommand = command
|
||||
}
|
||||
switch command {
|
||||
case 'h':
|
||||
path = appendNumber(path, dx)
|
||||
case 'v':
|
||||
path = appendNumber(path, dy)
|
||||
default:
|
||||
path = appendNumber(path, dx)
|
||||
path = appendNumber(path, dy)
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
path = append(path, 'z')
|
||||
return path, len(points)
|
||||
}
|
||||
|
||||
func projectEquirectangular(value geoPoint) point {
|
||||
return point{
|
||||
x: int((value.lon+180)/360*worldWidth + 0.5),
|
||||
y: int((90-value.lat)/180*worldHeight + 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
func projectPolar(value geoPoint, hemisphere int) point {
|
||||
radius := (90 - float64(hemisphere)*value.lat) / 90 * polarSize / 2
|
||||
longitude := value.lon * math.Pi / 180
|
||||
return point{
|
||||
x: int(polarSize/2 + radius*math.Sin(longitude) + 0.5),
|
||||
y: int(polarSize/2 - radius*math.Cos(longitude) + 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLongitude(value float64) float64 {
|
||||
value = math.Mod(value+180, 360)
|
||||
if value < 0 {
|
||||
value += 360
|
||||
}
|
||||
return value - 180
|
||||
}
|
||||
|
||||
func normalizeLongitudeDelta(value float64) float64 {
|
||||
return normalizeLongitude(value)
|
||||
}
|
||||
|
||||
func appendNumber(path []byte, value int) []byte {
|
||||
if len(path) > 0 && value >= 0 {
|
||||
last := path[len(path)-1]
|
||||
if last >= '0' && last <= '9' {
|
||||
path = append(path, ' ')
|
||||
}
|
||||
}
|
||||
return strconv.AppendInt(path, int64(value), 10)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "mapgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package svgmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkWriteLandOrthographic(b *testing.B) {
|
||||
frame := Frame{
|
||||
X: 57, Y: 184, Width: 360, Height: 360,
|
||||
Projection: ProjectionOrthographic,
|
||||
CenterLongitude: 104.145, CenterLatitude: -1.5,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for index := 0; index < b.N; index++ {
|
||||
var builder strings.Builder
|
||||
frame.WriteLand(&builder, "clip")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWriteLandCenteredEquirectangular(b *testing.B) {
|
||||
frame := Frame{
|
||||
X: 57, Y: 184, Width: 360, Height: 180,
|
||||
Projection: ProjectionEquirectangular, CenterLongitude: -104.145,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for index := 0; index < b.N; index++ {
|
||||
var builder strings.Builder
|
||||
frame.WriteLand(&builder, "clip")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package svgmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
var (
|
||||
landRingsOnce sync.Once
|
||||
landRingsCache [][]geodata.GeoPoint
|
||||
)
|
||||
|
||||
// landRings 把烘焙的等经纬陆地路径还原成经纬度环。
|
||||
// 路径编码在 5760x2880 的整数像素网格上,即 1/16° 量化;在球面图上不足 0.2 像素。
|
||||
// 正射图的视点随事件变化,无法预烘焙,只能运行时投影。
|
||||
func landRings() [][]geodata.GeoPoint {
|
||||
landRingsOnce.Do(func() {
|
||||
landRingsCache = decodeLandPath(equirectangularLandPath, worldLandWidth, worldLandHeight)
|
||||
})
|
||||
return landRingsCache
|
||||
}
|
||||
|
||||
// scanLandNumber 读取一个整数坐标;SVG 允许负号紧接前一个数字,因此不能按空白切分。
|
||||
func scanLandNumber(text string, position int) (int, int, bool) {
|
||||
// 坐标对之间有空白,调用方可能在命令字母之后直接进入,所以这里要自己跳过分隔符。
|
||||
for position < len(text) && (text[position] == ' ' || text[position] == ',') {
|
||||
position++
|
||||
}
|
||||
start := position
|
||||
if position < len(text) && (text[position] == '-' || text[position] == '+') {
|
||||
position++
|
||||
}
|
||||
digits := position
|
||||
for position < len(text) && text[position] >= '0' && text[position] <= '9' {
|
||||
position++
|
||||
}
|
||||
if position == digits {
|
||||
return 0, start, false
|
||||
}
|
||||
value, err := strconv.Atoi(text[start:position])
|
||||
if err != nil {
|
||||
return 0, start, false
|
||||
}
|
||||
return value, position, true
|
||||
}
|
||||
|
||||
// decodeLandPath 解析内嵌底图的紧凑路径,只用到 M/l/h/v/z 五种命令。
|
||||
func decodeLandPath(text string, width, height int) [][]geodata.GeoPoint {
|
||||
rings := make([][]geodata.GeoPoint, 0, 2048)
|
||||
var ring []geodata.GeoPoint
|
||||
x, y := 0, 0
|
||||
position := 0
|
||||
var command byte
|
||||
point := func(px, py int) geodata.GeoPoint {
|
||||
return geodata.GeoPoint{
|
||||
Longitude: float64(px)/float64(width)*360 - 180,
|
||||
Latitude: 90 - float64(py)/float64(height)*180,
|
||||
}
|
||||
}
|
||||
flush := func() {
|
||||
if len(ring) >= 3 {
|
||||
rings = append(rings, ring)
|
||||
}
|
||||
ring = nil
|
||||
}
|
||||
for position < len(text) {
|
||||
for position < len(text) && (text[position] == ' ' || text[position] == ',') {
|
||||
position++
|
||||
}
|
||||
if position >= len(text) {
|
||||
break
|
||||
}
|
||||
if character := text[position]; (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') {
|
||||
command = character
|
||||
position++
|
||||
if command == 'z' || command == 'Z' {
|
||||
flush()
|
||||
command = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
value, next, ok := scanLandNumber(text, position)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
position = next
|
||||
switch command {
|
||||
case 'M':
|
||||
second, afterSecond, secondOK := scanLandNumber(text, position)
|
||||
if !secondOK {
|
||||
return rings
|
||||
}
|
||||
position = afterSecond
|
||||
flush()
|
||||
x, y = value, second
|
||||
ring = append(ring, point(x, y))
|
||||
// M 之后的裸坐标对按规范是隐式相对直线段。
|
||||
command = 'l'
|
||||
case 'l':
|
||||
second, afterSecond, secondOK := scanLandNumber(text, position)
|
||||
if !secondOK {
|
||||
return rings
|
||||
}
|
||||
position = afterSecond
|
||||
x += value
|
||||
y += second
|
||||
ring = append(ring, point(x, y))
|
||||
case 'h':
|
||||
x += value
|
||||
ring = append(ring, point(x, y))
|
||||
case 'v':
|
||||
y += value
|
||||
ring = append(ring, point(x, y))
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return rings
|
||||
}
|
||||
|
||||
// writeOrthographicPath 先按视界裁剪再投影一段地理折线,写成 SVG 路径命令。
|
||||
func (frame Frame) writeOrthographicPath(builder *strings.Builder, view geodata.ClipView, points []geodata.GeoPoint, close bool) {
|
||||
for _, segment := range geodata.PolylineSegments(points, view) {
|
||||
frame.writeOrthographicRing(builder, segment, close)
|
||||
}
|
||||
}
|
||||
|
||||
// writeOrthographicRing 投影一个已经裁剪好的环或折线。
|
||||
// 这里不能再调 PolylineSegments:视界上的点深度恰为 0,会被判成不可见而把环拆开,
|
||||
// 每个碎片再用 Z 直线闭合,就会在圆盘上留下横穿的弦。
|
||||
func (frame Frame) writeOrthographicRing(builder *strings.Builder, points []geodata.GeoPoint, close bool) {
|
||||
written := 0
|
||||
for _, item := range points {
|
||||
x, y, visible := frame.Project(item.Longitude, item.Latitude)
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
command := "L"
|
||||
if written == 0 {
|
||||
command = "M"
|
||||
}
|
||||
// 球面图上 0.1 像素已远小于海岸线本身的量化误差。
|
||||
fmt.Fprintf(builder, "%s%.1f %.1f", command, x, y)
|
||||
written++
|
||||
}
|
||||
if written >= 2 && close {
|
||||
builder.WriteString("Z")
|
||||
}
|
||||
}
|
||||
|
||||
// writeOrthographicLand 在正射球面图上运行时投影并裁剪陆地。
|
||||
func (frame Frame) writeOrthographicLand(builder *strings.Builder, clipID string) {
|
||||
view := frame.Clip()
|
||||
var path strings.Builder
|
||||
for _, ring := range landRings() {
|
||||
for _, fragment := range geodata.PolygonFragments(ring, view) {
|
||||
frame.writeOrthographicRing(&path, fragment, true)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(builder, `<g class="land-layer" clip-path="url(#%s)" fill="#d8d9d2" stroke="#a6aaa4" stroke-width="0.75" stroke-linejoin="round"><path class="land" d="%s" fill-rule="evenodd" vector-effect="non-scaling-stroke"/></g>`,
|
||||
clipID, path.String())
|
||||
}
|
||||
|
||||
// writeOrthographicGraticule 绘制球面经纬网;经线是完整大圆,纬线是等纬圈,都按视界裁剪。
|
||||
func (frame Frame) writeOrthographicGraticule(builder *strings.Builder, clipID string) {
|
||||
view := frame.Clip()
|
||||
fmt.Fprintf(builder, `<g class="graticule" clip-path="url(#%s)" fill="none" stroke="#b8c4c3" stroke-width="0.65">`, clipID)
|
||||
for longitude := -180.0; longitude < 180; longitude += 30 {
|
||||
points := make([]geodata.GeoPoint, 0, 91)
|
||||
for latitude := -90.0; latitude <= 90; latitude += 2 {
|
||||
points = append(points, geodata.GeoPoint{Longitude: longitude, Latitude: latitude})
|
||||
}
|
||||
builder.WriteString(`<path d="`)
|
||||
frame.writeOrthographicPath(builder, view, points, false)
|
||||
builder.WriteString(`"/>`)
|
||||
}
|
||||
for latitude := -60.0; latitude <= 60; latitude += 30 {
|
||||
points := make([]geodata.GeoPoint, 0, 181)
|
||||
for longitude := -180.0; longitude <= 180; longitude += 2 {
|
||||
points = append(points, geodata.GeoPoint{Longitude: longitude, Latitude: latitude})
|
||||
}
|
||||
builder.WriteString(`<path d="`)
|
||||
frame.writeOrthographicPath(builder, view, points, false)
|
||||
builder.WriteString(`"/>`)
|
||||
}
|
||||
builder.WriteString(`</g>`)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package svgmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
var landGlobeCoordinatePattern = regexp.MustCompile(`[ML](-?[0-9.]+) (-?[0-9.]+)`)
|
||||
|
||||
// renderGlobeLand 渲染球面底图并取回陆地的路径命令与画布。
|
||||
func renderGlobeLand(t *testing.T, center geodata.GeoPoint) (Frame, string) {
|
||||
t.Helper()
|
||||
frame := Frame{
|
||||
X: 30, Y: 30, Width: 640, Height: 640, Projection: ProjectionOrthographic,
|
||||
CenterLongitude: center.Longitude, CenterLatitude: center.Latitude,
|
||||
}
|
||||
var builder strings.Builder
|
||||
fmt.Fprintf(&builder, `<svg xmlns="http://www.w3.org/2000/svg" width="700" height="700"><defs>%s</defs>`,
|
||||
frame.ClipDefinition("clip"))
|
||||
frame.WriteOcean(&builder)
|
||||
frame.WriteGraticule(&builder, "clip")
|
||||
frame.WriteLand(&builder, "clip")
|
||||
builder.WriteString(`</svg>`)
|
||||
rendered := builder.String()
|
||||
const marker = `<path class="land" d="`
|
||||
rest := rendered[strings.Index(rendered, marker)+len(marker):]
|
||||
return frame, rest[:strings.Index(rest, `"`)]
|
||||
}
|
||||
|
||||
// 球面底图的输出路径必须只由短边组成,且全部落在圆盘内。
|
||||
// 这条断言针对的是一个真实缺陷:把已经裁好的环再交给 PolylineSegments 二次裁剪时,
|
||||
// 视界上的点深度恰为 0 会被判成不可见,环被拆成多段,每段再用 Z 直线闭合,
|
||||
// 于是圆盘上出现一条横穿的弦——亚洲大陆看起来就"少了一块"。
|
||||
func TestOrthographicLandPathHasNoChords(t *testing.T) {
|
||||
for _, center := range []geodata.GeoPoint{
|
||||
{Longitude: 144.1, Latitude: 24.2},
|
||||
{Longitude: -35.9, Latitude: -24.2},
|
||||
{Longitude: 0, Latitude: 80},
|
||||
} {
|
||||
frame, path := renderGlobeLand(t, center)
|
||||
subpaths := make([][][2]float64, 0, 2048)
|
||||
for _, match := range landGlobeCoordinatePattern.FindAllStringSubmatch(path, -1) {
|
||||
x, errX := strconv.ParseFloat(match[1], 64)
|
||||
y, errY := strconv.ParseFloat(match[2], 64)
|
||||
if errX != nil || errY != nil {
|
||||
t.Fatalf("unparsable coordinate %q %q", match[1], match[2])
|
||||
}
|
||||
if len(subpaths) == 0 || match[0][0] == 'M' {
|
||||
subpaths = append(subpaths, nil)
|
||||
}
|
||||
subpaths[len(subpaths)-1] = append(subpaths[len(subpaths)-1], [2]float64{x, y})
|
||||
}
|
||||
if len(subpaths) < 100 {
|
||||
t.Fatalf("%v: only %d subpaths rendered", center, len(subpaths))
|
||||
}
|
||||
centerX := frame.X + frame.Width/2
|
||||
centerY := frame.Y + frame.Height/2
|
||||
radius := math.Min(frame.Width, frame.Height) / 2
|
||||
// 视界闭合弧按 180 段加密,整圈弦长约 2°;留 4 倍余量即可判定"这条边不可能是海岸线"。
|
||||
limit := radius * 0.09
|
||||
vertices := 0
|
||||
for _, subpath := range subpaths {
|
||||
if len(subpath) < 3 {
|
||||
t.Fatalf("%v: subpath with %d points", center, len(subpath))
|
||||
}
|
||||
vertices += len(subpath)
|
||||
for index, point := range subpath {
|
||||
if math.Hypot(point[0]-centerX, point[1]-centerY) > radius+0.6 {
|
||||
t.Fatalf("%v: vertex %.1f,%.1f falls outside the disk", center, point[0], point[1])
|
||||
}
|
||||
next := subpath[(index+1)%len(subpath)]
|
||||
if gap := math.Hypot(next[0]-point[0], next[1]-point[1]); gap > limit {
|
||||
t.Fatalf("%v: subpath edge of %.1f px (limit %.1f) between %.1f,%.1f and %.1f,%.1f",
|
||||
center, gap, limit, point[0], point[1], next[0], next[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("%v: subpaths=%d vertices=%d", center, len(subpaths), vertices)
|
||||
}
|
||||
}
|
||||
+138
-16
@@ -24,8 +24,13 @@ const (
|
||||
ProjectionEquirectangular = geodata.ProjectionEquirectangular
|
||||
ProjectionNorthPolar = geodata.ProjectionNorthPolar
|
||||
ProjectionSouthPolar = geodata.ProjectionSouthPolar
|
||||
// ProjectionOrthographic 是正射(球面)投影 / ProjectionOrthographic is the orthographic (globe) projection.
|
||||
ProjectionOrthographic = geodata.ProjectionOrthographic
|
||||
)
|
||||
|
||||
// ClipView 是裁剪所依赖的投影与视点 / ClipView carries the projection and its view point.
|
||||
type ClipView = geodata.ClipView
|
||||
|
||||
// GeoPoint 是以度表示的地理点,东经为正 / GeoPoint is a geographic point in degrees, with east longitude positive.
|
||||
type GeoPoint = geodata.GeoPoint
|
||||
|
||||
@@ -36,9 +41,21 @@ type Frame struct {
|
||||
Width float64
|
||||
Height float64
|
||||
Projection Projection
|
||||
// CenterLongitude 与 CenterLatitude 是正射投影的视点;其他投影忽略。
|
||||
// CenterLongitude and CenterLatitude are the orthographic view point; other projections ignore them.
|
||||
CenterLongitude float64
|
||||
CenterLatitude float64
|
||||
}
|
||||
|
||||
//go:generate go run ./internal/mapgen -output-dir .
|
||||
// Clip 返回该画布对应的裁剪视图 / Clip returns the clip view of this frame.
|
||||
func (frame Frame) Clip() ClipView {
|
||||
return ClipView{
|
||||
Projection: frame.Projection,
|
||||
Center: GeoPoint{Longitude: frame.CenterLongitude, Latitude: frame.CenterLatitude},
|
||||
}
|
||||
}
|
||||
|
||||
// 底图资产已随仓库提交;重生成工具不随仓库发布。
|
||||
|
||||
//go:embed land_equirectangular.path
|
||||
var equirectangularLandPath string
|
||||
@@ -53,7 +70,7 @@ var southPolarLandPath string
|
||||
// 为始终位于一个半球内的高纬事件选择极区视图 / for a high-latitude event that stays in one hemisphere.
|
||||
func ResolveProjection(requested Projection, focusLatitude, minimumLatitude, maximumLatitude float64) Projection {
|
||||
switch requested {
|
||||
case ProjectionEquirectangular, ProjectionNorthPolar, ProjectionSouthPolar:
|
||||
case ProjectionEquirectangular, ProjectionNorthPolar, ProjectionSouthPolar, ProjectionOrthographic:
|
||||
return requested
|
||||
}
|
||||
if focusLatitude >= 60 && minimumLatitude >= -2 {
|
||||
@@ -70,11 +87,32 @@ func (frame Frame) IsPolar() bool {
|
||||
return frame.Projection == ProjectionNorthPolar || frame.Projection == ProjectionSouthPolar
|
||||
}
|
||||
|
||||
// IsDisk 判断画布是否为圆盘版式:极区方位投影与正射球面图都只画一个圆。
|
||||
// IsDisk reports whether the frame is disk shaped: both hemispheric azimuthal and orthographic views draw one circle.
|
||||
func (frame Frame) IsDisk() bool {
|
||||
return frame.IsPolar() || frame.Projection == ProjectionOrthographic
|
||||
}
|
||||
|
||||
// Project 将经纬度映射为 SVG 坐标;布尔值为 false 表示点在极区投影半球外 / Project maps longitude and latitude to SVG coordinates. The boolean is false
|
||||
// 点位于极区投影的可见半球之外时返回 false / when a point lies outside a polar projection's visible hemisphere.
|
||||
func (frame Frame) Project(longitude, latitude float64) (float64, float64, bool) {
|
||||
if frame.Projection == ProjectionOrthographic {
|
||||
x, y, visible := geodata.OrthographicDiskPoint(
|
||||
GeoPoint{Longitude: longitude, Latitude: latitude},
|
||||
GeoPoint{Longitude: frame.CenterLongitude, Latitude: frame.CenterLatitude},
|
||||
)
|
||||
if !visible {
|
||||
return 0, 0, false
|
||||
}
|
||||
radius := math.Min(frame.Width, frame.Height) / 2
|
||||
return frame.X + frame.Width/2 + radius*x, frame.Y + frame.Height/2 - radius*y, true
|
||||
}
|
||||
if !frame.IsPolar() {
|
||||
x := frame.X + (longitude+180)/360*frame.Width
|
||||
offset := longitude + 180
|
||||
if frame.CenterLongitude != 0 {
|
||||
offset = equirectangularLongitudeOffset(longitude, frame.CenterLongitude)
|
||||
}
|
||||
x := frame.X + offset/360*frame.Width
|
||||
y := frame.Y + (90-latitude)/180*frame.Height
|
||||
return x, y, true
|
||||
}
|
||||
@@ -83,14 +121,22 @@ func (frame Frame) Project(longitude, latitude float64) (float64, float64, bool)
|
||||
return 0, 0, false
|
||||
}
|
||||
radius := (90 - hemisphere*latitude) / 90 * math.Min(frame.Width, frame.Height) / 2
|
||||
angle := longitude * math.Pi / 180
|
||||
// 极点俯视下横坐标是 sin(θ),南北两极的视点方向相反,θ 的符号也相反:
|
||||
// 北极 θ = C−λ+180、南极 θ = λ−C,各自把事件经线放到背向极点屏幕方向的一侧,
|
||||
// 使事件附近都是北在上、东在右。符号写错就会得到镜像图(东京跑到北京西边)。
|
||||
angle := (frame.CenterLongitude - longitude) * math.Pi / 180
|
||||
if hemisphere > 0 {
|
||||
angle += math.Pi
|
||||
} else {
|
||||
angle = -angle
|
||||
}
|
||||
return frame.X + frame.Width/2 + radius*math.Sin(angle),
|
||||
frame.Y + frame.Height/2 - radius*math.Cos(angle), true
|
||||
}
|
||||
|
||||
// ClipDefinition 写入地图的矩形或圆形裁剪路径 / ClipDefinition writes the rectangular or circular clipping path for a map.
|
||||
func (frame Frame) ClipDefinition(id string) string {
|
||||
if frame.IsPolar() {
|
||||
if frame.IsDisk() {
|
||||
return fmt.Sprintf(`<clipPath id="%s"><circle cx="%.3f" cy="%.3f" r="%.3f"/></clipPath>`,
|
||||
id, frame.X+frame.Width/2, frame.Y+frame.Height/2, math.Min(frame.Width, frame.Height)/2)
|
||||
}
|
||||
@@ -100,7 +146,7 @@ func (frame Frame) ClipDefinition(id string) string {
|
||||
|
||||
// WriteOcean 绘制地图的物理范围 / WriteOcean renders the map's physical extent.
|
||||
func (frame Frame) WriteOcean(builder *strings.Builder) {
|
||||
if frame.IsPolar() {
|
||||
if frame.IsDisk() {
|
||||
fmt.Fprintf(builder, `<circle class="map-ocean" cx="%.3f" cy="%.3f" r="%.3f" fill="#edf3f2"/>`,
|
||||
frame.X+frame.Width/2, frame.Y+frame.Height/2, math.Min(frame.Width, frame.Height)/2)
|
||||
return
|
||||
@@ -111,10 +157,21 @@ func (frame Frame) WriteOcean(builder *strings.Builder) {
|
||||
|
||||
// WriteGraticule 绘制符合投影的经线和纬线 / WriteGraticule renders projection-correct meridians and parallels.
|
||||
func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) {
|
||||
if frame.Projection == ProjectionOrthographic {
|
||||
frame.writeOrthographicGraticule(builder, clipID)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(builder, `<g class="graticule" clip-path="url(#%s)" fill="none" stroke="#b8c4c3" stroke-width="0.65">`, clipID)
|
||||
if !frame.IsPolar() {
|
||||
for longitude := -150.0; longitude <= 150; longitude += 30 {
|
||||
first, last := -150.0, 150.0
|
||||
if frame.CenterLongitude != 0 {
|
||||
first, last = frame.CenterLongitude-180, frame.CenterLongitude+180
|
||||
}
|
||||
for longitude := first; longitude <= last; longitude += 30 {
|
||||
x, _, _ := frame.Project(longitude, 0)
|
||||
if frame.CenterLongitude != 0 && (x < frame.X-0.5 || x > frame.X+frame.Width+0.5) {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(builder, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f"/>`, x, frame.Y, x, frame.Y+frame.Height)
|
||||
}
|
||||
for latitude := -60.0; latitude <= 60; latitude += 30 {
|
||||
@@ -128,7 +185,11 @@ func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) {
|
||||
for _, fraction := range []float64{1.0 / 3, 2.0 / 3, 1} {
|
||||
fmt.Fprintf(builder, `<circle cx="%.3f" cy="%.3f" r="%.3f"/>`, centerX, centerY, radius*fraction)
|
||||
}
|
||||
for longitude := -150.0; longitude <= 180; longitude += 30 {
|
||||
first, last := -150.0, 180.0
|
||||
if frame.CenterLongitude != 0 {
|
||||
first, last = frame.CenterLongitude-180, frame.CenterLongitude+180
|
||||
}
|
||||
for longitude := first; longitude <= last; longitude += 30 {
|
||||
x, y, _ := frame.Project(longitude, 0)
|
||||
fmt.Fprintf(builder, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f"/>`, centerX, centerY, x, y)
|
||||
}
|
||||
@@ -138,6 +199,14 @@ func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) {
|
||||
|
||||
// WriteLand 绘制无国界的 Natural Earth 1:50m 陆地 / WriteLand renders Natural Earth 1:50m physical land without borders.
|
||||
func (frame Frame) WriteLand(builder *strings.Builder, clipID string) {
|
||||
if frame.Projection == ProjectionOrthographic {
|
||||
frame.writeOrthographicLand(builder, clipID)
|
||||
return
|
||||
}
|
||||
if frame.Projection == ProjectionEquirectangular && frame.CenterLongitude != 0 {
|
||||
frame.writeCenteredEquirectangularLand(builder, clipID)
|
||||
return
|
||||
}
|
||||
path := equirectangularLandPath
|
||||
viewWidth := float64(worldLandWidth)
|
||||
viewHeight := float64(worldLandHeight)
|
||||
@@ -148,15 +217,32 @@ func (frame Frame) WriteLand(builder *strings.Builder, clipID string) {
|
||||
path = southPolarLandPath
|
||||
viewWidth, viewHeight = polarLandSize, polarLandSize
|
||||
}
|
||||
fmt.Fprintf(builder, `<g class="land-layer" clip-path="url(#%s)" fill="#d8d9d2" stroke="#a6aaa4" stroke-width="0.75" stroke-linejoin="round"><path class="land" d="`, clipID)
|
||||
fmt.Fprintf(builder, `<g class="land-layer" clip-path="url(#%s)" fill="#d8d9d2" stroke="#a6aaa4" stroke-width="0.75" stroke-linejoin="round">`, clipID)
|
||||
landX, landScaleX := frame.X, frame.Width/viewWidth
|
||||
if frame.IsPolar() {
|
||||
// 极地底图都按 θ=λ 烘焙。北极的投影与之手性相反,需先水平镜像变成 −λ 再转 C+180;
|
||||
// 南极手性相同,直接转 −C 即可。两侧目标角度都是 θ = ±(C−λ)+偏移。
|
||||
landRotation := -frame.CenterLongitude
|
||||
if frame.hemisphere() > 0 {
|
||||
landX, landScaleX = frame.X+frame.Width, -frame.Width/viewWidth
|
||||
landRotation = frame.CenterLongitude + 180
|
||||
}
|
||||
fmt.Fprintf(builder, `<g transform="rotate(%.4f %.3f %.3f)">`,
|
||||
landRotation, frame.X+frame.Width/2, frame.Y+frame.Height/2)
|
||||
}
|
||||
fmt.Fprintf(builder, `<path class="land" d="`)
|
||||
builder.WriteString(path)
|
||||
fmt.Fprintf(builder, `" transform="matrix(%.9f 0 0 %.9f %.3f %.3f)" fill-rule="evenodd" vector-effect="non-scaling-stroke"/></g>`,
|
||||
frame.Width/viewWidth, frame.Height/viewHeight, frame.X, frame.Y)
|
||||
fmt.Fprintf(builder, `" transform="matrix(%.9f 0 0 %.9f %.3f %.3f)" fill-rule="evenodd" vector-effect="non-scaling-stroke"/>`,
|
||||
landScaleX, frame.Height/viewHeight, landX, frame.Y)
|
||||
if frame.IsPolar() {
|
||||
builder.WriteString(`</g>`)
|
||||
}
|
||||
builder.WriteString(`</g>`)
|
||||
}
|
||||
|
||||
// WriteFrame 绘制地图轮廓 / WriteFrame renders the map outline.
|
||||
func (frame Frame) WriteFrame(builder *strings.Builder) {
|
||||
if frame.IsPolar() {
|
||||
if frame.IsDisk() {
|
||||
fmt.Fprintf(builder, `<circle class="map-frame" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#707879" stroke-width="1.1"/>`,
|
||||
frame.X+frame.Width/2, frame.Y+frame.Height/2, math.Min(frame.Width, frame.Height)/2)
|
||||
return
|
||||
@@ -167,13 +253,13 @@ func (frame Frame) WriteFrame(builder *strings.Builder) {
|
||||
|
||||
// PolylineSegments 将地理折线裁剪到选定投影并 / PolylineSegments clips a geographic polyline to the selected projection and
|
||||
// 在等经纬投影中按日界线拆分路径 / splits equirectangular paths at the antimeridian.
|
||||
func PolylineSegments(points []GeoPoint, projection Projection) [][]GeoPoint {
|
||||
return geodata.PolylineSegments(points, projection)
|
||||
func PolylineSegments(points []GeoPoint, view ClipView) [][]GeoPoint {
|
||||
return geodata.PolylineSegments(points, view)
|
||||
}
|
||||
|
||||
// PolygonFragments 将地理多边形裁剪到选定地图范围 / PolygonFragments clips a geographic polygon to the selected map extent.
|
||||
func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint {
|
||||
return geodata.PolygonFragments(points, projection)
|
||||
func PolygonFragments(points []GeoPoint, view ClipView) [][]GeoPoint {
|
||||
return geodata.PolygonFragments(points, view)
|
||||
}
|
||||
|
||||
func (frame Frame) hemisphere() float64 {
|
||||
@@ -182,3 +268,39 @@ func (frame Frame) hemisphere() float64 {
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// equirectangularLongitudeOffset 返回经度相对居中经线的偏移,换算成 0…360 的剂量。
|
||||
// 居中经线落在画面正中,其对面的经线落在左右任一边界上。
|
||||
func equirectangularLongitudeOffset(longitude, center float64) float64 {
|
||||
offset := math.Mod(longitude-center, 360)
|
||||
if offset < 0 {
|
||||
offset += 360
|
||||
}
|
||||
offset += 180
|
||||
if offset >= 360 {
|
||||
offset -= 360
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
// writeCenteredEquirectangularLand 画按经度居中后的陆地:底图路径只出现一次,
|
||||
// 再向图框缺的那一侧补一份平移副本覆盖环绕部分,因此输出大小与不居中时同量级。
|
||||
func (frame Frame) writeCenteredEquirectangularLand(builder *strings.Builder, clipID string) {
|
||||
shift := frame.CenterLongitude / 360 * frame.Width
|
||||
x := frame.X - shift
|
||||
fmt.Fprintf(builder, `<g class="land-layer" clip-path="url(#%s)" fill="#d8d9d2" stroke="#a6aaa4" stroke-width="0.75" stroke-linejoin="round">`, clipID)
|
||||
builder.WriteString(`<defs><path class="land" id="land-equirectangular-tile" d="`)
|
||||
builder.WriteString(equirectangularLandPath)
|
||||
fmt.Fprintf(builder, `" transform="matrix(%.9f 0 0 %.9f 0 0)" fill-rule="evenodd"/></defs>`,
|
||||
frame.Width/float64(worldLandWidth), frame.Height/float64(worldLandHeight))
|
||||
// 居中经线为负时窗口相对图块整体右移,只有向左补一份才能盖住图框左侧的 |C|° 空带。
|
||||
offsets := []float64{0, frame.Width}
|
||||
if shift < 0 {
|
||||
offsets = []float64{-frame.Width, 0}
|
||||
}
|
||||
for _, dx := range offsets {
|
||||
fmt.Fprintf(builder, `<use href="#land-equirectangular-tile" transform="translate(%.3f %.3f)" vector-effect="non-scaling-stroke"/>`,
|
||||
x+dx, frame.Y)
|
||||
}
|
||||
builder.WriteString(`</g>`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package svgmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -12,9 +13,10 @@ func TestPolarProjectionPlacesPoleAtCenterAndEquatorOnFrame(t *testing.T) {
|
||||
if !ok || math.Abs(x-160) > 1e-9 || math.Abs(y-170) > 1e-9 {
|
||||
t.Fatalf("north pole = %.3f %.3f %v, want map center", x, y, ok)
|
||||
}
|
||||
// 事件经线朝下,中心经线在赤道上落在圆盘最下方。
|
||||
x, y, ok = frame.Project(0, 0)
|
||||
if !ok || math.Abs(x-160) > 1e-9 || math.Abs(y-20) > 1e-9 {
|
||||
t.Fatalf("prime-meridian equator = %.3f %.3f %v, want top edge", x, y, ok)
|
||||
if !ok || math.Abs(x-160) > 1e-9 || math.Abs(y-320) > 1e-9 {
|
||||
t.Fatalf("prime-meridian equator = %.3f %.3f %v, want bottom edge", x, y, ok)
|
||||
}
|
||||
if _, _, ok := frame.Project(0, -1); ok {
|
||||
t.Fatal("north-polar projection accepted a southern-hemisphere point")
|
||||
@@ -23,11 +25,11 @@ func TestPolarProjectionPlacesPoleAtCenterAndEquatorOnFrame(t *testing.T) {
|
||||
|
||||
func TestProjectionClippingDoesNotSplitPolarAntimeridian(t *testing.T) {
|
||||
points := []GeoPoint{{Longitude: 170, Latitude: 70}, {Longitude: -170, Latitude: 70}}
|
||||
segments := PolylineSegments(points, ProjectionNorthPolar)
|
||||
segments := PolylineSegments(points, ClipView{Projection: ProjectionNorthPolar})
|
||||
if len(segments) != 1 || len(segments[0]) != 2 {
|
||||
t.Fatalf("polar antimeridian segments = %#v, want one continuous segment", segments)
|
||||
}
|
||||
segments = PolylineSegments(points, ProjectionEquirectangular)
|
||||
segments = PolylineSegments(points, ClipView{Projection: ProjectionEquirectangular})
|
||||
if len(segments) != 2 {
|
||||
t.Fatalf("equirectangular antimeridian segment count = %d, want 2", len(segments))
|
||||
}
|
||||
@@ -49,3 +51,65 @@ func TestNaturalEarthAssetsAndPolarClip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 极地投影必须与“从极点上方俯视”一致,并把事件经线转到可读的一侧:
|
||||
// 北极图的极点在圆心,事件经线落在正下方,于是事件附近是北在上、东在右。
|
||||
// 用 +sin(λ−C) 会得到镜像图,东京会跑到北京西边。
|
||||
func TestPolarProjectionKeepsTrueEastWest(t *testing.T) {
|
||||
frame := Frame{Width: 200, Height: 200, Projection: ProjectionNorthPolar}
|
||||
centreY := frame.Height / 2
|
||||
|
||||
// 极点落在圆心。
|
||||
_, poleY, _ := frame.Project(0, 90)
|
||||
if math.Abs(poleY-centreY) > 1e-9 {
|
||||
t.Fatalf("pole projects to y=%.3f, want centre %.3f", poleY, centreY)
|
||||
}
|
||||
// 事件经线朝下:赤道上中心经线的点落在圆盘最下方。
|
||||
_, meridianY, _ := frame.Project(0, 0)
|
||||
if meridianY <= centreY {
|
||||
t.Fatalf("centre meridian projects to y=%.1f, want below centre %.1f", meridianY, centreY)
|
||||
}
|
||||
// 事件附近东在右:同纬度上偏东的点横坐标更大。
|
||||
west, _, _ := frame.Project(-10, 45)
|
||||
east, _, _ := frame.Project(10, 45)
|
||||
if east <= west {
|
||||
t.Fatalf("10E projects to x=%.1f, want right of 10W at %.1f", east, west)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCenteredEquirectangularLandCoversWholeFrame(t *testing.T) {
|
||||
for _, center := range []float64{-179.5, -104.145, -90, -1, 1, 90, 104.145, 179.5} {
|
||||
frame := Frame{
|
||||
X: 57, Y: 184, Width: 360, Height: 180,
|
||||
Projection: ProjectionEquirectangular, CenterLongitude: center,
|
||||
}
|
||||
var builder strings.Builder
|
||||
frame.WriteLand(&builder, "clip")
|
||||
offsets := centeredLandTileOffsets(t, builder.String())
|
||||
if len(offsets) == 0 {
|
||||
t.Fatalf("center %.3f drew no land tiles", center)
|
||||
}
|
||||
left, right := math.Inf(1), math.Inf(-1)
|
||||
for _, offset := range offsets {
|
||||
left = math.Min(left, offset)
|
||||
right = math.Max(right, offset+frame.Width)
|
||||
}
|
||||
if left > frame.X+1e-6 || right < frame.X+frame.Width-1e-6 {
|
||||
t.Fatalf("center %.3f covers longitude band [%.3f,%.3f], frame needs [%.3f,%.3f]",
|
||||
center, left, right, frame.X, frame.X+frame.Width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func centeredLandTileOffsets(t *testing.T, document string) []float64 {
|
||||
t.Helper()
|
||||
offsets := make([]float64, 0, 2)
|
||||
for _, piece := range strings.Split(document, `<use href="#land-equirectangular-tile" transform="translate(`)[1:] {
|
||||
var x, y float64
|
||||
if _, err := fmt.Sscanf(piece, "%f %f", &x, &y); err != nil {
|
||||
t.Fatalf("parse land tile translate: %v", err)
|
||||
}
|
||||
offsets = append(offsets, x)
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user