feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package civiltime
|
||||
|
||||
import "time"
|
||||
|
||||
// Event 合并变长民用日的固定偏移候选,handled=false 表示普通日期 / merges fixed-offset candidates on a civil day with an offset change.
|
||||
func Event(date time.Time, missing error, calculate func(time.Time) (time.Time, error)) (result time.Time, err error, handled bool) {
|
||||
year, month, day := date.Date()
|
||||
start := time.Date(year, month, day, 0, 0, 0, 0, date.Location())
|
||||
end := time.Date(year, month, day+1, 0, 0, 0, 0, date.Location())
|
||||
_, firstOffset := start.Zone()
|
||||
_, lastOffset := end.Add(-time.Nanosecond).Zone()
|
||||
if firstOffset == lastOffset {
|
||||
return time.Time{}, nil, false
|
||||
}
|
||||
var firstErr error
|
||||
for _, offset := range []int{firstOffset, lastOffset} {
|
||||
fixed := time.Date(year, month, day, 0, 0, 0, 0, time.FixedZone("", offset))
|
||||
candidate, candidateErr := calculate(fixed)
|
||||
if candidateErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = candidateErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !candidate.Before(start) && candidate.Before(end) && (result.IsZero() || candidate.Before(result)) {
|
||||
result = candidate.In(date.Location())
|
||||
}
|
||||
}
|
||||
if !result.IsZero() {
|
||||
return result, nil, true
|
||||
}
|
||||
if firstErr != nil {
|
||||
return time.Time{}, firstErr, true
|
||||
}
|
||||
return time.Time{}, missing, true
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package civiltime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventFiltersByActualCivilDay(t *testing.T) {
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missing := errors.New("no event on requested day")
|
||||
cases := []struct {
|
||||
month, day, offset, hour int
|
||||
wantFound bool
|
||||
}{
|
||||
{3, 8, -5, 23, false},
|
||||
{11, 1, -5, 23, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
date := time.Date(2026, time.Month(tc.month), tc.day, 12, 0, 0, 0, loc)
|
||||
candidate := time.Date(2026, time.Month(tc.month), tc.day, tc.hour, 30, 0, 0, time.FixedZone("", tc.offset*3600))
|
||||
calls := 0
|
||||
got, err, handled := Event(date, missing, func(fixed time.Time) (time.Time, error) {
|
||||
calls++
|
||||
if fixed.Location() == loc {
|
||||
t.Fatal("calculation must use a fixed offset")
|
||||
}
|
||||
return candidate, nil
|
||||
})
|
||||
if !handled || calls != 2 {
|
||||
t.Fatalf("handled=%t calls=%d", handled, calls)
|
||||
}
|
||||
if tc.wantFound {
|
||||
if err != nil || !got.Equal(candidate) || got.Location() != loc {
|
||||
t.Errorf("got %s, %v; want %s", got, err, candidate)
|
||||
}
|
||||
} else if !got.IsZero() || !errors.Is(err, missing) {
|
||||
t.Errorf("outside-day result %s, %v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventLeavesFixedOffsetToCaller(t *testing.T) {
|
||||
date := time.Date(2026, 11, 1, 12, 0, 0, 0, time.FixedZone("EST", -5*3600))
|
||||
_, _, handled := Event(date, nil, func(time.Time) (time.Time, error) {
|
||||
t.Fatal("ordinary day invoked fallback")
|
||||
return time.Time{}, nil
|
||||
})
|
||||
if handled {
|
||||
t.Fatal("ordinary day reported handled")
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,9 @@ func TestPolygonFragmentsCentredSeamKeepsGeometry(t *testing.T) {
|
||||
t.Fatalf("fragment has %d points", len(fragment))
|
||||
}
|
||||
for _, point := range fragment {
|
||||
if point.Longitude < -60-1e-6 || point.Longitude > 60+1e-6 {
|
||||
t.Fatalf("fragment longitude %.3f escaped the ring", point.Longitude)
|
||||
// 分段用地图窗口坐标表示(居中经线 ±180),折回 -180…180 后再比对。
|
||||
if longitude := normalizeLongitude180(point.Longitude); longitude < -60-1e-6 || longitude > 60+1e-6 {
|
||||
t.Fatalf("fragment longitude %.3f escaped the ring", longitude)
|
||||
}
|
||||
if math.Abs(point.Latitude) > 30+0.5 {
|
||||
t.Fatalf("fragment latitude %.3f escaped the ring", point.Latitude)
|
||||
|
||||
@@ -455,7 +455,7 @@ func equirectangularSeamShift(view ClipView) float64 {
|
||||
func splitPolylineAtSeam(points []GeoPoint, shift float64) [][]GeoPoint {
|
||||
segments := splitPolylineAntimeridian(rotateLongitudes(points, -shift))
|
||||
for _, segment := range segments {
|
||||
rotateLongitudesInPlace(segment, shift)
|
||||
unshiftLongitudesInPlace(segment, shift)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func splitPolylineAtSeam(points []GeoPoint, shift float64) [][]GeoPoint {
|
||||
func splitPolygonAtSeam(points []GeoPoint, shift float64) [][]GeoPoint {
|
||||
fragments := splitPolygonAntimeridian(rotateLongitudes(points, -shift))
|
||||
for _, fragment := range fragments {
|
||||
rotateLongitudesInPlace(fragment, shift)
|
||||
unshiftLongitudesInPlace(fragment, shift)
|
||||
}
|
||||
return fragments
|
||||
}
|
||||
@@ -472,13 +472,25 @@ func splitPolygonAtSeam(points []GeoPoint, shift float64) [][]GeoPoint {
|
||||
func rotateLongitudes(points []GeoPoint, shift float64) []GeoPoint {
|
||||
rotated := make([]GeoPoint, len(points))
|
||||
copy(rotated, points)
|
||||
rotateLongitudesInPlace(rotated, shift)
|
||||
for index := range rotated {
|
||||
rotated[index].Longitude = normalizeLongitude180(rotated[index].Longitude + shift)
|
||||
}
|
||||
return rotated
|
||||
}
|
||||
|
||||
func rotateLongitudesInPlace(points []GeoPoint, shift float64) {
|
||||
// unshiftLongitudesInPlace 把接缝坐标系的分段转回地图窗口,结果落在 [shift-180, shift+180]。
|
||||
// 窗口两端是同一条接缝经线:分段落在 +180 的点贴的是右边缘,折回 -180 会让闭合边横穿整幅图。
|
||||
func unshiftLongitudesInPlace(points []GeoPoint, shift float64) {
|
||||
left := shift - 180
|
||||
for index := range points {
|
||||
points[index].Longitude = normalizeLongitude180(points[index].Longitude + shift)
|
||||
value := points[index].Longitude + shift
|
||||
for value < left {
|
||||
value += 360
|
||||
}
|
||||
for value > left+360 {
|
||||
value -= 360
|
||||
}
|
||||
points[index].Longitude = value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,11 +64,11 @@ func RefineWithin(points []geodata.GeoPoint, at time.Time, toleranceDegrees floa
|
||||
if len(points) < 3 {
|
||||
return points
|
||||
}
|
||||
jd := basic.Date2JDE(at.UTC())
|
||||
tt := basic.TD2UT(jd, true)
|
||||
jd := basic.Date2JD(at.UTC())
|
||||
tt := basic.UTC2TT(jd)
|
||||
ra, dec := basic.HMoonTrueRaDec(tt)
|
||||
distanceAU := basic.HMoonAway(tt) / 149597870.7
|
||||
sidereal := basic.ApparentSiderealTime(jd) * 15
|
||||
sidereal := basic.ApparentSiderealTime(basic.UTC2UT1(jd)) * 15
|
||||
center := geodata.GeoPoint{Longitude: normalizeLongitude(ra - sidereal), Latitude: dec}
|
||||
// Every correction is at the same instant. Reuse its full ephemeris while
|
||||
// retaining the ellipsoid and topocentric transform used by HMoonHeight.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ellipsisMark 是截断标记;用单个全角省略号,避免与文本里的三个半角点混淆。
|
||||
@@ -43,6 +44,12 @@ func truncateWithMark(value string, maxWidth, fontSize float64) string {
|
||||
// WrapText 按 EstimatedTextWidth 保守折行:优先在空白处断开,单个词或全角文本按字符断开,空行不返回。
|
||||
// WrapText wraps conservatively by EstimatedTextWidth, preferring whitespace breaks; empty lines are dropped.
|
||||
func WrapText(value string, maxWidth, fontSize float64) []string {
|
||||
return wrapText(value, maxWidth, fontSize, false)
|
||||
}
|
||||
|
||||
// wrapText 是 WrapText 与 WrapTextBalanced 共用的折行核心;fullWidthBreak 为真时允许在全角字符前断开,
|
||||
// 只有落在 ASCII 词内部才回退到空白,避免中文夹英文的行被空白回退压成短行。
|
||||
func wrapText(value string, maxWidth, fontSize float64, fullWidthBreak bool) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
@@ -67,7 +74,7 @@ func WrapText(value string, maxWidth, fontSize float64) []string {
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end < len(runes) && lastSpace > 0 {
|
||||
if end < len(runes) && lastSpace > 0 && !(fullWidthBreak && runes[end] >= utf8.RuneSelf) {
|
||||
end = lastSpace
|
||||
}
|
||||
if end == 0 {
|
||||
@@ -84,6 +91,48 @@ func WrapText(value string, maxWidth, fontSize float64) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// WrapTextBalanced 均衡折行:行数取 maxWidth 下 WrapText 的行数 n,再二分出仍能压到 n 行的最小宽度折行,使各行宽度接近;
|
||||
// 折行宽度不比 maxWidth 宽,逐行都不超过 maxWidth。全角字符前允许硬断,因此结果可能少于 n 行(中文夹空格时段落尾部会只剩一个短桩)。
|
||||
// 只影响断行位置,不改 WrapText 本身的口径。
|
||||
// WrapTextBalanced keeps the greedy line count for maxWidth and narrows the break width until the lines are close in width.
|
||||
// It may return fewer lines than WrapText because it may break before a full-width rune.
|
||||
func WrapTextBalanced(value string, maxWidth, fontSize float64) []string {
|
||||
if maxWidth <= 0 {
|
||||
return WrapText(value, maxWidth, fontSize)
|
||||
}
|
||||
return balancedTextLines(value, maxWidth, fontSize, len(wrapText(value, maxWidth, fontSize, true)))
|
||||
}
|
||||
|
||||
// WrapTextBalancedLines 均衡折行到 maxLines 行(maxLines 非正时等同 WrapTextBalanced),供只有固定行数槽位的文本块使用;
|
||||
// maxWidth 下压不到 maxLines 行时返回放得下的最少行数,由调用方截断。
|
||||
// WrapTextBalancedLines balances the text into maxLines slots when maxWidth allows it.
|
||||
func WrapTextBalancedLines(value string, maxWidth, fontSize float64, maxLines int) []string {
|
||||
if maxWidth <= 0 {
|
||||
return WrapText(value, maxWidth, fontSize)
|
||||
}
|
||||
if maxLines <= 0 {
|
||||
return WrapTextBalanced(value, maxWidth, fontSize)
|
||||
}
|
||||
return balancedTextLines(value, maxWidth, fontSize, maxLines)
|
||||
}
|
||||
|
||||
// balancedTextLines 二分出仍能压到 targetLines 行的最小折行宽度并折行;行数随折行宽度单调不增,故二分成立。
|
||||
func balancedTextLines(value string, maxWidth, fontSize float64, targetLines int) []string {
|
||||
if targetLines < 2 {
|
||||
return wrapText(value, maxWidth, fontSize, true)
|
||||
}
|
||||
low, high := 0.0, maxWidth
|
||||
for iteration := 0; iteration < 40; iteration++ {
|
||||
middle := (low + high) / 2
|
||||
if len(wrapText(value, middle, fontSize, true)) > targetLines {
|
||||
low = middle
|
||||
continue
|
||||
}
|
||||
high = middle
|
||||
}
|
||||
return wrapText(value, high, fontSize, true)
|
||||
}
|
||||
|
||||
// TextLineLimit 返回高度预算 maxHeight 能容纳的行数,至少一行;口径与 EstimatedTextExtents 一致。
|
||||
// TextLineLimit reports how many lines fit in maxHeight, never less than one.
|
||||
func TextLineLimit(fontSize, lineHeight, maxHeight float64) int {
|
||||
@@ -105,6 +154,50 @@ func BaselineLineLimit(fontSize, lineHeight, firstBaseline, bottomLimit float64)
|
||||
return TextLineLimit(fontSize, lineHeight, bottomLimit-firstBaseline+above)
|
||||
}
|
||||
|
||||
// FooterLineHeight 返回页脚行距:按 1.5 倍字号取整,12px 字得 18px、11px 字得 17px。
|
||||
// FooterLineHeight returns the footer line step, 1.5x the font size, rounded.
|
||||
func FooterLineHeight(fontSize float64) float64 {
|
||||
return math.Round(fontSize * 1.5)
|
||||
}
|
||||
|
||||
// FooterBottomPadding 返回页脚末行基线距画布底边的留白:按 1.35 倍字号取整,12px 字得 16px、11px 字得 15px。
|
||||
// FooterBottomPadding returns the gap between the last footer baseline and the canvas bottom.
|
||||
func FooterBottomPadding(fontSize float64) float64 {
|
||||
return math.Round(fontSize * 1.35)
|
||||
}
|
||||
|
||||
// FooterBlock 把页脚各行按统一行距自画布底边往上排:末行基线距底边 padding,其上每行递增 lineHeight;
|
||||
// 返回各行基线与整块自底边起占用的高度(含首行字高),供上方内容留位。
|
||||
// FooterBlock lays footer lines bottom-up with one uniform step and returns their baselines
|
||||
// plus the height the block occupies above the canvas bottom.
|
||||
func FooterBlock(lineCount int, height, fontSize, lineHeight, padding float64) (baselines []float64, occupied float64) {
|
||||
if lineCount < 1 {
|
||||
lineCount = 1
|
||||
}
|
||||
above, _ := EstimatedTextExtents(fontSize)
|
||||
baselines = make([]float64, lineCount)
|
||||
for index := range baselines {
|
||||
baselines[index] = height - padding - float64(lineCount-1-index)*lineHeight
|
||||
}
|
||||
return baselines, padding + float64(lineCount-1)*lineHeight + above
|
||||
}
|
||||
|
||||
// FooterLinesWithScale 在说明行之后接上时标声明:说明最多占 maxLines-1 行,声明独占最后一行、不参与截断;
|
||||
// 说明行数超过 maxLines 时先截断说明,再长的自定义说明也挤不掉时标声明。
|
||||
// FooterLinesWithScale keeps the time-scale declaration on its own final line, outside truncation.
|
||||
func FooterLinesWithScale(notes []string, scaleNote string, maxWidth, fontSize float64, maxLines int) []string {
|
||||
if maxLines <= 0 {
|
||||
return nil
|
||||
}
|
||||
if scaleNote == "" {
|
||||
return TruncateTextLines(notes, maxWidth, fontSize, maxLines)
|
||||
}
|
||||
if maxLines == 1 {
|
||||
return []string{scaleNote}
|
||||
}
|
||||
return append(TruncateTextLines(notes, maxWidth, fontSize, maxLines-1), scaleNote)
|
||||
}
|
||||
|
||||
// TruncateTextLines 按行数上限截断折行结果:截断时末行以省略号结尾并重新裁到 maxWidth 内,未截断时原样返回。
|
||||
// TruncateTextLines cuts lines to maxLines; a cut last line is ellipsized to maxWidth.
|
||||
func TruncateTextLines(lines []string, maxWidth, fontSize float64, maxLines int) []string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package svgchart
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -72,6 +73,33 @@ func TestTruncateTextLinesEllipsizesTheLastLineToWidth(t *testing.T) {
|
||||
textFits(t, kept, 60, 10)
|
||||
}
|
||||
|
||||
func TestFooterLinesWithScaleRespectsLineLimit(t *testing.T) {
|
||||
notes := []string{"说明一"}
|
||||
scale := "图中时刻为 UTC"
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
maxLines int
|
||||
scaleNote string
|
||||
want []string
|
||||
}{
|
||||
{name: "no lines", maxLines: 0, scaleNote: scale},
|
||||
{name: "negative lines", maxLines: -1, scaleNote: scale},
|
||||
{name: "scale only", maxLines: 1, scaleNote: scale, want: []string{scale}},
|
||||
{name: "notes and scale", maxLines: 2, scaleNote: scale, want: []string{"说明一", scale}},
|
||||
{name: "notes without scale", maxLines: 1, want: []string{"说明一"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := FooterLinesWithScale(notes, test.scaleNote, 400, 11, test.maxLines)
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("lines = %#v, want %#v", got, test.want)
|
||||
}
|
||||
if test.maxLines > 0 && len(got) > test.maxLines {
|
||||
t.Fatalf("lines = %d, want no more than %d", len(got), test.maxLines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextLineLimitCountsExtents(t *testing.T) {
|
||||
// 3 行 11 号字、行距 15 的总高约 42.65,预算再少一点就只放得下 2 行。
|
||||
if limit := TextLineLimit(11, 15, 42.7); limit != 3 {
|
||||
@@ -106,3 +134,113 @@ func TestEllipsizeTextStaysInsideWidth(t *testing.T) {
|
||||
t.Fatalf("clamped = %q, want an empty string when even the mark does not fit", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// 均衡折行只改断行位置:行数不超过同宽度下的 WrapText,行宽互相接近,且逐行仍在 maxWidth 内。
|
||||
// textRunes 拼掉断行处被吃掉的空白,用于比较两种折行的字符内容。
|
||||
func textRunes(lines []string) string {
|
||||
return strings.Join(strings.Fields(strings.Join(lines, " ")), "")
|
||||
}
|
||||
|
||||
func textSpread(lines []string, fontSize float64) float64 {
|
||||
minimum, maximum := 0.0, 0.0
|
||||
for index, line := range lines {
|
||||
width := EstimatedTextWidth(line, fontSize)
|
||||
if index == 0 || width < minimum {
|
||||
minimum = width
|
||||
}
|
||||
if index == 0 || width > maximum {
|
||||
maximum = width
|
||||
}
|
||||
}
|
||||
if minimum <= 0 {
|
||||
return 0
|
||||
}
|
||||
return maximum / minimum
|
||||
}
|
||||
|
||||
func TestWrapTextBalancedKeepsLineCountAndWidth(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value string
|
||||
maxWidth float64
|
||||
fontSize float64
|
||||
sameCount bool
|
||||
wantSpread bool
|
||||
}{
|
||||
{name: "english prose", maxWidth: 500, fontSize: 10, sameCount: true, wantSpread: true,
|
||||
value: "Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track."},
|
||||
{name: "chinese with one space", maxWidth: 420, fontSize: 12, wantSpread: true,
|
||||
value: "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 UTC+08:00)"},
|
||||
{name: "mixed page", maxWidth: 300, fontSize: 9,
|
||||
value: strings.Repeat("月球掩星 occultation 说明;", 8)},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
greedy := WrapText(test.value, test.maxWidth, test.fontSize)
|
||||
balanced := WrapTextBalanced(test.value, test.maxWidth, test.fontSize)
|
||||
if len(balanced) > len(greedy) {
|
||||
t.Fatalf("balanced lines = %d, want no more than the greedy %d", len(balanced), len(greedy))
|
||||
}
|
||||
if test.sameCount && len(balanced) != len(greedy) {
|
||||
t.Fatalf("balanced lines = %d, want the greedy %d", len(balanced), len(greedy))
|
||||
}
|
||||
if len(balanced) < 2 {
|
||||
t.Fatalf("balanced lines = %d, want the text to be wrapped", len(balanced))
|
||||
}
|
||||
textFits(t, balanced, test.maxWidth, test.fontSize)
|
||||
if got, want := textRunes(balanced), textRunes(greedy); got != want {
|
||||
t.Fatalf("balanced text = %q, want the same runes as %q", got, want)
|
||||
}
|
||||
spread := textSpread(balanced, test.fontSize)
|
||||
if test.sameCount && spread > textSpread(greedy, test.fontSize) {
|
||||
t.Fatalf("balanced spread = %.3f, want no worse than the greedy %.3f (%#v)",
|
||||
spread, textSpread(greedy, test.fontSize), balanced)
|
||||
}
|
||||
if test.wantSpread && spread > 1.15 {
|
||||
t.Fatalf("balanced spread = %.3f, want <= 1.15 (%#v)", spread, balanced)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 中文里只有一个空格时,WrapText 会为躲开西文词把首行回退到该空格,WrapTextBalanced 允许在全角字符前断开,不再留下短行。
|
||||
func TestWrapTextBalancedBreaksFullWidthTextWithoutShortTail(t *testing.T) {
|
||||
value := "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 UTC+08:00)"
|
||||
greedy := WrapText(value, 420, 12)
|
||||
if len(greedy) < 3 {
|
||||
t.Fatalf("greedy lines = %#v, want the whitespace backoff to cost extra lines", greedy)
|
||||
}
|
||||
if width := EstimatedTextWidth(greedy[0], 12); width > 0.5*420 {
|
||||
t.Fatalf("greedy first line = %q (%.1f), want the short backoff line", greedy[0], width)
|
||||
}
|
||||
balanced := WrapTextBalanced(value, 420, 12)
|
||||
if len(balanced) >= len(greedy) {
|
||||
t.Fatalf("balanced lines = %d, want fewer than the greedy %d", len(balanced), len(greedy))
|
||||
}
|
||||
textFits(t, balanced, 420, 12)
|
||||
if len(balanced) != 2 || textSpread(balanced, 12) > 1.15 {
|
||||
t.Fatalf("balanced = %#v, want two lines of close width", balanced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapTextBalancedLinesFillsSlots(t *testing.T) {
|
||||
value := strings.Repeat("月球掩星 occultation 说明;", 2)
|
||||
filled := WrapTextBalancedLines(value, 400, 9, 2)
|
||||
if len(filled) != 2 {
|
||||
t.Fatalf("filled lines = %d, want 2", len(filled))
|
||||
}
|
||||
textFits(t, filled, 400, 9)
|
||||
if spread := textSpread(filled, 9); spread > 1.15 {
|
||||
t.Fatalf("filled spread = %.3f, want <= 1.15 (%#v)", spread, filled)
|
||||
}
|
||||
if natural := WrapTextBalanced(value, 400, 9); len(natural) != 1 {
|
||||
t.Fatalf("natural lines = %d, want the natural wrap to keep one line", len(natural))
|
||||
}
|
||||
if lines := WrapTextBalancedLines(value, 400, 9, 0); len(lines) != 1 {
|
||||
t.Fatalf("lines = %d, want maxLines 0 to fall back to the natural wrap", len(lines))
|
||||
}
|
||||
// 槽位放不下时不强行行数:返回可用宽度下的折行结果,由调用方截断。
|
||||
long := strings.Repeat("月球掩星 occultation 说明;", 12)
|
||||
if lines := WrapTextBalancedLines(long, 400, 9, 2); len(lines) < 2 {
|
||||
t.Fatalf("lines = %d, want the width-limited wrap", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
+13
-10
@@ -163,11 +163,13 @@ func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) {
|
||||
}
|
||||
fmt.Fprintf(builder, `<g class="graticule" clip-path="url(#%s)" fill="none" stroke="#b8c4c3" stroke-width="0.65">`, clipID)
|
||||
if !frame.IsPolar() {
|
||||
first, last := -150.0, 150.0
|
||||
// 居中视图循环到窗口另一端会同一条接缝经线,只画一次。
|
||||
first, count := -150.0, 11
|
||||
if frame.CenterLongitude != 0 {
|
||||
first, last = frame.CenterLongitude-180, frame.CenterLongitude+180
|
||||
first, count = frame.CenterLongitude-180, 12
|
||||
}
|
||||
for longitude := first; longitude <= last; longitude += 30 {
|
||||
for index := 0; index < count; index++ {
|
||||
longitude := first + 30*float64(index)
|
||||
x, _, _ := frame.Project(longitude, 0)
|
||||
if frame.CenterLongitude != 0 && (x < frame.X-0.5 || x > frame.X+frame.Width+0.5) {
|
||||
continue
|
||||
@@ -269,17 +271,18 @@ func (frame Frame) hemisphere() float64 {
|
||||
return 1
|
||||
}
|
||||
|
||||
// equirectangularLongitudeOffset 返回经度相对居中经线的偏移,换算成 0…360 的剂量。
|
||||
// 居中经线落在画面正中,其对面的经线落在左右任一边界上。
|
||||
// equirectangularLongitudeOffset 返回经度相对画面左边缘的偏移,换算成 0…360 的剂量。
|
||||
// 居中经线落在画面正中,其对面的经线同时是左右边缘:已经在窗口内的经度按原值返回,
|
||||
// 让接缝两侧的点各自贴住自己那一侧的边缘,窗口外的经度再按 360 折回。
|
||||
func equirectangularLongitudeOffset(longitude, center float64) float64 {
|
||||
offset := math.Mod(longitude-center, 360)
|
||||
offset := longitude - (center - 180)
|
||||
if offset >= 0 && offset <= 360 {
|
||||
return offset
|
||||
}
|
||||
offset = math.Mod(offset, 360)
|
||||
if offset < 0 {
|
||||
offset += 360
|
||||
}
|
||||
offset += 180
|
||||
if offset >= 360 {
|
||||
offset -= 360
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package svgmap
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 居中经线对面的接缝同时是画面左右边缘:贴右边缘的分段必须落在窗口右端,
|
||||
// 折回左端会让闭合边横穿整幅图。
|
||||
func TestEquirectangularSeamKeepsBothWindowEdges(t *testing.T) {
|
||||
frame := Frame{X: 52, Y: 168, Width: 610.8, Height: 305.4,
|
||||
Projection: ProjectionEquirectangular, CenterLongitude: 176.271}
|
||||
left := frame.CenterLongitude - 180
|
||||
right := frame.CenterLongitude + 180
|
||||
if x, _, _ := frame.Project(left, 0); math.Abs(x-frame.X) > 1e-9 {
|
||||
t.Fatalf("seam at the left edge maps to x=%.6f, want %.6f", x, frame.X)
|
||||
}
|
||||
if x, _, _ := frame.Project(right, 0); math.Abs(x-(frame.X+frame.Width)) > 1e-9 {
|
||||
t.Fatalf("the same seam in the adjacent world maps to x=%.6f, want %.6f", x, frame.X+frame.Width)
|
||||
}
|
||||
if x, _, _ := frame.Project(right-0.5, 0); x >= frame.X+frame.Width {
|
||||
t.Fatalf("a point just west of the seam maps to x=%.6f, want inside the frame", x)
|
||||
}
|
||||
|
||||
ring := make([]GeoPoint, 0, 40)
|
||||
for longitude := -10.0; longitude <= 5; longitude += 5 {
|
||||
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 0})
|
||||
}
|
||||
for latitude := 5.0; latitude <= 20; latitude += 5 {
|
||||
ring = append(ring, GeoPoint{Longitude: 5, Latitude: latitude})
|
||||
}
|
||||
for longitude := 0.0; longitude >= -10; longitude -= 5 {
|
||||
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 20})
|
||||
}
|
||||
for latitude := 15.0; latitude >= 5; latitude -= 5 {
|
||||
ring = append(ring, GeoPoint{Longitude: -10, Latitude: latitude})
|
||||
}
|
||||
fragments := PolygonFragments(ring, frame.Clip())
|
||||
if len(fragments) != 2 {
|
||||
t.Fatalf("straddling ring fragments = %d, want 2", len(fragments))
|
||||
}
|
||||
for index, fragment := range fragments {
|
||||
minX, maxX := math.Inf(1), math.Inf(-1)
|
||||
for _, point := range fragment {
|
||||
x, _, ok := frame.Project(point.Longitude, point.Latitude)
|
||||
if !ok {
|
||||
t.Fatalf("fragment %d dropped a point", index)
|
||||
}
|
||||
minX, maxX = math.Min(minX, x), math.Max(maxX, x)
|
||||
}
|
||||
if maxX-minX > frame.Width/2 {
|
||||
t.Fatalf("fragment %d spans %.3f px, want one side of the seam only", index, maxX-minX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绕极点一圈的环靠地图上边缘闭合,闭合边两端必须分贴左右边缘。
|
||||
func TestEquirectangularPoleClosureUsesMapEdges(t *testing.T) {
|
||||
frame := Frame{X: 52, Y: 168, Width: 610.8, Height: 305.4,
|
||||
Projection: ProjectionEquirectangular, CenterLongitude: 176.271}
|
||||
ring := make([]GeoPoint, 0, 36)
|
||||
for longitude := -180.0; longitude < 180; longitude += 10 {
|
||||
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 70})
|
||||
}
|
||||
fragments := PolygonFragments(ring, frame.Clip())
|
||||
if len(fragments) != 1 {
|
||||
t.Fatalf("cap ring fragments = %d, want 1", len(fragments))
|
||||
}
|
||||
left, right, top := false, false, false
|
||||
for _, point := range fragments[0] {
|
||||
if math.Abs(point.Latitude) < 89.999 {
|
||||
continue
|
||||
}
|
||||
x, y, _ := frame.Project(point.Longitude, point.Latitude)
|
||||
if math.Abs(y-frame.Y) > 1e-6 {
|
||||
t.Fatalf("pole closure at y=%.3f, want the map top edge %.3f", y, frame.Y)
|
||||
}
|
||||
switch {
|
||||
case math.Abs(x-frame.X) < 1e-6:
|
||||
left = true
|
||||
case math.Abs(x-(frame.X+frame.Width)) < 1e-6:
|
||||
right = true
|
||||
}
|
||||
top = true
|
||||
}
|
||||
if !top || !left || !right {
|
||||
t.Fatalf("pole closure left=%v right=%v top=%v, want the cap closed along both edges", left, right, top)
|
||||
}
|
||||
if fragments := PolygonFragments(ring, ClipView{Projection: ProjectionEquirectangular}); len(fragments) != 1 {
|
||||
t.Fatalf("uncentred cap fragments = %d, want 1", len(fragments))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package timenote 生成出图时写入图内或图注的时标声明。
|
||||
package timenote
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"b612.me/astro"
|
||||
)
|
||||
|
||||
const english = "en"
|
||||
|
||||
// Scale 生成图内时标声明:UTC 口径写显示时区与 UTC 偏移,UT1 口径补 DUT1 差值 / builds the in-figure time-scale declaration.
|
||||
func Scale(scale astro.TimeScale, instant time.Time, location *time.Location, language string) string {
|
||||
if scale == astro.TimeScaleUT1 {
|
||||
dut1 := astro.DUT1(instant)
|
||||
if language == english {
|
||||
return fmt.Sprintf("Times are UT1 (Universal Time 1); DUT1 = UT1−UTC = %+.2f s.", dut1)
|
||||
}
|
||||
return fmt.Sprintf("图中时刻为 UT1(世界时),DUT1 = UT1−UTC = %+.2f s。", dut1)
|
||||
}
|
||||
zone, offset := displayZone(instant, location)
|
||||
if offset == 0 {
|
||||
if language == english {
|
||||
return "All times are UTC"
|
||||
}
|
||||
return "图中时刻为 UTC"
|
||||
}
|
||||
if named, ok := namedZoneOffset(zone); ok && named == offset {
|
||||
// 时区名本身就是偏移标签(FixedZone("UTC+08:00")、"Etc/GMT+8" 之类)时再写一遍偏移就是重复文案。
|
||||
if language == english {
|
||||
return fmt.Sprintf("All times are UTC (shown in %s)", zone)
|
||||
}
|
||||
return fmt.Sprintf("图中时刻为 UTC(显示时区 %s)", zone)
|
||||
}
|
||||
sign := "+"
|
||||
if offset < 0 {
|
||||
sign, offset = "-", -offset
|
||||
}
|
||||
hours, minutes := offset/3600, offset%3600/60
|
||||
if language == english {
|
||||
return fmt.Sprintf("All times are UTC (shown in %s, UTC%s%02d:%02d)", zone, sign, hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("图中时刻为 UTC(显示时区 %s,UTC%s%02d:%02d)", zone, sign, hours, minutes)
|
||||
}
|
||||
|
||||
// Merge 把时标声明并进图注,已含声明时不重复 / appends the declaration to a footer note once.
|
||||
func Merge(text, note string) string {
|
||||
if note == "" {
|
||||
return text
|
||||
}
|
||||
if text == "" {
|
||||
return note
|
||||
}
|
||||
if strings.Contains(text, note) {
|
||||
return text
|
||||
}
|
||||
return text + " " + note
|
||||
}
|
||||
|
||||
// namedZoneOffset 解析时区名里写死的 UTC 偏移,秒为单位;名字不含偏移时返回 false。
|
||||
func namedZoneOffset(name string) (int, bool) {
|
||||
rest := strings.ToUpper(name)
|
||||
for _, prefix := range []string{"UTC", "GMT", "UT"} {
|
||||
if strings.HasPrefix(rest, prefix) {
|
||||
rest = rest[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if rest == "" {
|
||||
return 0, false
|
||||
}
|
||||
sign := 1
|
||||
switch rest[0] {
|
||||
case '+':
|
||||
rest = rest[1:]
|
||||
case '-':
|
||||
sign, rest = -1, rest[1:]
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
digits := strings.ReplaceAll(rest, ":", "")
|
||||
if digits == "" || len(digits) > 6 {
|
||||
return 0, false
|
||||
}
|
||||
for _, symbol := range digits {
|
||||
if symbol < '0' || symbol > '9' {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
hours, minutes, seconds := 0, 0, 0
|
||||
switch len(digits) {
|
||||
case 1, 2:
|
||||
hours, _ = strconv.Atoi(digits)
|
||||
case 3, 4:
|
||||
hours, _ = strconv.Atoi(digits[:len(digits)-2])
|
||||
minutes, _ = strconv.Atoi(digits[len(digits)-2:])
|
||||
case 5, 6:
|
||||
hours, _ = strconv.Atoi(digits[:len(digits)-4])
|
||||
minutes, _ = strconv.Atoi(digits[len(digits)-4 : len(digits)-2])
|
||||
seconds, _ = strconv.Atoi(digits[len(digits)-2:])
|
||||
}
|
||||
if minutes > 59 || seconds > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return sign * (hours*3600 + minutes*60 + seconds), true
|
||||
}
|
||||
|
||||
func displayZone(instant time.Time, location *time.Location) (string, int) {
|
||||
if location != nil {
|
||||
instant = instant.In(location)
|
||||
}
|
||||
zone, offset := instant.Zone()
|
||||
if zone == "" {
|
||||
zone = "UTC"
|
||||
}
|
||||
return zone, offset
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package timenote
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro"
|
||||
)
|
||||
|
||||
// 时标声明里同一个 UTC 偏移只写一次:时区名本身是偏移标签(FixedZone("UTC+08:00") 之类)时不再追加偏移。
|
||||
func TestScaleWritesTheOffsetOnce(t *testing.T) {
|
||||
instant := time.Date(2025, time.June, 5, 20, 2, 6, 0, time.UTC)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
location *time.Location
|
||||
language string
|
||||
want string
|
||||
}{
|
||||
{name: "偏移型时区名", location: time.FixedZone("UTC+08:00", 8*3600), want: "图中时刻为 UTC(显示时区 UTC+08:00)"},
|
||||
{name: "偏移型时区名英文", location: time.FixedZone("UTC+08:00", 8*3600), language: "en", want: "All times are UTC (shown in UTC+08:00)"},
|
||||
{name: "整点时区名", location: time.FixedZone("UTC+8", 8*3600), want: "图中时刻为 UTC(显示时区 UTC+8)"},
|
||||
{name: "半小时偏移名", location: time.FixedZone("+05:30", 5*3600+1800), want: "图中时刻为 UTC(显示时区 +05:30)"},
|
||||
{name: "地名缩写中文", location: time.FixedZone("CST", 8*3600), want: "图中时刻为 UTC(显示时区 CST,UTC+08:00)"},
|
||||
{name: "地名缩写英文", location: time.FixedZone("EST", -5*3600), language: "en", want: "All times are UTC (shown in EST, UTC-05:00)"},
|
||||
{name: "偏移与名字不符", location: time.FixedZone("UTC+3", 8*3600), want: "图中时刻为 UTC(显示时区 UTC+3,UTC+08:00)"},
|
||||
{name: "UTC", location: time.UTC, want: "图中时刻为 UTC"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := Scale(astro.TimeScaleUTC, instant, test.location, test.language)
|
||||
if got != test.want {
|
||||
t.Fatalf("Scale() = %q, want %q", got, test.want)
|
||||
}
|
||||
if strings.Count(got, "UTC+08:00") > 1 || strings.Count(got, "UTC+8") > 1 {
|
||||
t.Fatalf("Scale() = %q, want the offset written once", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedZoneOffset(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value string
|
||||
offset int
|
||||
ok bool
|
||||
}{
|
||||
{name: "UTC 前缀", value: "UTC+08:00", offset: 8 * 3600, ok: true},
|
||||
{name: "GMT 前缀", value: "GMT-5", offset: -5 * 3600, ok: true},
|
||||
{name: "无前缀", value: "+0800", offset: 8 * 3600, ok: true},
|
||||
{name: "带秒", value: "UTC+05:30:30", offset: 5*3600 + 30*60 + 30, ok: true},
|
||||
{name: "地名", value: "CST", ok: false},
|
||||
{name: "UTC 本身", value: "UTC", ok: false},
|
||||
{name: "小数偏移", value: "UTC+8.5", ok: false},
|
||||
{name: "分钟越界", value: "UTC+0870", ok: false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
offset, ok := namedZoneOffset(test.value)
|
||||
if ok != test.ok || offset != test.offset {
|
||||
t.Fatalf("namedZoneOffset(%q) = (%d, %v), want (%d, %v)", test.value, offset, ok, test.offset, test.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user