feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
// Package geodata 提供与投影无关的地理拓扑辅助函数,用于 / Package geodata provides projection-neutral geographic topology helpers for
|
||||
// 地图渲染器和传输编码器使用 / map renderers and transport encoders.
|
||||
package geodata
|
||||
|
||||
// Projection 标识受支持的地图投影 / Projection identifies one of the supported map projections.
|
||||
type Projection string
|
||||
|
||||
const (
|
||||
ProjectionEquirectangular Projection = "equirectangular"
|
||||
ProjectionNorthPolar Projection = "north-polar"
|
||||
ProjectionSouthPolar Projection = "south-polar"
|
||||
)
|
||||
|
||||
// GeoPoint 是以度表示的地理点,东经为正 / GeoPoint is a geographic point in degrees, with east longitude positive.
|
||||
type GeoPoint struct {
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package geodata
|
||||
|
||||
import "math"
|
||||
|
||||
// SphericalCircle 返回球面小圆上的等间隔采样点 / SphericalCircle returns evenly spaced points on a small circle on the
|
||||
// 球面小圆;方位角从地理北方顺时针采样 / sphere. Bearings are sampled clockwise from geographic north.
|
||||
func SphericalCircle(center GeoPoint, radiusDegrees float64, points int) []GeoPoint {
|
||||
if points < 3 {
|
||||
return nil
|
||||
}
|
||||
latitude := center.Latitude * math.Pi / 180
|
||||
longitude := center.Longitude * math.Pi / 180
|
||||
radius := radiusDegrees * math.Pi / 180
|
||||
result := make([]GeoPoint, points)
|
||||
for index := range result {
|
||||
bearing := 2 * math.Pi * float64(index) / float64(points)
|
||||
lat := math.Asin(math.Sin(latitude)*math.Cos(radius) +
|
||||
math.Cos(latitude)*math.Sin(radius)*math.Cos(bearing))
|
||||
lon := longitude + math.Atan2(
|
||||
math.Sin(bearing)*math.Sin(radius)*math.Cos(latitude),
|
||||
math.Cos(radius)-math.Sin(latitude)*math.Sin(lat),
|
||||
)
|
||||
result[index] = GeoPoint{
|
||||
Longitude: normalizeLongitude(lon * 180 / math.Pi),
|
||||
Latitude: lat * 180 / math.Pi,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// JoinPolylineSegments 按最近端点连接无序边界线段 / JoinPolylineSegments joins unordered boundary segments by their nearest
|
||||
// 端点连接;输入线段不会被修改 / endpoints. The input segments are not modified.
|
||||
func JoinPolylineSegments(segments [][]GeoPoint) []GeoPoint {
|
||||
filtered := make([][]GeoPoint, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
if len(segment) == 0 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, append([]GeoPoint(nil), segment...))
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := append([]GeoPoint(nil), filtered[0]...)
|
||||
used := make([]bool, len(filtered))
|
||||
used[0] = true
|
||||
for joined := 1; joined < len(filtered); joined++ {
|
||||
bestIndex := -1
|
||||
bestReverse := false
|
||||
bestPrepend := false
|
||||
bestDistance := math.Inf(1)
|
||||
start := result[0]
|
||||
end := result[len(result)-1]
|
||||
for index, segment := range filtered {
|
||||
if used[index] {
|
||||
continue
|
||||
}
|
||||
if distance := angularDistanceDegrees(end, segment[0]); distance < bestDistance {
|
||||
bestIndex, bestReverse, bestPrepend, bestDistance = index, false, false, distance
|
||||
}
|
||||
if distance := angularDistanceDegrees(end, segment[len(segment)-1]); distance < bestDistance {
|
||||
bestIndex, bestReverse, bestPrepend, bestDistance = index, true, false, distance
|
||||
}
|
||||
if distance := angularDistanceDegrees(start, segment[len(segment)-1]); distance < bestDistance {
|
||||
bestIndex, bestReverse, bestPrepend, bestDistance = index, false, true, distance
|
||||
}
|
||||
if distance := angularDistanceDegrees(start, segment[0]); distance < bestDistance {
|
||||
bestIndex, bestReverse, bestPrepend, bestDistance = index, true, true, distance
|
||||
}
|
||||
}
|
||||
if bestIndex < 0 {
|
||||
break
|
||||
}
|
||||
segment := filtered[bestIndex]
|
||||
if bestReverse {
|
||||
reverseGeoPoints(segment)
|
||||
}
|
||||
if bestPrepend {
|
||||
result = append(segment, result...)
|
||||
} else {
|
||||
result = append(result, segment...)
|
||||
}
|
||||
used[bestIndex] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ShortestCircleArc 返回两点之间较短的采样圆弧 / ShortestCircleArc returns the shorter sampled arc from one point to another.
|
||||
func ShortestCircleArc(circle []GeoPoint, from, to GeoPoint) []GeoPoint {
|
||||
if len(circle) == 0 {
|
||||
return nil
|
||||
}
|
||||
fromIndex := nearestGeoPointIndex(circle, from)
|
||||
toIndex := nearestGeoPointIndex(circle, to)
|
||||
forwardSteps := (toIndex - fromIndex + len(circle)) % len(circle)
|
||||
backwardSteps := (fromIndex - toIndex + len(circle)) % len(circle)
|
||||
direction := 1
|
||||
steps := forwardSteps
|
||||
if backwardSteps < forwardSteps {
|
||||
direction = -1
|
||||
steps = backwardSteps
|
||||
}
|
||||
result := make([]GeoPoint, 0, steps+2)
|
||||
result = append(result, from)
|
||||
for step := 1; step < steps; step++ {
|
||||
index := (fromIndex + direction*step) % len(circle)
|
||||
if index < 0 {
|
||||
index += len(circle)
|
||||
}
|
||||
result = append(result, circle[index])
|
||||
}
|
||||
return append(result, to)
|
||||
}
|
||||
|
||||
// SameGeoPoint 判断两个经纬度点是否在拓扑所需精度内相等 / SameGeoPoint reports whether two longitude/latitude points are equal within
|
||||
// 地图拓扑辅助函数所需的精度内相等 / the precision needed by the map topology helpers.
|
||||
func SameGeoPoint(a, b GeoPoint) bool {
|
||||
return math.Abs(normalizeLongitude(a.Longitude-b.Longitude)) < 1e-9 &&
|
||||
math.Abs(a.Latitude-b.Latitude) < 1e-9
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if samples < 12 {
|
||||
samples = 12
|
||||
}
|
||||
if projection == ProjectionNorthPolar {
|
||||
return [][]GeoPoint{polarVisibleHemispherePolygon(center, 1, samples/2)}
|
||||
}
|
||||
if projection == ProjectionSouthPolar {
|
||||
return [][]GeoPoint{polarVisibleHemispherePolygon(center, -1, samples/2)}
|
||||
}
|
||||
return equirectangularVisibleHemispherePolygons(center, samples)
|
||||
}
|
||||
|
||||
func equirectangularVisibleHemispherePolygons(center GeoPoint, samples int) [][]GeoPoint {
|
||||
if math.Abs(center.Latitude) < 1e-9 {
|
||||
return equirectangularLongitudeBand(center.Longitude)
|
||||
}
|
||||
polygon := make([]GeoPoint, 0, samples+3)
|
||||
for index := 0; index <= samples; index++ {
|
||||
longitude := -180 + 360*float64(index)/float64(samples)
|
||||
polygon = append(polygon, GeoPoint{
|
||||
Longitude: longitude,
|
||||
Latitude: visibleHorizonLatitude(center, longitude),
|
||||
})
|
||||
}
|
||||
mapEdgeLatitude := math.Copysign(90, center.Latitude)
|
||||
return [][]GeoPoint{append(polygon,
|
||||
GeoPoint{Longitude: 180, Latitude: mapEdgeLatitude},
|
||||
GeoPoint{Longitude: -180, Latitude: mapEdgeLatitude},
|
||||
)}
|
||||
}
|
||||
|
||||
func equirectangularLongitudeBand(centerLongitude float64) [][]GeoPoint {
|
||||
centerLongitude = normalizeLongitude(centerLongitude)
|
||||
start, end := centerLongitude-90, centerLongitude+90
|
||||
var polygons [][]GeoPoint
|
||||
for _, shift := range []float64{-360, 0, 360} {
|
||||
left := math.Max(-180, start+shift)
|
||||
right := math.Min(180, end+shift)
|
||||
if right-left <= 1e-9 {
|
||||
continue
|
||||
}
|
||||
polygons = append(polygons, []GeoPoint{
|
||||
{Longitude: left, Latitude: -90},
|
||||
{Longitude: right, Latitude: -90},
|
||||
{Longitude: right, Latitude: 90},
|
||||
{Longitude: left, Latitude: 90},
|
||||
})
|
||||
}
|
||||
return polygons
|
||||
}
|
||||
|
||||
func polarVisibleHemispherePolygon(center GeoPoint, hemisphere float64, samples int) []GeoPoint {
|
||||
if samples < 6 {
|
||||
samples = 6
|
||||
}
|
||||
if math.Abs(center.Latitude) < 1e-9 {
|
||||
polygon := []GeoPoint{
|
||||
{Longitude: normalizeLongitude(center.Longitude - 90), Latitude: 0},
|
||||
{Longitude: normalizeLongitude(center.Longitude), Latitude: 90 * hemisphere},
|
||||
{Longitude: normalizeLongitude(center.Longitude + 90), Latitude: 0},
|
||||
}
|
||||
return appendPolarVisibilityRim(polygon, center.Longitude, false, samples)
|
||||
}
|
||||
|
||||
centerLongitude := normalizeLongitude(center.Longitude)
|
||||
centerInsideProjection := center.Latitude*hemisphere > 0
|
||||
horizonMidpoint := centerLongitude
|
||||
if centerInsideProjection {
|
||||
horizonMidpoint += 180
|
||||
}
|
||||
polygon := make([]GeoPoint, 0, 2*samples+1)
|
||||
for index := 0; index <= samples; index++ {
|
||||
longitude := horizonMidpoint - 90 + 180*float64(index)/float64(samples)
|
||||
latitude := visibleHorizonLatitude(center, longitude)
|
||||
if latitude*hemisphere < 0 && math.Abs(latitude) < 1e-9 {
|
||||
latitude = 0
|
||||
}
|
||||
polygon = append(polygon, GeoPoint{
|
||||
Longitude: normalizeLongitude(longitude),
|
||||
Latitude: latitude,
|
||||
})
|
||||
}
|
||||
return appendPolarVisibilityRim(polygon, centerLongitude, centerInsideProjection, samples)
|
||||
}
|
||||
|
||||
func appendPolarVisibilityRim(
|
||||
polygon []GeoPoint,
|
||||
centerLongitude float64,
|
||||
centerInsideProjection bool,
|
||||
samples int,
|
||||
) []GeoPoint {
|
||||
for index := 1; index <= samples; index++ {
|
||||
fraction := float64(index) / float64(samples)
|
||||
longitude := centerLongitude + 90 - 180*fraction
|
||||
if centerInsideProjection {
|
||||
longitude = centerLongitude - 90 + 180*fraction
|
||||
}
|
||||
polygon = append(polygon, GeoPoint{
|
||||
Longitude: normalizeLongitude(longitude),
|
||||
Latitude: 0,
|
||||
})
|
||||
}
|
||||
return polygon
|
||||
}
|
||||
|
||||
func visibleHorizonLatitude(center GeoPoint, longitude float64) float64 {
|
||||
declination := center.Latitude * math.Pi / 180
|
||||
deltaLongitude := (longitude - center.Longitude) * math.Pi / 180
|
||||
return math.Atan(-math.Cos(declination)*math.Cos(deltaLongitude)/math.Sin(declination)) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func nearestGeoPointIndex(points []GeoPoint, target GeoPoint) int {
|
||||
bestIndex := 0
|
||||
bestDistance := math.Inf(1)
|
||||
for index, point := range points {
|
||||
if distance := angularDistanceDegrees(point, target); distance < bestDistance {
|
||||
bestIndex, bestDistance = index, distance
|
||||
}
|
||||
}
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
func angularDistanceDegrees(a, b GeoPoint) float64 {
|
||||
lat1 := a.Latitude * math.Pi / 180
|
||||
lat2 := b.Latitude * math.Pi / 180
|
||||
dLongitude := normalizeLongitude(b.Longitude-a.Longitude) * math.Pi / 180
|
||||
cosine := math.Sin(lat1)*math.Sin(lat2) +
|
||||
math.Cos(lat1)*math.Cos(lat2)*math.Cos(dLongitude)
|
||||
return math.Acos(math.Max(-1, math.Min(1, cosine))) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func reverseGeoPoints(points []GeoPoint) {
|
||||
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
|
||||
points[left], points[right] = points[right], points[left]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package geodata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestJoinPolylineSegmentsUsesBothResultEndpoints(t *testing.T) {
|
||||
segments := [][]GeoPoint{
|
||||
{
|
||||
{Longitude: -179, Latitude: 16},
|
||||
{Longitude: -41, Latitude: 64},
|
||||
},
|
||||
{
|
||||
{Longitude: 51, Latitude: 60},
|
||||
{Longitude: 179, Latitude: 16},
|
||||
},
|
||||
}
|
||||
joined := JoinPolylineSegments(segments)
|
||||
if len(joined) != 4 {
|
||||
t.Fatalf("joined point count = %d, want 4", len(joined))
|
||||
}
|
||||
if absoluteLongitude(joined[0].Longitude) > 90 || absoluteLongitude(joined[len(joined)-1].Longitude) > 90 {
|
||||
t.Fatalf("joined open endpoints are on the antimeridian: first=%+v last=%+v", joined[0], joined[len(joined)-1])
|
||||
}
|
||||
if angularDistanceDegrees(joined[1], joined[2]) > 3 {
|
||||
t.Fatalf("nearest antimeridian endpoints were not joined: %+v -> %+v", joined[1], joined[2])
|
||||
}
|
||||
}
|
||||
|
||||
func absoluteLongitude(value float64) float64 {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package geodata
|
||||
|
||||
import "math"
|
||||
|
||||
// PolylineSegments 将地理折线裁剪到选定投影并 / PolylineSegments clips a geographic polyline to the selected projection and
|
||||
// 在等经纬投影中按日界线拆分路径 / splits equirectangular paths at the antimeridian.
|
||||
func PolylineSegments(points []GeoPoint, projection Projection) [][]GeoPoint {
|
||||
if projection == ProjectionNorthPolar {
|
||||
return clipPolylineHemisphere(points, 1)
|
||||
}
|
||||
if projection == ProjectionSouthPolar {
|
||||
return clipPolylineHemisphere(points, -1)
|
||||
}
|
||||
return splitPolylineAntimeridian(points)
|
||||
}
|
||||
|
||||
// PolygonFragments 将地理多边形裁剪到选定地图范围 / PolygonFragments clips a geographic polygon to the selected map extent.
|
||||
func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint {
|
||||
if len(points) < 3 {
|
||||
return nil
|
||||
}
|
||||
if projection == ProjectionNorthPolar {
|
||||
if clipped := clipPolygonHemisphere(points, 1); len(clipped) >= 3 {
|
||||
return [][]GeoPoint{clipped}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if projection == ProjectionSouthPolar {
|
||||
if clipped := clipPolygonHemisphere(points, -1); len(clipped) >= 3 {
|
||||
return [][]GeoPoint{clipped}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return splitPolygonAntimeridian(points)
|
||||
}
|
||||
|
||||
func splitPolylineAntimeridian(points []GeoPoint) [][]GeoPoint {
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
segments := make([][]GeoPoint, 0, 2)
|
||||
current := []GeoPoint{points[0]}
|
||||
for index := 1; index < len(points); index++ {
|
||||
a, b := points[index-1], points[index]
|
||||
if (a.Longitude == -180 && b.Longitude == 180) || (a.Longitude == 180 && b.Longitude == -180) {
|
||||
// -180/+180 的精确端点属于同一子午线,不要 / Exact -180/+180 endpoints are the same meridian; do not
|
||||
// 不要让它们进入分母为零的交叉插值 / feed them into the crossing interpolation with a zero denominator.
|
||||
b.Longitude = a.Longitude
|
||||
current = append(current, b)
|
||||
continue
|
||||
}
|
||||
if math.Abs(b.Longitude-a.Longitude) <= 180 {
|
||||
current = append(current, b)
|
||||
continue
|
||||
}
|
||||
boundary := 180.0
|
||||
adjustedLongitude := b.Longitude
|
||||
if a.Longitude < 0 {
|
||||
boundary = -180
|
||||
adjustedLongitude -= 360
|
||||
} else {
|
||||
adjustedLongitude += 360
|
||||
}
|
||||
fraction := (boundary - a.Longitude) / (adjustedLongitude - a.Longitude)
|
||||
crossing := GeoPoint{Longitude: boundary, Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude)}
|
||||
current = append(current, crossing)
|
||||
if len(current) >= 2 {
|
||||
segments = append(segments, current)
|
||||
}
|
||||
crossing.Longitude = -boundary
|
||||
current = []GeoPoint{crossing, b}
|
||||
}
|
||||
if len(current) >= 2 {
|
||||
segments = append(segments, current)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func clipPolylineHemisphere(points []GeoPoint, hemisphere float64) [][]GeoPoint {
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
inside := func(value GeoPoint) bool { return value.Latitude*hemisphere >= 0 }
|
||||
var segments [][]GeoPoint
|
||||
var current []GeoPoint
|
||||
for index, point := range points {
|
||||
pointInside := inside(point)
|
||||
if index == 0 {
|
||||
if pointInside {
|
||||
current = append(current, point)
|
||||
}
|
||||
continue
|
||||
}
|
||||
previous := points[index-1]
|
||||
previousInside := inside(previous)
|
||||
if previousInside != pointInside {
|
||||
crossing := hemisphereIntersection(previous, point)
|
||||
if previousInside {
|
||||
current = append(current, crossing)
|
||||
if len(current) >= 2 {
|
||||
segments = append(segments, current)
|
||||
}
|
||||
current = nil
|
||||
} else {
|
||||
current = []GeoPoint{crossing}
|
||||
}
|
||||
}
|
||||
if pointInside {
|
||||
current = append(current, point)
|
||||
}
|
||||
}
|
||||
if len(current) >= 2 {
|
||||
segments = append(segments, current)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func clipPolygonHemisphere(points []GeoPoint, hemisphere float64) []GeoPoint {
|
||||
inside := func(value GeoPoint) bool { return value.Latitude*hemisphere >= 0 }
|
||||
return clipPolygon(points, inside, hemisphereIntersection)
|
||||
}
|
||||
|
||||
func splitPolygonAntimeridian(points []GeoPoint) [][]GeoPoint {
|
||||
unwrapped := make([]GeoPoint, len(points))
|
||||
unwrapped[0] = points[0]
|
||||
for index := 1; index < len(points); index++ {
|
||||
point := points[index]
|
||||
previous := unwrapped[index-1].Longitude
|
||||
for point.Longitude-previous > 180 {
|
||||
point.Longitude -= 360
|
||||
}
|
||||
for point.Longitude-previous < -180 {
|
||||
point.Longitude += 360
|
||||
}
|
||||
unwrapped[index] = point
|
||||
}
|
||||
minimum, maximum := unwrapped[0].Longitude, unwrapped[0].Longitude
|
||||
for _, point := range unwrapped[1:] {
|
||||
minimum = math.Min(minimum, point.Longitude)
|
||||
maximum = math.Max(maximum, point.Longitude)
|
||||
}
|
||||
firstWorld := int(math.Floor((minimum + 180) / 360))
|
||||
lastWorld := int(math.Floor((maximum + 180) / 360))
|
||||
var fragments [][]GeoPoint
|
||||
for world := firstWorld; world <= lastWorld; world++ {
|
||||
left := -180.0 + 360*float64(world)
|
||||
right := 180.0 + 360*float64(world)
|
||||
clipped := clipPolygonLongitude(unwrapped, left, true)
|
||||
clipped = clipPolygonLongitude(clipped, right, false)
|
||||
if len(clipped) < 3 {
|
||||
continue
|
||||
}
|
||||
for index := range clipped {
|
||||
clipped[index].Longitude -= 360 * float64(world)
|
||||
}
|
||||
if math.Abs(signedPolygonArea(clipped)) < 1e-12 {
|
||||
// 边恰好落在日界线上的多边形可能在相邻世界各输出一次 / A polygon whose edge lies exactly on the antimeridian can be
|
||||
// 可能在相邻世界各输出一次;丢弃重复的 / emitted once for each adjacent world. Drop the duplicate
|
||||
// 零面积片段后再做 GeoJSON 环验证 / zero-area fragment before GeoJSON ring validation.
|
||||
continue
|
||||
}
|
||||
fragments = append(fragments, clipped)
|
||||
}
|
||||
return fragments
|
||||
}
|
||||
|
||||
func signedPolygonArea(points []GeoPoint) float64 {
|
||||
if len(points) < 3 {
|
||||
return 0
|
||||
}
|
||||
area := 0.0
|
||||
for index, point := range points {
|
||||
next := points[(index+1)%len(points)]
|
||||
area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
|
||||
}
|
||||
return area / 2
|
||||
}
|
||||
|
||||
func clipPolygonLongitude(points []GeoPoint, boundary float64, keepGreater bool) []GeoPoint {
|
||||
inside := func(value GeoPoint) bool {
|
||||
if keepGreater {
|
||||
return value.Longitude >= boundary
|
||||
}
|
||||
return value.Longitude <= boundary
|
||||
}
|
||||
intersection := func(a, b GeoPoint) GeoPoint {
|
||||
fraction := (boundary - a.Longitude) / (b.Longitude - a.Longitude)
|
||||
return GeoPoint{Longitude: boundary, Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude)}
|
||||
}
|
||||
return clipPolygon(points, inside, intersection)
|
||||
}
|
||||
|
||||
func clipPolygon(
|
||||
points []GeoPoint,
|
||||
inside func(GeoPoint) bool,
|
||||
intersection func(GeoPoint, GeoPoint) GeoPoint,
|
||||
) []GeoPoint {
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
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 hemisphereIntersection(a, b GeoPoint) GeoPoint {
|
||||
dLongitude := normalizeLongitude(b.Longitude - a.Longitude)
|
||||
fraction := -a.Latitude / (b.Latitude - a.Latitude)
|
||||
return GeoPoint{Longitude: normalizeLongitude(a.Longitude + fraction*dLongitude), Latitude: 0}
|
||||
}
|
||||
|
||||
func normalizeLongitude(value float64) float64 {
|
||||
value = math.Mod(value+180, 360)
|
||||
if value < 0 {
|
||||
value += 360
|
||||
}
|
||||
return value - 180
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) {
|
||||
segments := PolylineSegments([]GeoPoint{
|
||||
{Longitude: -180, Latitude: 10},
|
||||
{Longitude: 180, Latitude: 20},
|
||||
}, ProjectionEquirectangular)
|
||||
if len(segments) != 1 || len(segments[0]) != 2 {
|
||||
t.Fatalf("exact-antimeridian line segments = %#v", segments)
|
||||
}
|
||||
if segments[0][0].Longitude != segments[0][1].Longitude {
|
||||
t.Fatalf("exact-antimeridian line spans %.1f degrees",
|
||||
math.Abs(segments[0][1].Longitude-segments[0][0].Longitude))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) {
|
||||
fragments := PolygonFragments([]GeoPoint{
|
||||
{Longitude: -180, Latitude: 15},
|
||||
{Longitude: 180, Latitude: 14},
|
||||
{Longitude: 150, Latitude: 13},
|
||||
{Longitude: 150, Latitude: -12},
|
||||
{Longitude: 180, Latitude: -11},
|
||||
{Longitude: -180, Latitude: -10},
|
||||
}, ProjectionEquirectangular)
|
||||
if len(fragments) != 1 {
|
||||
t.Fatalf("exact-antimeridian polygon produced %d fragments, want 1", len(fragments))
|
||||
}
|
||||
if math.Abs(signedPolygonArea(fragments[0])) < 1e-12 {
|
||||
t.Fatal("exact-antimeridian polygon fragment has zero area")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user