feat: 完善时标与天象几何计算并扩展输出接口

- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
This commit is contained in:
2026-09-23 18:55:12 +08:00
parent 1f31a9b5b5
commit 16c62a97d5
503 changed files with 33290 additions and 9471 deletions
+94 -1
View File
@@ -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 {
+138
View File
@@ -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))
}
}