Compare commits

..
1 Commits
Author SHA1 Message Date
haopengzhan f474b5a51e Initial commit: Modular personal toolbox with high-fidelity Chinese stroke order tool and CI/CD
Build and Push Docker Image / build (push) Has been cancelled
2026-02-25 19:21:50 -08:00
35 changed files with 401 additions and 10851 deletions
+4 -8
View File
@@ -1,5 +1,4 @@
# Binaries # Binaries
build/
/toolbox /toolbox
*.exe *.exe
*.exe~ *.exe~
@@ -17,11 +16,8 @@ vendor/
*.swp *.swp
*.swo *.swo
# Certificates & Keys # OS files
*.crt
*.key
server.crt
server.key
# OS
.DS_Store .DS_Store
# Data (if you want to exclude original design data, but here we include pkg/jitie/data)
# data/
+4 -61
View File
@@ -4,18 +4,14 @@ import (
"bytes" "bytes"
"embed" "embed"
"flag" "flag"
"fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
"sort"
"strconv" "strconv"
"toolbox/pkg/base" "toolbox/pkg/base"
_ "toolbox/pkg/football"
_ "toolbox/pkg/learnnumber" _ "toolbox/pkg/learnnumber"
_ "toolbox/pkg/orbitviewer"
_ "toolbox/pkg/zitie" // 匿名导入以触发 init() _ "toolbox/pkg/zitie" // 匿名导入以触发 init()
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -59,23 +55,6 @@ func main() {
subFS, _ := fs.Sub(webFS, "web") subFS, _ := fs.Sub(webFS, "web")
r.StaticFS("/static", http.FS(subFS)) r.StaticFS("/static", http.FS(subFS))
// 2.5 SEO 静态文件映射
r.GET("/robots.txt", func(c *gin.Context) {
c.String(http.StatusOK, "User-agent: *\nAllow: /\nSitemap: https://toolbox.pengzhan.dev/sitemap.xml\n")
})
r.GET("/sitemap.xml", func(c *gin.Context) {
var buf bytes.Buffer
buf.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
buf.WriteString("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n")
buf.WriteString(" <url><loc>https://toolbox.pengzhan.dev/</loc><priority>1.0</priority></url>\n")
// 动态根据注册的工具生成路由
for id := range base.Registry {
fmt.Fprintf(&buf, " <url><loc>https://toolbox.pengzhan.dev/%s</loc><priority>0.8</priority></url>\n", id)
}
buf.WriteString("</urlset>")
c.Data(http.StatusOK, "application/xml; charset=utf-8", buf.Bytes())
})
// 3. 模板引擎初始化 // 3. 模板引擎初始化
// 递归加载 web 目录下所有的 .html 文件 // 递归加载 web 目录下所有的 .html 文件
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html") tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html")
@@ -85,37 +64,8 @@ func main() {
// 通用页面渲染函数 // 通用页面渲染函数
serveIndex := func(c *gin.Context) { serveIndex := func(c *gin.Context) {
path := c.Request.URL.Path
title := "探索工具箱"
desc := "基于 Go 语言构建的模块化个人工具箱,提供汉字字帖生成、数字学习等多种实用生产力工具。"
keywords := "个人工具箱, 汉字字帖生成, 书法练习, 数字学习, Own-Tools"
baseURL := "https://toolbox.pengzhan.dev"
canonical := baseURL + path
// 针对不同路径设置 SEO 标签
switch path {
case "/zitie":
title = "汉字字帖生成器 - 教学方格与步进式分解"
desc = "在线生成 2x3 教学方格字帖和 9 列步进式笔顺分解字帖,支持多种书法字体和古风排版。"
keywords = "字帖生成, 笔顺分解, 汉字教学, 书法字帖, 练字"
case "/learn-number":
title = "趣味数字学习工具"
desc = "为儿童设计的数字学习与计数练习工具,生动活泼,寓教于乐。"
keywords = "数字学习, 儿童计数, 幼小衔接"
case "/football":
title = "2026世界杯小组出线模拟器 - 蒙特卡洛预测"
desc = "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。"
keywords = "2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测"
case "/orbit-viewer":
title = "卫星轨道观测与AR实景寻星 - 实时轨迹与过境预测"
desc = "实时模拟空间站与卫星投影地面轨迹,精准预测未来10天最佳肉眼可见过境窗口,并支持手机AR实景摄像头与陀螺仪定向寻星。"
keywords = "卫星过境预测, 空间站追踪, ISS轨迹, AR寻星, 卫星观测, 轨道投影, 空间望远镜"
}
data := gin.H{ data := gin.H{
"Title": title, "Title": "Own-Tools",
"Description": desc,
"Keywords": keywords,
"CanonicalURL": canonical,
"GA_ID": gaID, "GA_ID": gaID,
} }
@@ -127,22 +77,15 @@ func main() {
} }
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
} }
r.GET("/", serveIndex) r.GET("/", serveIndex)
r.GET("/api/tools", func(c *gin.Context) { r.GET("/api/tools", func(c *gin.Context) {
var list []map[string]string var list []map[string]string
ids := make([]string, 0, len(base.Registry)) for _, t := range base.Registry {
for id := range base.Registry {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
t := base.Registry[id]
list = append(list, map[string]string{ list = append(list, map[string]string{
"id": t.ID(), "id": t.ID(),
"name": t.Name(), "name": t.Name(),
"desc": t.Description(), "desc": t.Description(),
"emoji": t.Emoji(),
}) })
} }
c.JSON(http.StatusOK, list) c.JSON(http.StatusOK, list)
@@ -161,7 +104,7 @@ func main() {
finalPort = "8080" finalPort = "8080"
} }
log.Printf("Toolbox HTTP server starting on :%s -> http://localhost:%s", finalPort, finalPort) log.Printf("Toolbox server starting on :%s", finalPort)
if err := r.Run(":" + finalPort); err != nil { if err := r.Run(":" + finalPort); err != nil {
log.Fatalf("Server failed: %v", err) log.Fatalf("Server failed: %v", err)
} }
-223
View File
@@ -1,223 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"testing"
"time"
"toolbox/pkg/base"
"github.com/gin-gonic/gin"
)
const (
testPort = "9999"
baseURL = "http://localhost:" + testPort
)
func TestOrbitViewerTemplatesAndRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
for id, tool := range base.Registry {
if err := tool.Init(); err != nil {
t.Fatalf("Failed to initialize tool %s: %v", id, err)
}
tool.RegisterRoutes(r.Group("/api/" + id))
}
subFS, err := fs.Sub(webFS, "web")
if err != nil {
t.Fatalf("Failed to fs.Sub: %v", err)
}
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html")
if err != nil {
t.Fatalf("Failed to parse templates: %v", err)
}
serveIndex := func(c *gin.Context) {
data := gin.H{
"Title": "测试",
"Description": "描述",
"Keywords": "关键词",
"CanonicalURL": "https://toolbox.pengzhan.dev/orbit-viewer",
"GA_ID": "",
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
c.String(http.StatusInternalServerError, "Template error: %v", err)
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
r.GET("/orbit-viewer", serveIndex)
// Test GET /orbit-viewer
req, _ := http.NewRequest("GET", "/orbit-viewer", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200 for /orbit-viewer, got %d: %s", w.Code, w.Body.String())
}
bodyStr := w.Body.String()
if !bytes.Contains([]byte(bodyStr), []byte("panel-orbit-viewer")) {
t.Errorf("Expected body to contain panel-orbit-viewer")
}
if !bytes.Contains([]byte(bodyStr), []byte("阶段一:卫星轨道与全球投影轨迹")) {
t.Errorf("Expected body to contain orbit viewer content")
}
}
func TestToolboxE2E(t *testing.T) {
// 1. Build the binary
fmt.Println("--- Step 1: Building binary... ---")
_ = os.MkdirAll("build", 0755)
buildPath := "build/toolbox_test_bin"
buildCmd := exec.Command("go", "build", "-o", buildPath, "main.go")
if err := buildCmd.Run(); err != nil {
t.Fatalf("Failed to build binary: %v", err)
}
defer func() { _ = os.Remove(buildPath) }()
// 2. Start the server
fmt.Println("--- Step 2: Starting server... ---")
serverCmd := exec.Command("./"+buildPath, "-port", testPort)
if err := serverCmd.Start(); err != nil {
t.Fatalf("Failed to start server: %v", err)
}
defer func() {
if serverCmd.Process != nil {
_ = serverCmd.Process.Kill()
}
}()
// 3. Wait for server to be ready
fmt.Println("--- Step 3: Waiting for server readiness... ---")
ready := false
for i := 0; i < 10; i++ {
resp, err := http.Get(baseURL + "/api/tools")
if err == nil && resp.StatusCode == http.StatusOK {
ready = true
_ = resp.Body.Close()
break
}
time.Sleep(500 * time.Millisecond)
}
if !ready {
t.Fatal("Server failed to become ready in time")
}
// 4. Test endpoints
fmt.Println("--- Step 4: Testing tool endpoints... ---")
testOutputDir, err := os.MkdirTemp("", "toolbox_test_output_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
fmt.Printf("--- Test Artifacts will be saved to: %s ---\n", testOutputDir)
tests := []struct {
name string
path string
method string
fileName string
body interface{}
}{
{
name: "Zitie Teaching",
path: "/api/zitie/teaching",
method: "POST",
fileName: "zitie_teaching.pdf",
body: map[string]string{"chars": "永和九年", "paper_size": "A4"},
},
{
name: "Zitie Step",
path: "/api/zitie/step",
method: "POST",
fileName: "zitie_step.pdf",
body: map[string]string{"chars": "永", "paper_size": "A4"},
},
{
name: "Zitie Manuscript",
path: "/api/zitie/manuscript",
method: "POST",
fileName: "zitie_manuscript.pdf",
body: map[string]string{"chars": "天道酬勤", "paper_size": "A4", "font_type": "kaiti"},
},
{
name: "Learn Number Counting",
path: "/api/learn-number/counting",
method: "POST",
fileName: "counting.pdf",
body: map[string]interface{}{"page_count": 1, "paper_size": "A4"},
},
{
name: "Learn Number Writing",
path: "/api/learn-number/writing",
method: "POST",
fileName: "writing.pdf",
body: map[string]interface{}{"start_num": 1, "end_num": 5, "paper_size": "A4"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp *http.Response
var err error
if tt.method == "POST" {
b, _ := json.Marshal(tt.body)
resp, err = http.Post(baseURL+tt.path, "application/json", bytes.NewBuffer(b))
} else {
resp, err = http.Get(baseURL + tt.path)
}
if err != nil {
t.Errorf("%s failed: %v", tt.name, err)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("%s returned status %d", tt.name, resp.StatusCode)
return
}
contentType := resp.Header.Get("Content-Type")
if contentType != "application/pdf" {
t.Errorf("%s expected application/pdf, got %s", tt.name, contentType)
} else {
// Save PDF to temp directory for human review
outPath := testOutputDir + "/" + tt.fileName
f, err := os.Create(outPath)
if err != nil {
t.Errorf("Failed to create output file: %v", err)
return
}
defer func() { _ = f.Close() }()
_, err = f.ReadFrom(resp.Body)
if err != nil {
t.Errorf("Failed to save PDF: %v", err)
return
}
fmt.Printf("[PASS] %s: Saved to %s\n", tt.name, outPath)
}
})
}
fmt.Printf("\n--- ALL TESTS DONE. PLEASE REVIEW PDFS IN: %s ---\n", testOutputDir)
}
+67 -37
View File
@@ -1,50 +1,80 @@
{{define "content"}} {{define "content"}}
<!-- Dashboard / Welcome Panel -->
<div id="panel-welcome" class="tool-panel active"> <!-- 注入各个工具面板 -->
{{template "zitie" .}}
{{template "learn_number" .}}
<!-- 欢迎面板 -->
<div id="panel-welcome" class="tool-panel">
<header class="page-header"> <header class="page-header">
<h1>探索工具箱</h1> <h1>欢迎使用个人工具箱</h1>
<p>欢迎使用个人生产力助手,点击下方卡片开始工作。</p> <p>这是您的私人效率基地。请从侧边栏或下方列表选择一个功能开始。</p>
</header> </header>
<div id="tools-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px;"> <div style="margin-top: 40px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px;">
<!-- JS 动态注入 --> <div class="card" style="cursor:pointer; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-5px)'" onmouseout="this.style.transform='translateY(0)'" onclick="navigateTo('/zitie')">
<h3 style="margin-top:0;">🖋️ 汉字字帖生成</h3>
<p style="color: #86868b; line-height: 1.5;">支持多种书法字体的 2x3 教学方格、步进分解和古风竖排信纸。</p>
<div style="color: var(--apple-blue); font-weight: 600; margin-top: 16px;">立即开始 →</div>
</div>
<div class="card" style="cursor:pointer; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-5px)'" onmouseout="this.style.transform='translateY(0)'" onclick="navigateTo('/learn-number')">
<h3 style="margin-top:0;">🔢 幼儿数学助手</h3>
<p style="color: #86868b; line-height: 1.5;">数图形、基础算术等趣味练习。培养孩子的数感与逻辑。</p>
<div style="color: var(--apple-blue); font-weight: 600; margin-top: 16px;">立即开始 →</div>
</div>
</div> </div>
</div> </div>
<!-- Tools Panels Containers -->
{{template "zitie" .}}
{{template "learn_number" .}}
{{template "football" .}}
{{template "orbit_viewer" .}}
<script> <script>
// 首页 Tile 渲染 // --- Zitie Tool Logic ---
async function renderDashboard() { let currentZitieMode = 'teaching';
try { function switchZitieTab(mode) {
const response = await fetch('/api/tools'); currentZitieMode = mode;
const tools = await response.json(); document.querySelectorAll('#panel-zitie .tab-btn').forEach(btn => btn.classList.remove('active'));
const grid = document.getElementById('tools-grid'); document.getElementById(`tab-${mode}`).classList.add('active');
grid.innerHTML = ''; document.getElementById('font-select-group').style.display = (mode === 'manuscript') ? 'block' : 'none';
tools.forEach(tool => {
const card = document.createElement('div');
card.className = 'card';
card.style.cssText = 'cursor: pointer; transition: all 0.3s ease; display: flex; flex-direction: column; gap: 12px; height: 100%;';
card.innerHTML = `
<div style="font-size: 40px;">${tool.emoji || '🛠️'}</div>
<h3 style="margin: 0; font-size: 20px; font-weight: 700;">${tool.name}</h3>
<p style="margin: 0; color: #86868b; font-size: 15px; line-height: 1.4;">${tool.desc}</p>
`;
card.onclick = () => navigateTo(`/${tool.id}`);
card.onmouseover = () => card.style.transform = 'translateY(-5px)';
card.onmouseout = () => card.style.transform = 'translateY(0)';
grid.appendChild(card);
});
} catch (e) { console.error(e); }
} }
// 页面加载完成后执行 async function generateZitiePDF() {
document.addEventListener('DOMContentLoaded', () => { const chars = document.getElementById('chars').value;
renderDashboard(); const paper_size = document.getElementById('paper_size').value;
const font_type = document.getElementById('font_type').value;
const btn = document.querySelector('#panel-zitie button');
btn.innerText = '绘图中...'; btn.disabled = true;
try {
const response = await fetch(`/api/zitie/${currentZitieMode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chars, paper_size, font_type })
}); });
if (response.ok) {
const blob = await response.blob();
document.getElementById('zitie-preview').style.display = 'block';
document.getElementById('zitie-frame').src = URL.createObjectURL(blob);
}
} catch (e) { alert('错误'); } finally { btn.innerText = '生成字帖预览'; btn.disabled = false; }
}
// --- Math Tool Logic ---
async function generateMathPDF() {
const total_count = parseInt(document.getElementById('total_count').value);
const icon_types = parseInt(document.getElementById('icon_types').value);
const paper_size = document.getElementById('math_paper_size').value;
const btn = document.querySelector('#panel-learn-number button');
btn.innerText = '生成中...'; btn.disabled = true;
try {
const response = await fetch('/api/learn-number/counting', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ total_count, icon_types, paper_size })
});
if (response.ok) {
const blob = await response.blob();
document.getElementById('math-preview').style.display = 'block';
document.getElementById('math-frame').src = URL.createObjectURL(blob);
if(window.innerWidth < 768) document.getElementById('math-preview').scrollIntoView({behavior: 'smooth'});
}
} catch (e) { alert('错误'); } finally { btn.innerText = '生成数学练习帖'; btn.disabled = false; }
}
</script> </script>
{{end}} {{end}}
+6 -70
View File
@@ -4,24 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} | Own-Tools</title> <title>{{.Title}} | Own-Tools</title>
<meta name="description" content="{{.Description}}">
<meta name="keywords" content="{{.Keywords}}">
<link rel="canonical" href="{{.CanonicalURL}}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="{{.CanonicalURL}}">
<meta property="og:title" content="{{.Title}} | Own-Tools">
<meta property="og:description" content="{{.Description}}">
<meta property="og:image" content="/static/og-image.png">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="{{.CanonicalURL}}">
<meta property="twitter:title" content="{{.Title}} | Own-Tools">
<meta property="twitter:description" content="{{.Description}}">
<meta property="twitter:image" content="/static/og-image.png">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖋️</text></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖋️</text></svg>">
{{if .GA_ID}} {{if .GA_ID}}
@@ -57,21 +39,19 @@
.nav-item:hover { background: rgba(0,0,0,0.04); color: #000; } .nav-item:hover { background: rgba(0,0,0,0.04); color: #000; }
.nav-item.active { background: var(--apple-blue); color: #ffffff; } .nav-item.active { background: var(--apple-blue); color: #ffffff; }
.main-container { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; width: 100%; max-width: 100vw; } .main-container { flex: 1; display: flex; flex-direction: column; overflow-y: auto; width: 100%; }
.main-content { padding: 40px 60px; width: 100%; max-width: 100%; box-sizing: border-box; } .main-content { padding: 40px 60px; width: 100%; }
/* Unified Page Header */ /* Unified Page Header */
.page-header { margin-bottom: 32px; } .page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 34px; font-weight: 700; margin: 0 0 8px 0; word-break: break-word; } .page-header h1 { font-size: 34px; font-weight: 700; margin: 0 0 8px 0; }
.page-header p { font-size: 18px; color: #86868b; margin: 0; } .page-header p { font-size: 18px; color: #86868b; margin: 0; }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { position: fixed; left: -260px; height: 100%; } .sidebar { position: fixed; left: -260px; height: 100%; }
#menu-toggle:checked ~ .sidebar { transform: translateX(260px); } #menu-toggle:checked ~ .sidebar { transform: translateX(260px); }
.menu-btn { display: block; } .menu-btn { display: block; }
.main-content { padding: 70px 14px 20px; width: 100vw; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } .main-content { padding: 80px 20px 20px; }
.page-header h1 { font-size: 24px; }
.page-header p { font-size: 14px; }
} }
.tool-panel { display: none; } .tool-panel { display: none; }
@@ -133,7 +113,7 @@
tools.forEach(tool => { tools.forEach(tool => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'nav-item'; item.id = `nav-${tool.id}`; item.className = 'nav-item'; item.id = `nav-${tool.id}`;
item.innerHTML = `<span>${tool.emoji || '🛠️'}</span> ${tool.name}`; item.innerHTML = `<span>🛠️</span> ${tool.name}`;
item.onclick = () => { navigateTo(`/${tool.id}`); document.getElementById('menu-toggle').checked = false; }; item.onclick = () => { navigateTo(`/${tool.id}`); document.getElementById('menu-toggle').checked = false; };
navList.appendChild(item); navList.appendChild(item);
}); });
@@ -146,51 +126,10 @@
renderCurrentPath(); renderCurrentPath();
} }
const metaConfig = {
'welcome': {
title: '探索工具箱',
desc: '基于 Go 语言构建的模块化个人工具箱,提供汉字字帖生成、数字学习等多种实用生产力工具。',
keywords: '个人工具箱, 汉字字帖生成, 书法练习, 数字学习, Own-Tools'
},
'zitie': {
title: '汉字字帖生成器 - 教学方格与步进式分解',
desc: '在线生成 2x3 教学方格字帖和 9 列步进式笔顺分解字帖,支持多种书法字体和古风排版。',
keywords: '字帖生成, 笔顺分解, 汉字教学, 书法字帖, 练字'
},
'learn-number': {
title: '趣味数字学习工具',
desc: '为儿童设计的数字学习与计数练习工具,生动活泼,寓教力乐。',
keywords: '数字学习, 儿童计数, 幼小衔接'
},
'football': {
title: '2026世界杯小组出线模拟器',
desc: '使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。',
keywords: '2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测'
},
'orbit-viewer': {
title: '卫星轨道观测与AR实景寻星',
desc: '实时模拟空间站与卫星投影地面轨迹,精准预测未来10天最佳肉眼可见过境窗口,并支持手机AR实景摄像头与陀螺仪定向寻星。',
keywords: '卫星过境预测, 空间站追踪, ISS轨迹, AR寻星, 卫星观测, 轨道投影, 空间望远镜'
}
};
function renderCurrentPath() { function renderCurrentPath() {
const path = window.location.pathname.replace('/', '') || 'welcome'; const path = window.location.pathname.replace('/', '') || 'welcome';
// 1. 更新标题与元数据 // 隐藏所有面板
const meta = metaConfig[path] || metaConfig['welcome'];
document.title = `${meta.title} | Own-Tools`;
document.querySelector('meta[name="description"]').setAttribute('content', meta.desc);
document.querySelector('meta[name="keywords"]').setAttribute('content', meta.keywords);
// 更新 Open Graph 标签 (可选,社交分享主要看服务端渲染)
const canonical = `https://toolbox.pengzhan.dev/${path === 'welcome' ? '' : path}`;
document.querySelector('link[rel="canonical"]').setAttribute('href', canonical);
document.querySelector('meta[property="og:title"]').setAttribute('content', `${meta.title} | Own-Tools`);
document.querySelector('meta[property="og:description"]').setAttribute('content', meta.desc);
document.querySelector('meta[property="og:url"]').setAttribute('content', canonical);
// 2. 隐藏所有面板
document.querySelectorAll('.tool-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.tool-panel').forEach(p => p.classList.remove('active'));
// 显示目标面板 // 显示目标面板
@@ -206,9 +145,6 @@
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const activeNav = document.getElementById(`nav-${path}`) || document.getElementById('nav-welcome'); const activeNav = document.getElementById(`nav-${path}`) || document.getElementById('nav-welcome');
if (activeNav) activeNav.classList.add('active'); if (activeNav) activeNav.classList.add('active');
// 触发自定义面板切换事件,通知工具初始化或重设尺寸
window.dispatchEvent(new CustomEvent('panel-changed', { detail: { path: path } }));
} }
window.onpopstate = renderCurrentPath; window.onpopstate = renderCurrentPath;
-398
View File
@@ -1,398 +0,0 @@
{{define "football"}}
<div id="panel-football" class="tool-panel">
<header class="page-header">
<h1 style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<span>⚽</span> 足球概率模拟
</h1>
<p style="font-size: 16px; color: #86868b; margin: 0;">使用蒙特卡洛算法进行足球赛事出线概率模拟计算。</p>
</header>
<div class="tabs-container">
<div class="tab-btn active" id="tab-btn-wc">2026世界杯小组赛模拟</div>
<div class="tab-btn" id="tab-btn-afc" style="cursor: default; opacity: 0.5; font-weight: normal;" title="暂未开放">18(3*6) AFC qualification</div>
</div>
<!-- Tab 1: World Cup Simulator -->
<div id="football-tab-wc" class="football-tab-content">
<!-- Inputs Group Boxes -->
<div style="margin-bottom: 24px;">
<h2 style="font-size: 18px; font-weight: 700; color: #1d1d1f; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center;">
<span>📅 小组赛比分录入 (Completed Matches by Group)</span>
<button type="button" class="btn-sec" onclick="resetMatchesToDefault()" style="height: 28px; padding: 0 10px; font-size: 12px;">重置默认比分</button>
</h2>
<div id="groups-input-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px;">
<!-- JS 动态注入 12 个小组盒子 -->
</div>
</div>
<!-- Actions -->
<div class="card" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 24px;">
<div style="display: flex; align-items: center; gap: 10px;">
<label for="sim-count" style="font-size: 15px; font-weight: 600;">模拟次数 (Runs):</label>
<select id="sim-count" style="width: 140px; height: 38px; padding: 0 10px; font-size: 14px;">
<option value="10000">10,000 (快速)</option>
<option value="50000" selected>50,000 (标准)</option>
<option value="100000">100,000 (精准)</option>
</select>
</div>
<button id="run-simulation-btn" onclick="startSimulation()" style="min-width: 180px; height: 44px; font-size: 15px;">
🚀 运行蒙特卡洛模拟
</button>
</div>
<!-- Loading -->
<div id="simulation-loading" style="display: none; text-align: center; padding: 30px; margin-bottom: 24px;" class="card">
<div class="spinner" style="border: 3px solid rgba(0,0,0,0.1); width: 40px; height: 40px; border-radius: 50%; border-left-color: var(--apple-blue); animation: spin 1s linear infinite; margin: 0 auto 12px;"></div>
<p style="font-size: 15px; color: #86868b; margin: 0;">正在进行蒙特卡洛模拟计算,请稍候...</p>
</div>
<!-- Results -->
<div id="simulation-results" style="display: none;">
<div class="card" style="margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h2 style="font-size: 20px; font-weight: 700; margin: 0;">📈 模拟预测结果</h2>
<span id="sim-duration-badge" style="font-size: 14px; color: #86868b; background: #f5f5f7; padding: 4px 10px; border-radius: 8px;"></span>
</div>
<!-- Group Cards Grid -->
<div id="groups-results-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px;">
<!-- Group cards will be generated here -->
</div>
</div>
</div>
</div>
<style>
.btn-sec {
background-color: #f5f5f7;
color: #1d1d1f;
border: 1px solid #d2d2d7;
border-radius: 6px;
padding: 0 12px;
height: 32px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-sec:hover {
background-color: #e5e5e7;
}
.score-input {
width: 32px;
text-align: center;
border: 1px solid #d2d2d7;
border-radius: 4px;
padding: 2px;
font-size: 13px;
box-sizing: border-box;
}
.flag-preview {
font-size: 16px;
min-width: 20px;
display: inline-block;
text-align: center;
}
/* Group Card */
.group-input-card {
background: white;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
gap: 12px;
}
.group-input-card h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #1d1d1f;
border-bottom: 1px solid #f5f5f7;
padding-bottom: 8px;
}
/* Group Result Card */
.group-result-card {
background: white;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
gap: 12px;
}
.group-result-card h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1d1d1f;
border-bottom: 1px solid #f5f5f7;
padding-bottom: 8px;
display: flex;
justify-content: space-between;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<script>
const FIFA_TO_FLAG = {
'MEX': '🇲🇽', 'RSA': '🇿🇦', 'KOR': '🇰🇷', 'CZE': '🇨🇿',
'CAN': '🇨🇦', 'QAT': '🇶🇦', 'SUI': '🇨🇭', 'BIH': '🇧🇦',
'BRA': '🇧🇷', 'MAR': '🇲🇦', 'HAI': '🇭🇹', 'SCO': '🏴󠁧󠁢󠁳󠁣󠁴󠁿',
'USA': '🇺🇸', 'PAR': '🇵🇾', 'AUS': '🇦🇺', 'TUR': '🇹🇷',
'GER': '🇩🇪', 'CUW': '🇨🇼', 'CIV': '🇨🇮', 'ECU': '🇪🇨',
'NED': '🇳🇱', 'JPN': '🇯🇵', 'TUN': '🇹🇳', 'SWE': '🇸🇪',
'BEL': '🇧🇪', 'EGY': '🇪🇬', 'IRN': '🇮🇷', 'NZL': '🇳🇿',
'ESP': '🇪🇸', 'KSA': '🇸🇦', 'URU': '🇺🇾', 'CPV': '🇨🇻',
'FRA': '🇫🇷', 'SEN': '🇸🇳', 'NOR': '🇳🇴', 'COD': '🇨🇩',
'ARG': '🇦🇷', 'ALG': '🇩🇿', 'AUT': '🇦🇹', 'JOR': '🇯🇴',
'POR': '🇵🇹', 'COL': '🇨🇴', 'UZB': '🇺🇿', 'IRQ': '🇮🇶',
'ENG': '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'CRO': '🇭🇷', 'GHA': '🇬🇭', 'PAN': '🇵🇦'
};
function getFlag(teamCode) {
if (!teamCode) return '🏴';
return FIFA_TO_FLAG[teamCode.trim().toUpperCase()] || '🏴';
}
let defaultPayload = null;
async function loadFootballDefaults() {
try {
const response = await fetch('/api/football/defaults');
defaultPayload = await response.json();
resetMatchesToDefault();
} catch (e) {
console.error('Failed to load defaults:', e);
}
}
function resetMatchesToDefault() {
if (!defaultPayload) return;
renderGroupsInputGrid();
}
// Render the 12 input boxes programmatically
function renderGroupsInputGrid() {
const grid = document.getElementById('groups-input-grid');
grid.innerHTML = '';
const groups = defaultPayload.groups;
const completedMatches = defaultPayload.completed_matches;
// Sorted group keys (A to L)
const sortedGroups = Object.keys(groups).sort();
sortedGroups.forEach(grpName => {
const grpTeams = groups[grpName]; // Array of 4 teams
// Create Card Container
const card = document.createElement('div');
card.className = 'group-input-card';
let cardContentHTML = `<h3>小组 ${grpName} 比赛比分录入</h3>`;
cardContentHTML += `<table style="width: 100%; border-collapse: collapse; font-size: 13px;"><tbody>`;
// Filter completedMatches for matches belonging to this group (preserves chronological order)
const pairings = [];
completedMatches.forEach(m => {
if (grpTeams.includes(m.team_a) && grpTeams.includes(m.team_b)) {
pairings.push({
teamA: m.team_a,
teamB: m.team_b,
scoreA: m.score_a,
scoreB: m.score_b,
isCompleted: m.is_completed
});
}
});
// Render each pairing row
pairings.forEach(p => {
let scoreA = p.scoreA;
let scoreB = p.scoreB;
let isCompleted = p.isCompleted;
cardContentHTML += `
<tr class="match-input-row" data-team-a="${p.teamA}" data-team-b="${p.teamB}" style="border-bottom: 1px solid #f5f5f7;">
<td style="padding: 8px 0; display: flex; align-items: center; gap: 4px; height: 38px;">
<span class="flag-preview">${getFlag(p.teamA)}</span>
<span style="font-weight: 600;">${p.teamA}</span>
</td>
<td style="padding: 8px 0; text-align: center;">
<div style="display: inline-flex; align-items: center; gap: 3px;">
<input type="number" class="score-input score-a" value="${scoreA}" min="0">
<span style="color: #86868b;">:</span>
<input type="number" class="score-input score-b" value="${scoreB}" min="0">
</div>
</td>
<td style="padding: 8px 0; text-align: right;">
<div style="display: inline-flex; align-items: center; gap: 4px; justify-content: flex-end;">
<span style="font-weight: 600;">${p.teamB}</span>
<span class="flag-preview">${getFlag(p.teamB)}</span>
</div>
</td>
<td style="padding: 8px 0; text-align: right; width: 56px;">
<label style="display: inline-flex; align-items: center; gap: 3px; font-size: 11px; color: #86868b; cursor: pointer; margin: 0;">
<input type="checkbox" class="completed-chk" ${isCompleted ? 'checked' : ''} style="cursor: pointer; width: 13px; height: 13px; margin: 0;">
完赛
</label>
</td>
</tr>
`;
});
cardContentHTML += `</tbody></table>`;
card.innerHTML = cardContentHTML;
grid.appendChild(card);
});
}
async function startSimulation() {
const runBtn = document.getElementById('run-simulation-btn');
const loadingDiv = document.getElementById('simulation-loading');
const resultsDiv = document.getElementById('simulation-results');
runBtn.disabled = true;
loadingDiv.style.display = 'block';
resultsDiv.style.display = 'none';
try {
if (!defaultPayload) {
alert('默认数据尚未加载完成,请稍候再试。');
return;
}
const scoreOutcomes = defaultPayload.score_outcomes;
const groups = defaultPayload.groups;
const completedMatches = [];
const rows = document.querySelectorAll('.match-input-row');
rows.forEach(row => {
const teamA = row.getAttribute('data-team-a');
const teamB = row.getAttribute('data-team-b');
const scoreA = parseInt(row.querySelector('.score-a').value);
const scoreB = parseInt(row.querySelector('.score-b').value);
const isCompleted = row.querySelector('.completed-chk').checked;
completedMatches.push({
team_a: teamA,
team_b: teamB,
score_a: isNaN(scoreA) ? 0 : scoreA,
score_b: isNaN(scoreB) ? 0 : scoreB,
is_completed: isCompleted
});
});
const simulations = parseInt(document.getElementById('sim-count').value);
const startTime = performance.now();
const response = await fetch('/api/football/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
groups,
completed_matches: completedMatches,
score_outcomes: scoreOutcomes,
simulations
})
});
const results = await response.json();
const endTime = performance.now();
renderGroupedResults(results, endTime - startTime);
loadingDiv.style.display = 'none';
resultsDiv.style.display = 'block';
} catch (err) {
alert('计算出错!' + err.message);
loadingDiv.style.display = 'none';
} finally {
runBtn.disabled = false;
}
}
function renderGroupedResults(results, clientDuration) {
const grid = document.getElementById('groups-results-grid');
grid.innerHTML = '';
document.getElementById('sim-duration-badge').innerText = `耗时: ${(clientDuration / 1000).toFixed(2)} 秒`;
// Group the flat list by group name
const grouped = {};
results.forEach(r => {
if (!grouped[r.group]) {
grouped[r.group] = [];
}
grouped[r.group].push(r);
});
// Get sorted groups keys
const sortedGroups = Object.keys(grouped).sort();
sortedGroups.forEach(grpName => {
const groupTeams = grouped[grpName];
// Create a group card
const card = document.createElement('div');
card.className = 'group-result-card';
let tableRowsHTML = '';
groupTeams.forEach((teamProb, idx) => {
const flag = getFlag(teamProb.team_name);
const isQualifying = idx < 2; // top 2
const rankColor = isQualifying ? '#34c759' : '#86868b';
tableRowsHTML += `
<tr style="border-bottom: 1px solid #f5f5f7;">
<td style="padding: 8px 0; font-weight: bold; color: ${rankColor}; width: 20px;">${idx + 1}</td>
<td style="padding: 8px 0; display: flex; align-items: center; gap: 6px;">
<span>${flag}</span>
<span style="font-weight: 600;">${teamProb.team_name}</span>
</td>
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 13px;" title="直接出线概率 (小组前二)">
${(teamProb.direct_qual_prob * 100).toFixed(1)}%
</td>
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 13px; color: #86868b;" title="最佳第三名出线概率">
${(teamProb.third_qual_prob * 100).toFixed(1)}%
</td>
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 13px; font-weight: bold; color: var(--apple-blue);" title="总出线概率">
${(teamProb.total_qual_prob * 100).toFixed(1)}%
</td>
</tr>
`;
});
card.innerHTML = `
<h3>
<span>小组 ${grpName}</span>
<span style="font-size: 12px; font-weight: normal; color: #86868b;">前2名 / 第3名 / 总出线</span>
</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<tbody>
${tableRowsHTML}
</tbody>
</table>
`;
grid.appendChild(card);
});
}
document.addEventListener('DOMContentLoaded', () => {
loadFootballDefaults();
});
</script>
{{end}}
+19 -172
View File
@@ -2,190 +2,37 @@
<div id="panel-learn-number" class="tool-panel"> <div id="panel-learn-number" class="tool-panel">
<header class="page-header"> <header class="page-header">
<h1>幼儿数学助手</h1> <h1>幼儿数学助手</h1>
<p>通过趣味游戏,培养孩子的数感、逻辑思维与书写能力。</p> <p>通过趣味图形和基础练习,培养孩子的数感与逻辑。</p>
</header> </header>
<div class="tabs-container"> <div class="tabs-container">
<div id="tab-counting" class="tab-btn active" onclick="switchMathTab('counting')">数图形练习</div> <div id="tab-counting" class="tab-btn active">数图形练习</div>
<div id="tab-writing" class="tab-btn" onclick="switchMathTab('writing')">数字连连看</div>
</div> </div>
<div class="card">
<!-- 数图形面板 --> <div style="display: flex; flex-direction: column; gap: 24px;">
<div id="math-counting-controls" class="card"> <div style="display: flex; gap: 30px; flex-wrap: wrap;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 20px; align-items: flex-end;"> <div style="flex: 1; min-width: 250px;">
<div class="input-group"> <label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 12px;">图标种类数 (1 - 5)</label>
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">练习主题</label> <input type="range" id="icon_types" min="1" max="5" value="3" style="width: 100%;" oninput="this.nextElementSibling.value = this.value">
<select id="math_category" onchange="updateIconPreview()" class="apple-select"> <output style="font-size: 18px; font-weight: 700; color: var(--apple-blue); margin-top: 10px; display: block;">3</output>
<option value="fruits">素材库 (SVG)</option>
<option value="shapes">基础几何图形</option>
</select>
</div> </div>
<div class="input-group"> <div style="flex: 1; min-width: 250px;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">种类 (Max 6)</label> <label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 12px;">总图案数量 (种类数 - 30)</label>
<input type="number" id="icon_types" min="1" max="6" value="3" class="apple-input"> <input type="range" id="total_count" min="5" max="30" value="15" style="width: 100%;" oninput="this.nextElementSibling.value = this.value">
<output style="font-size: 18px; font-weight: 700; color: var(--apple-blue); margin-top: 10px; display: block;">15</output>
</div> </div>
<div class="input-group"> <div style="width: 240px;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">总量 (Max 30)</label> <label>纸张大小</label>
<input type="number" id="total_count" min="1" max="30" value="15" class="apple-input"> <select id="math_paper_size">
</div>
<div class="input-group">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">页数 (1-10)</label>
<input type="number" id="page_count" min="1" max="10" value="1" class="apple-input">
</div>
<div class="input-group">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张</label>
<select id="math_paper_size" class="apple-select">
<option value="Letter" selected>Letter</option>
<option value="A4">A4</option> <option value="A4">A4</option>
<option value="Letter">Letter</option>
</select> </select>
</div> </div>
<button onclick="generateMathPDF()" id="btn-math-counting">生成练习帖</button>
</div> </div>
<div id="icon-preview-container" style="margin-top: 24px; padding: 20px; background: #fbfbfd; border-radius: 12px; border: 1px solid #e5e5e7;"> <button onclick="generateMathPDF()">生成数学练习帖</button>
<div id="icon-list" style="display: flex; gap: 12px; flex-wrap: wrap;"></div>
</div> </div>
</div> </div>
<div id="math-preview" style="display:none; width: 100%; height: 800px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
<!-- 数字连连看面板 (已简化页数选择) -->
<div id="math-writing-controls" class="card" style="display:none;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 24px; align-items: flex-end;">
<div class="input-group">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">起始数字</label>
<input type="number" id="start_num" min="0" value="1" class="apple-input">
</div>
<div class="input-group">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">结束数字</label>
<input type="number" id="end_num" min="1" value="15" class="apple-input">
</div>
<div class="input-group">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张大小</label>
<select id="write_paper_size" class="apple-select">
<option value="Letter" selected>Letter (8.5x11in)</option>
<option value="A4">A4 (210x297mm)</option>
</select>
</div>
<button onclick="generateWritingPDF()" id="btn-math-writing">生成闯关地图</button>
</div>
<p style="margin-top: 16px; font-size: 13px; color: #86868b;">💡 规则:系统将根据数字范围自动分页(每页约 15 个)。描红数字,并寻找下一个数字进行连线。</p>
</div>
<div id="math-error-msg" style="color: #ff3b30; font-size: 14px; display: none; font-weight: 500; text-align: center; padding: 10px; background: #fff2f2; border-radius: 8px; margin-bottom: 20px;"></div>
<div id="math-preview" style="display:none; width: 100%; height: 850px; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.12); background: #fff; border: 1px solid #d2d2d7;">
<iframe id="math-frame" style="width:100%; height:100%; border:none;"></iframe> <iframe id="math-frame" style="width:100%; height:100%; border:none;"></iframe>
</div> </div>
</div> </div>
<style>
.apple-input, .apple-select {
width: 100%; height: 46px; padding: 0 12px; border: 1px solid #d2d2d7; border-radius: 10px;
font-size: 15px; font-weight: 500; background-color: #ffffff; transition: all 0.2s ease;
outline: none; box-shadow: inset 0 1px 2px rgba(0,0,0,0.05); line-height: 46px;
}
.apple-input:focus, .apple-select:focus { border-color: #0071e3; box-shadow: 0 0 0 4px rgba(0,113,227,0.15); }
.icon-thumbnail {
width: 52px; height: 52px; background: white; border-radius: 10px; border: 1px solid #e5e5e7;
display: flex; align-items: center; justify-content: center; padding: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.04); transition: all 0.2s ease;
}
</style>
<script>
let mathTab = 'counting';
function switchMathTab(tab) {
mathTab = tab;
document.querySelectorAll('#panel-learn-number .tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(`tab-${tab}`).classList.add('active');
document.getElementById('math-counting-controls').style.display = (tab === 'counting') ? 'block' : 'none';
document.getElementById('math-writing-controls').style.display = (tab === 'writing') ? 'block' : 'none';
}
let allCategories = {};
async function initMathTool() {
try {
const res = await fetch('/api/learn-number/categories');
allCategories = await res.json();
updateIconPreview();
} catch (e) { console.error(e); }
}
function calculateBBox(paths) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const numRegex = /-?\d+\.?\d*/g;
paths.forEach(path => {
const matches = path.match(numRegex);
if (matches) {
for (let i=0; i<matches.length; i+=2) {
const x = parseFloat(matches[i]); const y = parseFloat(matches[i+1]);
if (!isNaN(x) && !isNaN(y)) {
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
}
});
if (minX === Infinity) return "0 0 1024 1024";
const w = maxX - minX, h = maxY - minY;
const padding = Math.max(w, h) * 0.15;
return `${minX - padding} ${minY - padding} ${w + padding*2} ${h + padding*2}`;
}
function updateIconPreview() {
const cat = document.getElementById('math_category').value;
const icons = allCategories[cat] || [];
const container = document.getElementById('icon-list');
container.innerHTML = '';
icons.forEach(icon => {
const div = document.createElement('div');
div.className = 'icon-thumbnail';
const viewBox = calculateBBox(icon.Paths);
const pathsHtml = icon.Paths.map(p => `<path d="${p}" fill="none" stroke="black" stroke-width="2%" stroke-linecap="round" stroke-linejoin="round" />`).join('');
div.innerHTML = `<svg viewBox="${viewBox}" style="width: 100%; height: 100%; overflow: visible;">${pathsHtml}</svg>`;
container.appendChild(div);
});
}
async function generateMathPDF() {
const total_count = parseInt(document.getElementById('total_count').value);
const icon_types = parseInt(document.getElementById('icon_types').value);
const page_count = parseInt(document.getElementById('page_count').value || 1);
const category = document.getElementById('math_category').value;
const paper_size = document.getElementById('math_paper_size').value;
if (total_count < icon_types) { alert('总量必须大于种类'); return; }
const btn = document.getElementById('btn-math-counting');
btn.innerText = '生成中...'; btn.disabled = true;
try {
const response = await fetch('/api/learn-number/counting', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ total_count, icon_types, page_count, category, paper_size })
});
const blob = await response.blob();
document.getElementById('math-preview').style.display = 'block';
document.getElementById('math-frame').src = URL.createObjectURL(blob);
} finally { btn.innerText = '生成练习帖'; btn.disabled = false; }
}
async function generateWritingPDF() {
const start_num = parseInt(document.getElementById('start_num').value);
const end_num = parseInt(document.getElementById('end_num').value);
const paper_size = document.getElementById('write_paper_size').value;
if (end_num <= start_num) { alert('结束数字必须大于起始数字'); return; }
const btn = document.getElementById('btn-math-writing');
btn.innerText = '生成中...'; btn.disabled = true;
try {
const response = await fetch('/api/learn-number/writing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_num, end_num, paper_size })
});
const blob = await response.blob();
document.getElementById('math-preview').style.display = 'block';
document.getElementById('math-frame').src = URL.createObjectURL(blob);
} finally { btn.innerText = '生成闯关地图'; btn.disabled = false; }
}
setTimeout(initMathTool, 200);
</script>
{{end}} {{end}}
File diff suppressed because it is too large Load Diff
+23 -57
View File
@@ -1,77 +1,43 @@
{{define "zitie"}} {{define "zitie"}}
<div id="panel-zitie" class="tool-panel"> <div id="panel-zitie" class="tool-panel">
<header class="page-header"> <header class="page-header">
<h1>汉字字帖生成</h1> <h1>汉字字帖生成器</h1>
<p>提供智能缺字处理和古风排版的专业字帖工具。</p> <p>生成高颜值的硬笔/毛笔书法练习帖。</p>
</header> </header>
<div class="tabs-container"> <div class="tabs-container">
<div id="tab-teaching" class="tab-btn active" onclick="switchZitieMode('teaching')">教学版 (2x3)</div> <div id="tab-teaching" class="tab-btn active" onclick="switchZitieTab('teaching')">2x3 教学方格</div>
<div id="tab-step" class="tab-btn" onclick="switchZitieMode('step')">临摹版 (9列)</div> <div id="tab-step" class="tab-btn" onclick="switchZitieTab('step')">步进式分解</div>
<div id="tab-manuscript" class="tab-btn" onclick="switchZitieMode('manuscript')">古风纵写</div> <div id="tab-manuscript" class="tab-btn" onclick="switchZitieTab('manuscript')">古风竖排</div>
</div> </div>
<div class="card"> <div class="card">
<div style="display: flex; flex-direction: column; gap: 20px;"> <div style="display: flex; flex-direction: column; gap: 24px;">
<textarea id="chars" rows="5" placeholder="请输入汉字" style="resize: vertical; min-height: 120px;">天道酬勤 厚德载物</textarea> <div class="input-group">
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-end;"> <label id="input-label">输入汉字内容</label>
<textarea id="chars" placeholder="支持多行输入...">永和九年,岁在癸丑。</textarea>
</div>
<div style="display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px;"> <div style="flex: 1; min-width: 200px;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">纸张大小</label> <label>纸张大小</label>
<select id="paper_size" class="apple-select"> <select id="paper_size">
<option value="Letter" selected>Letter (8.5x11in)</option>
<option value="A4">A4 (210x297mm)</option> <option value="A4">A4 (210x297mm)</option>
<option value="Letter">Letter (8.5x11in)</option>
</select> </select>
</div> </div>
<div id="font-type-container" style="flex: 1; min-width: 200px; display: none;"> <div id="font-select-group" style="flex: 1; min-width: 200px; display: none;">
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">书法字体</label> <label>书法字体</label>
<select id="font_type" class="apple-select"> <select id="font_type">
<option value="kaiti">华光楷体 (推荐)</option> <option value="kaiti">华光楷体</option>
<option value="songti">华光书宋</option>
<option value="lishu">华光隶变</option>
<option value="xingshu">华光行草</option> <option value="xingshu">华光行草</option>
<option value="lishu">华光隶变</option>
<option value="songti">华光书宋</option>
</select> </select>
</div> </div>
<button id="btn-generate-zitie" onclick="generateZitiePDF()">生成 PDF 字帖</button> <button onclick="generateZitiePDF()">生成字帖预览</button>
</div> </div>
</div> </div>
</div> </div>
<div id="zitie-preview" style="display:none; width: 100%; height: 850px; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.12); background: #fff; border: 1px solid #d2d2d7;"> <div id="zitie-preview" style="display:none; width: 100%; height: 800px; border-radius: 20px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.1); background: #fff; border: 1px solid #d2d2d7;">
<iframe id="pdf-frame" style="width:100%; height:100%; border:none;"></iframe> <iframe id="zitie-frame" style="width:100%; height:100%; border:none;"></iframe>
</div> </div>
</div> </div>
<script>
let currentMode = 'teaching';
function switchZitieMode(mode) {
currentMode = mode;
document.querySelectorAll('#panel-zitie .tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(`tab-${mode}`).classList.add('active');
document.getElementById('font-type-container').style.display = (mode === 'manuscript') ? 'block' : 'none';
}
async function generateZitiePDF() {
const chars = document.getElementById('chars').value;
const paper_size = document.getElementById('paper_size').value;
const font_type = document.getElementById('font_type').value;
if (!chars.trim()) { alert('请输入汉字'); return; }
const btn = document.getElementById('btn-generate-zitie');
const originalText = btn.innerText;
btn.innerText = '生成中...'; btn.disabled = true;
try {
const response = await fetch(`/api/zitie/${currentMode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chars, paper_size, font_type })
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
document.getElementById('zitie-preview').style.display = 'block';
document.getElementById('pdf-frame').src = url;
} catch (e) {
alert('生成失败: ' + e);
} finally {
btn.innerText = originalText; btn.disabled = false;
}
}
</script>
{{end}} {{end}}
+11 -7
View File
@@ -1,18 +1,22 @@
package base package base
import "github.com/gin-gonic/gin" import (
"github.com/gin-gonic/gin"
)
// Tool 定义了工具箱中每个子工具必须实现的接口
type Tool interface { type Tool interface {
ID() string ID() string // 工具的唯一标识,用于路由前缀,如 "zitie"
Name() string Name() string // 工具的显示名称
Description() string Description() string // 工具的描述
Emoji() string Init() error // 初始化逻辑,如加载 embed 的数据
Init() error RegisterRoutes(r *gin.RouterGroup) // 注册该工具的 API 路由
RegisterRoutes(r *gin.RouterGroup)
} }
// Registry 存储所有已注册的工具
var Registry = make(map[string]Tool) var Registry = make(map[string]Tool)
// Register 用于工具在 init() 函数中注册自己
func Register(t Tool) { func Register(t Tool) {
Registry[t.ID()] = t Registry[t.ID()] = t
} }
-44
View File
@@ -1,44 +0,0 @@
package logic
// Team represents a football team.
type Team struct {
Name string `json:"name"`
Group string `json:"group"` // e.g., "A", "B", ..., "L"
}
// Match represents a group stage match between two teams.
type Match struct {
TeamA string `json:"team_a"`
TeamB string `json:"team_b"`
IsCompleted bool `json:"is_completed"`
ScoreA int `json:"score_a"`
ScoreB int `json:"score_b"`
}
// ScoreOutcome represents a possible match score and its probability.
type ScoreOutcome struct {
ScoreA int `json:"score_a"`
ScoreB int `json:"score_b"`
Probability float64 `json:"probability"`
}
// TeamStats holds the metrics used to rank teams within a group or across third-place teams.
type TeamStats struct {
TeamName string
Points int
GoalDifference int
GoalsScored int
// Fields used for tie-breaking comparison within the group
H2HPoints int
H2HGD int
H2HGS int
}
// TeamProbability represents the calculated qualification probabilities for a single team.
type TeamProbability struct {
TeamName string `json:"team_name"`
Group string `json:"group"`
DirectQualProb float64 `json:"direct_qual_prob"`
ThirdQualProb float64 `json:"third_qual_prob"`
TotalQualProb float64 `json:"total_qual_prob"`
}
-191
View File
@@ -1,191 +0,0 @@
package logic
import (
"math"
"math/rand"
"sync"
)
// RawScoreRecord represents the raw statistics for a scoreline in football history.
type RawScoreRecord struct {
GoalsA int `json:"goals_a"`
GoalsB int `json:"goals_b"`
Count int `json:"count"`
}
// WorldCupRawScores represents the actual historical frequencies of scorelines
// in the FIFA World Cup history (964 matches from 1930 to 2022).
var WorldCupRawScores = []RawScoreRecord{
{GoalsA: 1, GoalsB: 0, Count: 182},
{GoalsA: 2, GoalsB: 1, Count: 152},
{GoalsA: 2, GoalsB: 0, Count: 111},
{GoalsA: 1, GoalsB: 1, Count: 92},
{GoalsA: 0, GoalsB: 0, Count: 78},
{GoalsA: 3, GoalsB: 1, Count: 68},
{GoalsA: 3, GoalsB: 0, Count: 57},
{GoalsA: 3, GoalsB: 2, Count: 43},
{GoalsA: 2, GoalsB: 2, Count: 35},
{GoalsA: 4, GoalsB: 1, Count: 31},
{GoalsA: 4, GoalsB: 0, Count: 24},
{GoalsA: 4, GoalsB: 2, Count: 17},
{GoalsA: 6, GoalsB: 1, Count: 11},
{GoalsA: 5, GoalsB: 2, Count: 9},
{GoalsA: 5, GoalsB: 0, Count: 7},
{GoalsA: 3, GoalsB: 3, Count: 7},
{GoalsA: 5, GoalsB: 1, Count: 7},
{GoalsA: 6, GoalsB: 0, Count: 5},
{GoalsA: 7, GoalsB: 0, Count: 5},
{GoalsA: 4, GoalsB: 3, Count: 3},
{GoalsA: 7, GoalsB: 1, Count: 3},
{GoalsA: 8, GoalsB: 1, Count: 3},
{GoalsA: 4, GoalsB: 4, Count: 2},
{GoalsA: 6, GoalsB: 3, Count: 2},
{GoalsA: 9, GoalsB: 0, Count: 2},
{GoalsA: 5, GoalsB: 3, Count: 1},
{GoalsA: 6, GoalsB: 2, Count: 1},
{GoalsA: 7, GoalsB: 2, Count: 1},
{GoalsA: 7, GoalsB: 3, Count: 1},
{GoalsA: 6, GoalsB: 5, Count: 1},
{GoalsA: 8, GoalsB: 3, Count: 1},
{GoalsA: 10, GoalsB: 1, Count: 1},
{GoalsA: 7, GoalsB: 5, Count: 1},
}
// poissonProbability computes the Poisson probability P(k; lambda).
func poissonProbability(lambda float64, k int) float64 {
factorial := 1.0
for i := 1; i <= k; i++ {
factorial *= float64(i)
}
return (math.Pow(lambda, float64(k)) * math.Exp(-lambda)) / factorial
}
// DefaultScoreOutcomes returns the symmetric default probability distribution based on WorldCupRawScores.
func DefaultScoreOutcomes() []ScoreOutcome {
var totalGoals int
var totalMatches int
for _, r := range WorldCupRawScores {
totalGoals += (r.GoalsA + r.GoalsB) * r.Count
totalMatches += r.Count
}
if totalMatches == 0 {
return []ScoreOutcome{{ScoreA: 1, ScoreB: 1, Probability: 1.0}}
}
// 1. Calculate expected goals per team (lambda)
lambda := float64(totalGoals) / (2.0 * float64(totalMatches))
// 2. Compute Poisson 1D and 2D probabilities (up to MaxGoals = 10)
maxGoals := 10
poisson1D := make([]float64, maxGoals+1)
var sumP float64
for g := 0; g <= maxGoals; g++ {
p := poissonProbability(lambda, g)
poisson1D[g] = p
sumP += p
}
for g := 0; g <= maxGoals; g++ {
poisson1D[g] /= sumP
}
// 3. Build Empirical 2D map
type scoreKey struct {
goalsA int
goalsB int
}
empirical := make(map[scoreKey]float64)
for _, r := range WorldCupRawScores {
if r.GoalsA <= maxGoals && r.GoalsB <= maxGoals {
prob := float64(r.Count) / float64(totalMatches)
if r.GoalsA == r.GoalsB {
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob
} else {
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob / 2.0
empirical[scoreKey{r.GoalsB, r.GoalsA}] = prob / 2.0
}
}
}
// 4. Combine into Mixture Model (alpha = 0.99)
alpha := 0.99
var outcomes []ScoreOutcome
for ga := 0; ga <= maxGoals; ga++ {
for gb := 0; gb <= maxGoals; gb++ {
pPoisson := poisson1D[ga] * poisson1D[gb]
pEmpirical := empirical[scoreKey{ga, gb}]
pMix := alpha*pEmpirical + (1.0-alpha)*pPoisson
outcomes = append(outcomes, ScoreOutcome{
ScoreA: ga,
ScoreB: gb,
Probability: pMix,
})
}
}
// Normalize just in case of float64 precision drift
var sumMix float64
for _, o := range outcomes {
sumMix += o.Probability
}
for i := range outcomes {
outcomes[i].Probability /= sumMix
}
return outcomes
}
// ScoreModel holds the probability distribution used to sample random scores.
type ScoreModel struct {
Outcomes []ScoreOutcome
mu sync.RWMutex
}
// NewScoreModel creates a ScoreModel from custom score outcomes.
// If customOutcomes is empty, it uses DefaultScoreOutcomes().
func NewScoreModel(customOutcomes []ScoreOutcome) *ScoreModel {
sm := &ScoreModel{}
if len(customOutcomes) > 0 {
// Normalize custom outcomes to ensure they sum to 1.0
var sum float64
for _, o := range customOutcomes {
sum += o.Probability
}
if sum > 0 {
normalized := make([]ScoreOutcome, len(customOutcomes))
for i, o := range customOutcomes {
normalized[i] = ScoreOutcome{
ScoreA: o.ScoreA,
ScoreB: o.ScoreB,
Probability: o.Probability / sum,
}
}
sm.Outcomes = normalized
return sm
}
}
sm.Outcomes = DefaultScoreOutcomes()
return sm
}
// RandomScore samples a random score outcome based on the probability distribution.
func (sm *ScoreModel) RandomScore(r *rand.Rand) ScoreOutcome {
sm.mu.RLock()
outcomes := sm.Outcomes
sm.mu.RUnlock()
if len(outcomes) == 0 {
return ScoreOutcome{ScoreA: 0, ScoreB: 0, Probability: 1.0}
}
p := r.Float64()
var cumulative float64
for _, outcome := range outcomes {
cumulative += outcome.Probability
if p <= cumulative {
return outcome
}
}
return outcomes[len(outcomes)-1]
}
-125
View File
@@ -1,125 +0,0 @@
package logic
import (
"math/rand"
"sort"
"time"
)
// RunMonteCarlo runs a Monte Carlo simulation of the remaining matches.
// Returns a slice of TeamProbability sorted by Group and then by TotalQualProb descending.
func RunMonteCarlo(teams []Team, matches []Match, scoreOutcomes []ScoreOutcome, numSimulations int) []TeamProbability {
if numSimulations <= 0 {
numSimulations = 50000
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
sm := NewScoreModel(scoreOutcomes)
// Map teams to groups
teamGroups := make(map[string]string)
groupsTeams := make(map[string][]string)
for _, t := range teams {
teamGroups[t.Name] = t.Group
groupsTeams[t.Group] = append(groupsTeams[t.Group], t.Name)
}
directCounts := make(map[string]int)
thirdCounts := make(map[string]int)
// Separate completed and uncompleted matches
var completed []Match
var uncompleted []Match
for _, m := range matches {
if m.IsCompleted {
completed = append(completed, m)
} else {
uncompleted = append(uncompleted, m)
}
}
for sim := 0; sim < numSimulations; sim++ {
// Clone completed matches and simulate uncompleted ones
simMatches := make([]Match, len(completed)+len(uncompleted))
copy(simMatches, completed)
for i, m := range uncompleted {
outcome := sm.RandomScore(r)
simMatches[len(completed)+i] = Match{
TeamA: m.TeamA,
TeamB: m.TeamB,
IsCompleted: true,
ScoreA: outcome.ScoreA,
ScoreB: outcome.ScoreB,
}
}
// Calculate standings for each group
thirdPlaced := make([]*TeamStats, 0, 12)
for grp, grpTeams := range groupsTeams {
var grpMatches []Match
for _, m := range simMatches {
if teamGroups[m.TeamA] == grp && teamGroups[m.TeamB] == grp {
grpMatches = append(grpMatches, m)
}
}
// Shuffle group teams to randomize tie-breaks in SortGroupStandings
shuffledGrpTeams := make([]string, len(grpTeams))
copy(shuffledGrpTeams, grpTeams)
r.Shuffle(len(shuffledGrpTeams), func(i, j int) {
shuffledGrpTeams[i], shuffledGrpTeams[j] = shuffledGrpTeams[j], shuffledGrpTeams[i]
})
standings := CalculateStandings(shuffledGrpTeams, grpMatches)
// Top 2 qualify directly
if len(standings) > 0 {
directCounts[standings[0].TeamName]++
}
if len(standings) > 1 {
directCounts[standings[1].TeamName]++
}
// 3rd placed team goes to the pool
if len(standings) > 2 {
thirdPlaced = append(thirdPlaced, standings[2])
}
}
// Shuffle third-placed list to randomize cross-group tie-breaks in CompareThirdPlaced
r.Shuffle(len(thirdPlaced), func(i, j int) {
thirdPlaced[i], thirdPlaced[j] = thirdPlaced[j], thirdPlaced[i]
})
CompareThirdPlaced(thirdPlaced)
limit := 8
if len(thirdPlaced) < limit {
limit = len(thirdPlaced)
}
for i := 0; i < limit; i++ {
thirdCounts[thirdPlaced[i].TeamName]++
}
}
// Build result slice
res := make([]TeamProbability, 0, len(teams))
for _, t := range teams {
dQual := float64(directCounts[t.Name]) / float64(numSimulations)
tQual := float64(thirdCounts[t.Name]) / float64(numSimulations)
res = append(res, TeamProbability{
TeamName: t.Name,
Group: t.Group,
DirectQualProb: dQual,
ThirdQualProb: tQual,
TotalQualProb: dQual + tQual,
})
}
// Sort results by Group name first, then by TotalQualProb descending
sort.Slice(res, func(i, j int) bool {
if res[i].Group != res[j].Group {
return res[i].Group < res[j].Group
}
return res[i].TotalQualProb > res[j].TotalQualProb
})
return res
}
-54
View File
@@ -1,54 +0,0 @@
package logic
import (
"testing"
)
func TestCalculateStandings(t *testing.T) {
teams := []string{"MEX", "RSA", "KOR", "CZE"}
matches := []Match{
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // MEX: 3 pts, RSA: 0 pts
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // KOR: 3 pts, CZE: 0 pts
}
standings := CalculateStandings(teams, matches)
if len(standings) != 4 {
t.Fatalf("expected 4 teams, got %d", len(standings))
}
// MEX and KOR should be top 2
if standings[0].TeamName != "MEX" && standings[0].TeamName != "KOR" {
t.Errorf("expected MEX or KOR as first, got %s", standings[0].TeamName)
}
}
func TestRunMonteCarlo(t *testing.T) {
teams := []Team{
{Name: "MEX", Group: "A"},
{Name: "RSA", Group: "A"},
{Name: "KOR", Group: "A"},
{Name: "CZE", Group: "A"},
}
matches := []Match{
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0},
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1},
{TeamA: "CZE", TeamB: "RSA", IsCompleted: false},
{TeamA: "MEX", TeamB: "KOR", IsCompleted: false},
{TeamA: "MEX", TeamB: "CZE", IsCompleted: false},
{TeamA: "KOR", TeamB: "RSA", IsCompleted: false},
}
// We only simulate Group A here (4 teams, 6 matches total).
// Note: 3rd place comparison won't succeed in putting any team to top 8 of third placed
// unless there are other groups, but it shouldn't crash.
probs := RunMonteCarlo(teams, matches, nil, 100)
if len(probs) != 4 {
t.Fatalf("expected probabilities for 4 teams, got %d", len(probs))
}
for _, p := range probs {
if p.TotalQualProb < 0 || p.TotalQualProb > 1.0 {
t.Errorf("invalid total qual probability for %s: %f", p.TeamName, p.TotalQualProb)
}
}
}
-161
View File
@@ -1,161 +0,0 @@
package logic
import (
"sort"
)
// CalculateStandings computes the stats for each team in a group given the match results.
func CalculateStandings(teams []string, matches []Match) []*TeamStats {
statsMap := make(map[string]*TeamStats)
for _, t := range teams {
statsMap[t] = &TeamStats{TeamName: t}
}
for _, m := range matches {
if !m.IsCompleted {
continue
}
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
if sa == nil || sb == nil {
continue
}
sa.GoalsScored += m.ScoreA
sa.GoalDifference += m.ScoreA - m.ScoreB
sb.GoalsScored += m.ScoreB
sb.GoalDifference += m.ScoreB - m.ScoreA
if m.ScoreA > m.ScoreB {
sa.Points += 3
} else if m.ScoreA < m.ScoreB {
sb.Points += 3
} else {
sa.Points += 1
sb.Points += 1
}
}
res := make([]*TeamStats, 0, len(teams))
for _, s := range statsMap {
res = append(res, s)
}
// Sort the standings
SortGroupStandings(res, matches)
return res
}
// SortGroupStandings sorts the teams in a group based on the tie-breaking rules.
func SortGroupStandings(teams []*TeamStats, matches []Match) {
sort.Slice(teams, func(i, j int) bool {
ti, tj := teams[i], teams[j]
// 1. Points
if ti.Points != tj.Points {
return ti.Points > tj.Points
}
// 2. Goal Difference
if ti.GoalDifference != tj.GoalDifference {
return ti.GoalDifference > tj.GoalDifference
}
// 3. Goals Scored
if ti.GoalsScored != tj.GoalsScored {
return ti.GoalsScored > tj.GoalsScored
}
// 4-6. Head-to-Head (H2H)
// We dynamically compute H2H stats for all teams that are tied on Points, GD, and GS.
// Since we are sorting, we can check the relationship between ti and tj.
// First find all teams tied with ti and tj.
tiedTeams := []string{}
for _, t := range teams {
if t.Points == ti.Points && t.GoalDifference == ti.GoalDifference && t.GoalsScored == ti.GoalsScored {
tiedTeams = append(tiedTeams, t.TeamName)
}
}
if len(tiedTeams) > 1 {
h2hStats := computeH2H(tiedTeams, matches)
h2hi := h2hStats[ti.TeamName]
h2hj := h2hStats[tj.TeamName]
if h2hi != nil && h2hj != nil {
// H2H Points
if h2hi.Points != h2hj.Points {
return h2hi.Points > h2hj.Points
}
// H2H GD
if h2hi.GoalDifference != h2hj.GoalDifference {
return h2hi.GoalDifference > h2hj.GoalDifference
}
// H2H GS
if h2hi.GoalsScored != h2hj.GoalsScored {
return h2hi.GoalsScored > h2hj.GoalsScored
}
}
}
// 7. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
return false
})
}
// computeH2H calculates Points, GD, and GS only considering matches between the specified tied teams.
func computeH2H(tiedTeams []string, matches []Match) map[string]*TeamStats {
statsMap := make(map[string]*TeamStats)
isTied := make(map[string]bool)
for _, t := range tiedTeams {
statsMap[t] = &TeamStats{TeamName: t}
isTied[t] = true
}
for _, m := range matches {
if !m.IsCompleted {
continue
}
if isTied[m.TeamA] && isTied[m.TeamB] {
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
sa.GoalsScored += m.ScoreA
sa.GoalDifference += m.ScoreA - m.ScoreB
sb.GoalsScored += m.ScoreB
sb.GoalDifference += m.ScoreB - m.ScoreA
if m.ScoreA > m.ScoreB {
sa.Points += 3
} else if m.ScoreA < m.ScoreB {
sb.Points += 3
} else {
sa.Points += 1
sb.Points += 1
}
}
}
return statsMap
}
// CompareThirdPlaced ranks the third-placed teams across groups.
func CompareThirdPlaced(thirdTeams []*TeamStats) {
sort.Slice(thirdTeams, func(i, j int) bool {
ti, tj := thirdTeams[i], thirdTeams[j]
// 1. Points
if ti.Points != tj.Points {
return ti.Points > tj.Points
}
// 2. Goal Difference
if ti.GoalDifference != tj.GoalDifference {
return ti.GoalDifference > tj.GoalDifference
}
// 3. Goals Scored
if ti.GoalsScored != tj.GoalsScored {
return ti.GoalsScored > tj.GoalsScored
}
// 4. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
return false
})
}
-211
View File
@@ -1,211 +0,0 @@
package football
import (
"fmt"
"net/http"
"toolbox/pkg/base"
"toolbox/pkg/football/logic"
"github.com/gin-gonic/gin"
)
type footballTool struct{}
func init() {
base.Register(&footballTool{})
}
func (t *footballTool) ID() string { return "football" }
func (t *footballTool) Name() string { return "足球概率模拟" }
func (t *footballTool) Description() string {
return "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛阶段的出线概率"
}
func (t *footballTool) Emoji() string { return "⚽" }
func (t *footballTool) Init() error {
fmt.Println("Initializing Football Tool...")
return nil
}
func (t *footballTool) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/defaults", t.handleDefaults)
r.POST("/simulate", t.handleSimulate)
}
type SimulateRequest struct {
Groups map[string][]string `json:"groups"`
CompletedMatches []logic.Match `json:"completed_matches"`
ScoreOutcomes []logic.ScoreOutcome `json:"score_outcomes"`
Simulations int `json:"simulations"`
}
func (t *footballTool) handleDefaults(c *gin.Context) {
defaultGroups := map[string][]string{
"A": {"MEX", "RSA", "KOR", "CZE"},
"B": {"CAN", "QAT", "SUI", "BIH"},
"C": {"BRA", "MAR", "HAI", "SCO"},
"D": {"USA", "PAR", "AUS", "TUR"},
"E": {"GER", "CUW", "CIV", "ECU"},
"F": {"NED", "JPN", "TUN", "SWE"},
"G": {"BEL", "EGY", "IRN", "NZL"},
"H": {"ESP", "KSA", "URU", "CPV"},
"I": {"FRA", "SEN", "IRQ", "NOR"},
"J": {"ARG", "ALG", "AUT", "JOR"},
"K": {"POR", "COL", "UZB", "COD"},
"L": {"ENG", "CRO", "GHA", "PAN"},
}
defaultMatches := []logic.Match{
// === ROUND 1 ===
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group A
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // Group A
{TeamA: "CAN", TeamB: "BIH", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group B
{TeamA: "USA", TeamB: "PAR", IsCompleted: true, ScoreA: 4, ScoreB: 1}, // Group D
{TeamA: "QAT", TeamB: "SUI", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group B
{TeamA: "BRA", TeamB: "MAR", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group C
{TeamA: "SCO", TeamB: "HAI", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group C
{TeamA: "AUS", TeamB: "TUR", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group D
{TeamA: "GER", TeamB: "CUW", IsCompleted: true, ScoreA: 7, ScoreB: 1}, // Group E
{TeamA: "NED", TeamB: "JPN", IsCompleted: true, ScoreA: 2, ScoreB: 2}, // Group F
{TeamA: "CIV", TeamB: "ECU", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group E
{TeamA: "SWE", TeamB: "TUN", IsCompleted: true, ScoreA: 5, ScoreB: 1}, // Group F
{TeamA: "ESP", TeamB: "CPV", IsCompleted: true, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "BEL", TeamB: "EGY", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group G
{TeamA: "KSA", TeamB: "URU", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group H
{TeamA: "IRN", TeamB: "NZL", IsCompleted: true, ScoreA: 2, ScoreB: 2}, // Group G
{TeamA: "FRA", TeamB: "SEN", IsCompleted: true, ScoreA: 3, ScoreB: 1}, // Group I
{TeamA: "IRQ", TeamB: "NOR", IsCompleted: true, ScoreA: 1, ScoreB: 4}, // Group I
{TeamA: "ARG", TeamB: "ALG", IsCompleted: true, ScoreA: 3, ScoreB: 0}, // Group J
{TeamA: "AUT", TeamB: "JOR", IsCompleted: true, ScoreA: 3, ScoreB: 1}, // Group J
{TeamA: "POR", TeamB: "COD", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group K
{TeamA: "ENG", TeamB: "CRO", IsCompleted: true, ScoreA: 4, ScoreB: 2}, // Group L
{TeamA: "GHA", TeamB: "PAN", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group L
{TeamA: "UZB", TeamB: "COL", IsCompleted: true, ScoreA: 1, ScoreB: 3}, // Group K
// === ROUND 2 ===
{TeamA: "CZE", TeamB: "RSA", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group A
{TeamA: "SUI", TeamB: "BIH", IsCompleted: true, ScoreA: 4, ScoreB: 1}, // Group B
{TeamA: "CAN", TeamB: "QAT", IsCompleted: true, ScoreA: 6, ScoreB: 0}, // Group B
{TeamA: "MEX", TeamB: "KOR", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group A
{TeamA: "USA", TeamB: "AUS", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group D
{TeamA: "SCO", TeamB: "MAR", IsCompleted: true, ScoreA: 0, ScoreB: 1}, // Group C
{TeamA: "BRA", TeamB: "HAI", IsCompleted: true, ScoreA: 3, ScoreB: 0}, // Group C
{TeamA: "TUR", TeamB: "PAR", IsCompleted: true, ScoreA: 0, ScoreB: 1}, // Group D
{TeamA: "NED", TeamB: "SWE", IsCompleted: true, ScoreA: 5, ScoreB: 1}, // Group F
{TeamA: "GER", TeamB: "CIV", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // Group E
{TeamA: "ECU", TeamB: "CUW", IsCompleted: true, ScoreA: 0, ScoreB: 0}, // Group E
{TeamA: "TUN", TeamB: "JPN", IsCompleted: true, ScoreA: 0, ScoreB: 4}, // Group F
{TeamA: "ESP", TeamB: "KSA", IsCompleted: true, ScoreA: 4, ScoreB: 0}, // Group H
{TeamA: "BEL", TeamB: "IRN", IsCompleted: true, ScoreA: 0, ScoreB: 0}, // Group G
{TeamA: "URU", TeamB: "CPV", IsCompleted: true, ScoreA: 2, ScoreB: 2}, // Group H
{TeamA: "NZL", TeamB: "EGY", IsCompleted: true, ScoreA: 1, ScoreB: 3}, // Group G
{TeamA: "ARG", TeamB: "AUT", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group J
{TeamA: "FRA", TeamB: "IRQ", IsCompleted: true, ScoreA: 3, ScoreB: 0}, // Group I
{TeamA: "NOR", TeamB: "SEN", IsCompleted: true, ScoreA: 3, ScoreB: 2}, // Group I
{TeamA: "JOR", TeamB: "ALG", IsCompleted: true, ScoreA: 1, ScoreB: 2}, // Group J
{TeamA: "POR", TeamB: "UZB", IsCompleted: true, ScoreA: 5, ScoreB: 0}, // Group K
{TeamA: "ENG", TeamB: "GHA", IsCompleted: true, ScoreA: 0, ScoreB: 0}, // Group L
{TeamA: "PAN", TeamB: "CRO", IsCompleted: true, ScoreA: 0, ScoreB: 1}, // Group L
{TeamA: "COL", TeamB: "COD", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group K
// === ROUND 3 ===
{TeamA: "SUI", TeamB: "CAN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group B
{TeamA: "BIH", TeamB: "QAT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group B
{TeamA: "SCO", TeamB: "BRA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
{TeamA: "MAR", TeamB: "HAI", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
{TeamA: "CZE", TeamB: "MEX", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group A
{TeamA: "RSA", TeamB: "KOR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group A
{TeamA: "CUW", TeamB: "CIV", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
{TeamA: "ECU", TeamB: "GER", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
{TeamA: "JPN", TeamB: "SWE", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
{TeamA: "TUN", TeamB: "NED", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
{TeamA: "TUR", TeamB: "USA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
{TeamA: "PAR", TeamB: "AUS", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
{TeamA: "NOR", TeamB: "FRA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
{TeamA: "SEN", TeamB: "IRQ", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
{TeamA: "CPV", TeamB: "KSA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "URU", TeamB: "ESP", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "EGY", TeamB: "IRN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
{TeamA: "NZL", TeamB: "BEL", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
{TeamA: "PAN", TeamB: "ENG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
{TeamA: "CRO", TeamB: "GHA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
{TeamA: "COL", TeamB: "POR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
{TeamA: "COD", TeamB: "UZB", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
{TeamA: "ALG", TeamB: "AUT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
{TeamA: "JOR", TeamB: "ARG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
}
c.JSON(http.StatusOK, gin.H{
"groups": defaultGroups,
"completed_matches": defaultMatches,
"score_outcomes": logic.DefaultScoreOutcomes(),
})
}
func (t *footballTool) handleSimulate(c *gin.Context) {
var req SimulateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Groups) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Groups map cannot be empty"})
return
}
// Reconstruct Teams
var teams []logic.Team
for grp, grpTeams := range req.Groups {
for _, teamName := range grpTeams {
teams = append(teams, logic.Team{Name: teamName, Group: grp})
}
}
// Generate all group matches (completed + uncompleted)
var allMatches []logic.Match
for _, grpTeams := range req.Groups {
for i := 0; i < len(grpTeams); i++ {
for j := i + 1; j < len(grpTeams); j++ {
tA := grpTeams[i]
tB := grpTeams[j]
// Check if this match is completed in input
isCompleted := false
scoreA, scoreB := 0, 0
for _, cm := range req.CompletedMatches {
if !cm.IsCompleted {
continue
}
if cm.TeamA == tA && cm.TeamB == tB {
isCompleted = true
scoreA = cm.ScoreA
scoreB = cm.ScoreB
break
} else if cm.TeamA == tB && cm.TeamB == tA {
isCompleted = true
scoreA = cm.ScoreB
scoreB = cm.ScoreA
break
}
}
allMatches = append(allMatches, logic.Match{
TeamA: tA,
TeamB: tB,
IsCompleted: isCompleted,
ScoreA: scoreA,
ScoreB: scoreB,
})
}
}
}
sims := req.Simulations
if sims <= 0 {
sims = 50000
}
results := logic.RunMonteCarlo(teams, allMatches, req.ScoreOutcomes, sims)
c.JSON(http.StatusOK, results)
}
-87
View File
@@ -1,87 +0,0 @@
package football
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"toolbox/pkg/football/logic"
"github.com/gin-gonic/gin"
)
func TestFootballToolRoutes(t *testing.T) {
// Set Gin to test mode
gin.SetMode(gin.TestMode)
// Create test router and register routes
r := gin.New()
api := r.Group("/api/football")
tool := &footballTool{}
tool.RegisterRoutes(api)
// 1. Test /api/football/defaults
reqDefaults, _ := http.NewRequest(http.MethodGet, "/api/football/defaults", nil)
respDefaults := httptest.NewRecorder()
r.ServeHTTP(respDefaults, reqDefaults)
if respDefaults.Code != http.StatusOK {
t.Fatalf("expected status 200 OK for defaults, got %d", respDefaults.Code)
}
var defaults struct {
Groups map[string][]string `json:"groups"`
CompletedMatches []logic.Match `json:"completed_matches"`
ScoreOutcomes []logic.ScoreOutcome `json:"score_outcomes"`
}
if err := json.Unmarshal(respDefaults.Body.Bytes(), &defaults); err != nil {
t.Fatalf("failed to parse defaults JSON response: %v", err)
}
if len(defaults.Groups) != 12 {
t.Errorf("expected 12 groups, got %d", len(defaults.Groups))
}
if len(defaults.CompletedMatches) == 0 {
t.Errorf("expected completed matches in default configuration")
}
// 2. Test /api/football/simulate
simReq := SimulateRequest{
Groups: defaults.Groups,
CompletedMatches: defaults.CompletedMatches,
ScoreOutcomes: defaults.ScoreOutcomes,
Simulations: 100, // run a small number for fast test
}
reqBody, _ := json.Marshal(simReq)
reqSim, _ := http.NewRequest(http.MethodPost, "/api/football/simulate", bytes.NewBuffer(reqBody))
reqSim.Header.Set("Content-Type", "application/json")
respSim := httptest.NewRecorder()
r.ServeHTTP(respSim, reqSim)
if respSim.Code != http.StatusOK {
t.Fatalf("expected status 200 OK for simulate, got %d. Body: %s", respSim.Code, respSim.Body.String())
}
var results []logic.TeamProbability
if err := json.Unmarshal(respSim.Body.Bytes(), &results); err != nil {
t.Fatalf("failed to parse simulate JSON response: %v", err)
}
if len(results) != 48 {
t.Errorf("expected 48 team probabilities, got %d", len(results))
}
// Sum of TotalQualProb across all 48 teams should be exactly 32.0
var sum float64
for _, r := range results {
sum += r.TotalQualProb
}
// Because of floating point representation it should be extremely close to 32.0
if sum < 31.9 || sum > 32.1 {
t.Errorf("expected sum of probabilities to be 32.0, got %f", sum)
}
}
Binary file not shown.
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 404 KiB

+33 -88
View File
@@ -1,96 +1,41 @@
package data package data
import (
_ "embed"
"encoding/xml"
"fmt"
"strings"
)
//go:embed fruit.svg
var fruitSVGContent []byte
//go:embed font.ttf
var FontContent []byte
type Icon struct { type Icon struct {
Name string Name string
Paths []string Paths []string
} }
var IconCategories = map[string][]Icon{ // 升级版:更卡通、更饱满的简笔画
"shapes": { var CountingIcons = []Icon{
{Name: "Circle", Paths: []string{"M 512 112 C 291 112 112 291 112 512 C 112 733 291 912 512 912 C 733 912 912 733 912 512 C 912 291 733 112 512 112 Z"}}, {Name: "Bear", Paths: []string{
{Name: "Square", Paths: []string{"M 150 150 L 874 150 L 874 874 L 150 874 Z"}}, "M 512 800 C 300 800 200 700 200 500 C 200 300 350 200 512 200 C 674 200 824 300 824 500 C 824 700 724 800 512 800 Z", // Body
{Name: "Rectangle", Paths: []string{"M 100 300 L 924 300 L 924 724 L 100 724 Z"}}, "M 300 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear L
{Name: "Triangle", Paths: []string{"M 512 150 L 900 850 L 124 850 Z"}}, "M 724 300 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Ear R
{Name: "Star", Paths: []string{"M 512 100 L 612 400 L 924 400 L 674 600 L 774 900 L 512 700 L 250 900 L 350 600 L 100 400 L 412 400 Z"}}, "M 400 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye L
{Name: "Heart", Paths: []string{"M 512 900 C 200 700 100 500 100 300 C 100 100 400 100 512 250 C 624 100 924 100 924 300 C 924 500 824 700 512 900 Z"}}, "M 624 450 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye R
{Name: "Diamond", Paths: []string{"M 512 100 L 900 512 L 512 924 L 124 512 Z"}}, "M 512 550 Q 512 650 400 650 M 512 550 Q 512 650 624 650", // Mouth
{Name: "Oval", Paths: []string{"M 512 350 C 200 350 100 420 100 512 C 100 604 200 674 512 674 C 824 674 924 604 924 512 C 924 420 824 350 512 350 Z"}}, }},
{Name: "Trapezoid", Paths: []string{"M 300 200 L 724 200 L 924 800 L 100 800 Z"}}, {Name: "Cat", Paths: []string{
{Name: "Hexagon", Paths: []string{"M 512 100 L 858 300 L 858 724 L 512 924 L 166 724 L 166 300 Z"}}, "M 200 800 L 300 400 L 400 200 L 512 350 L 624 200 L 724 400 L 824 800 Z", // Head
}, "M 400 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye L
"fruits": {}, "M 624 550 m -15 0 a 15 15 0 1 0 30 0 a 15 15 0 1 0 -30 0", // Eye R
} "M 512 650 L 450 700 M 512 650 L 574 700", // Nose
}},
type SVG struct { {Name: "Car", Paths: []string{
Groups []G `xml:"g"` "M 100 700 L 100 500 Q 100 400 300 400 L 700 400 Q 900 400 900 500 L 900 700 Z", // Body
} "M 250 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel L
"M 750 700 m -60 0 a 60 60 0 1 0 120 0 a 60 60 0 1 0 -120 0", // Wheel R
type G struct { "M 300 400 L 400 250 L 624 250 L 724 400", // Roof
ID string `xml:"id,attr"` }},
Groups []G `xml:"g"` {Name: "Bird", Paths: []string{
Paths []Path `xml:"path"` "M 512 512 m -300 0 a 300 300 0 1 0 600 0 a 300 300 0 1 0 -600 0", // Body
} "M 812 512 L 950 450 L 812 400 Z", // Beak
"M 400 400 m -20 0 a 20 20 0 1 0 40 0 a 20 20 0 1 0 -40 0", // Eye
type Path struct { "M 212 512 Q 100 400 212 300", // Wing
D string `xml:"d,attr"` }},
} {Name: "Rocket", Paths: []string{
"M 512 100 Q 700 400 700 800 L 324 800 Q 324 400 512 100 Z", // Body
func LoadIconsFromEmbed() error { "M 512 400 m -50 0 a 50 50 0 1 0 100 0 a 50 50 0 1 0 -100 0", // Window
var svg SVG "M 324 800 L 200 950 L 324 900 M 700 800 L 824 950 L 700 900", // Fins
if err := xml.Unmarshal(fruitSVGContent, &svg); err != nil { }},
return err
}
var objectsG *G
for i := range svg.Groups {
if svg.Groups[i].ID == "objects" {
objectsG = &svg.Groups[i]
break
}
}
if objectsG == nil {
return nil
}
var fruitIcons []Icon
for i, g := range objectsG.Groups {
paths := collectPaths(g)
if len(paths) > 0 {
fruitIcons = append(fruitIcons, Icon{
Name: fmt.Sprintf("Item %d", i+1),
Paths: paths,
})
}
}
IconCategories["fruits"] = fruitIcons
return nil
}
func collectPaths(g G) []string {
var paths []string
for _, p := range g.Paths {
d := strings.TrimSpace(p.D)
if d != "" {
paths = append(paths, d)
}
}
for _, subG := range g.Groups {
paths = append(paths, collectPaths(subG)...)
}
return paths
} }
+97 -269
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"toolbox/pkg/learnnumber/data" "toolbox/pkg/learnnumber/data"
"github.com/signintech/gopdf" "github.com/signintech/gopdf"
@@ -15,326 +16,153 @@ import (
type CountingRequest struct { type CountingRequest struct {
TotalCount int `json:"total_count"` TotalCount int `json:"total_count"`
IconTypes int `json:"icon_types"` IconTypes int `json:"icon_types"`
PageCount int `json:"page_count"`
Category string `json:"category"`
PaperSize string `json:"paper_size"` PaperSize string `json:"paper_size"`
} }
type PlacedIcon struct { var reSVG = regexp.MustCompile(`([MLQCZmlqcz])|(-?\d+\.?\d*)`)
X, Y, Radius float64
}
type PathResult struct {
Points []gopdf.Point
Closed bool
}
var reSVGToken = regexp.MustCompile(`[a-zA-Z]|-?\d+\.?\d*`)
func GenerateCountingPDF(req CountingRequest) ([]byte, error) { func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
rand.Seed(time.Now().UnixNano())
pdf := &gopdf.GoPdf{} pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89} rect := gopdf.Rect{W: 595.28, H: 841.89}
if req.PaperSize == "Letter" { if req.PaperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
rect = gopdf.Rect{W: 612, H: 792}
}
pdf.Start(gopdf.Config{PageSize: rect}) pdf.Start(gopdf.Config{PageSize: rect})
// 硬性限制:页数 1-10 页,防止资源耗尽
if req.PageCount < 1 {
req.PageCount = 1
}
if req.PageCount > 10 {
req.PageCount = 10
}
for i := 0; i < req.PageCount; i++ {
pdf.AddPage() pdf.AddPage()
drawProblemV4(pdf, req, 0, rect.W, rect.H/2)
drawProblemV4(pdf, req, rect.H/2, rect.W, rect.H/2) drawProblem(pdf, req, 0, rect.W, rect.H/2)
} drawProblem(pdf, req, rect.H/2, rect.W, rect.H/2)
var buf bytes.Buffer var buf bytes.Buffer
_, err := pdf.WriteTo(&buf) _, err := pdf.WriteTo(&buf)
return buf.Bytes(), err return buf.Bytes(), err
} }
func drawProblemV4(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64) { func drawProblem(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64) {
margin := 40.0 margin := 40.0
boxW, boxH := (pW-2*margin)*0.7, pH-60.0 boxW := (pW - 2*margin) * 0.7
xBase, yBase := margin, startY+30.0 boxH := pH - 60.0
x, y := margin, startY + 30.0
pdf.SetStrokeColor(0, 0, 0) // 1. 绘制方框
pdf.SetLineWidth(2.5) pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(2.0)
pdf.RectFromUpperLeft(xBase, yBase, boxW, boxH) pdf.RectFromUpperLeft(x, y, boxW, boxH)
catIcons := data.IconCategories[req.Category] // 2. 分配图标数量
if len(catIcons) == 0 { numTypes := req.IconTypes; if numTypes < 1 { numTypes = 1 }
catIcons = data.IconCategories["shapes"] if numTypes > len(data.CountingIcons) { numTypes = len(data.CountingIcons) }
}
numTypes := req.IconTypes total := req.TotalCount; if total < numTypes { total = numTypes }
if numTypes < 1 { if total > 30 { total = 30 }
numTypes = 1
}
if numTypes > 6 {
numTypes = 6
}
if numTypes > len(catIcons) {
numTypes = len(catIcons)
}
total := req.TotalCount // 随机选出图标种类
if total < numTypes { allIcons := rand.Perm(len(data.CountingIcons))
total = numTypes selectedIcons := []data.Icon{}
} counts := []int{}
if total > 30 {
total = 30
}
allIconsPerm := rand.Perm(len(catIcons)) // 先每种分配1个
var selectedIcons []data.Icon rem := total - numTypes
counts := make([]int, numTypes)
// 随机分配逻辑
for i := 0; i < numTypes; i++ { for i := 0; i < numTypes; i++ {
selectedIcons = append(selectedIcons, catIcons[allIconsPerm[i]]) selectedIcons = append(selectedIcons, data.CountingIcons[allIcons[i]])
counts[i] = 1 counts = append(counts, 1)
} }
remaining := total - numTypes // 随机分配剩下的
for i := 0; i < remaining; i++ { for i := 0; i < rem; i++ {
counts[rand.Intn(numTypes)]++ counts[rand.Intn(numTypes)]++
} }
avgRadius := math.Sqrt((boxW * boxH * 0.22) / (float64(total) * math.Pi)) // 3. 布局计划 (使用网格,增加 Padding 避开边框)
if avgRadius > 35.0 { rows, cols := 5, 6
avgRadius = 35.0 cellW, cellH := boxW/float64(cols), boxH/float64(rows)
} indices := rand.Perm(rows * cols)
// 严格图标大小:单元格的 70%,确保不溢出单元格边界
iconSize := math.Min(cellW, cellH) * 0.7
paddingX, paddingY := (cellW - iconSize)/2, (cellH - iconSize)/2
iconTypeIdx := 0
countInCurrentType := 0
var placed []PlacedIcon
iconTypeIdx, currentInType := 0, 0
for i := 0; i < total; i++ { for i := 0; i < total; i++ {
scaleVar := 0.9 + rand.Float64()*0.2 cellIdx := indices[i]
r := avgRadius * scaleVar rIdx, cIdx := cellIdx/cols, cellIdx%cols
for retry := 0; retry < 200; retry++ {
randX := xBase + r + 5 + rand.Float64()*(boxW-2*r-10)
randY := yBase + r + 5 + rand.Float64()*(boxH-2*r-10)
collision := false
for _, p := range placed {
dx := randX - p.X
dy := randY - p.Y
if math.Sqrt(dx*dx+dy*dy) < (r + p.Radius + 10.0) {
collision = true
break
}
}
if !collision { // 基础位置
drawSimpleIcon(pdf, selectedIcons[iconTypeIdx], randX, randY, r*2) baseX := x + float64(cIdx)*cellW + paddingX
placed = append(placed, PlacedIcon{X: randX, Y: randY, Radius: r}) baseY := y + float64(rIdx)*cellH + paddingY
break
} // 渲染图标
} drawIcon(pdf, selectedIcons[iconTypeIdx], baseX + iconSize/2, baseY + iconSize/2, iconSize)
currentInType++
if currentInType >= counts[iconTypeIdx] { countInCurrentType++
if countInCurrentType >= counts[iconTypeIdx] {
iconTypeIdx++ iconTypeIdx++
currentInType = 0 countInCurrentType = 0
} }
} }
legendX, legendStepY := xBase+boxW+20.0, boxH/float64(numTypes+1) // 4. 绘制右侧对照表
legendX := x + boxW + 20.0
legendStepY := boxH / float64(numTypes + 1)
for i := 0; i < numTypes; i++ { for i := 0; i < numTypes; i++ {
lY := yBase + float64(i+1)*legendStepY lY := y + float64(i+1)*legendStepY
drawSimpleIcon(pdf, selectedIcons[i], legendX+25, lY, 35) drawIcon(pdf, selectedIcons[i], legendX + 20, lY, 30)
pdf.SetStrokeColor(150, 150, 150) pdf.SetStrokeColor(150, 150, 150); pdf.SetLineWidth(0.8)
pdf.SetLineWidth(1.0) pdf.RectFromUpperLeft(legendX + 50, lY - 15, 30, 30)
pdf.RectFromUpperLeft(legendX+60, lY-15, 35, 35)
} }
} }
func drawSimpleIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) { func drawIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
rawResults := parseMultiPath(icon, 1.0, 0, 0) scale := size / 1024.0
if len(rawResults) == 0 { pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.5) // 加粗线条
return
}
var minX, minY, maxX, maxY = 1e9, 1e9, -1e9, -1e9
for _, res := range rawResults {
for _, p := range res.Points {
if p.X < minX {
minX = p.X
}
if p.X > maxX {
maxX = p.X
}
if p.Y < minY {
minY = p.Y
}
if p.Y > maxY {
maxY = p.Y
}
}
}
curW, curH := maxX-minX, maxY-minY
if curW <= 0 {
curW = 1
}
if curH <= 0 {
curH = 1
}
scale := size / math.Max(curW, curH)
ox, oy := cX-(curW*scale)/2-minX*scale, cY-(curH*scale)/2-minY*scale
pdf.SetStrokeColor(0, 0, 0) ox, oy := cX - size/2, cY - size/2
if strings.Contains(strings.ToLower(icon.Name), "item") || len(icon.Paths) > 1 { for _, pStr := range icon.Paths {
pdf.SetLineWidth(size / 60.0) // 复杂素材用细线 (30% less than 45 is approx 60) pts := parseSmoothPath(pStr, scale, ox, oy)
if len(pts) > 1 {
// 如果是闭合路径
if strings.Contains(strings.ToUpper(pStr), "Z") {
pdf.Polygon(pts, "D")
} else { } else {
pdf.SetLineWidth(size / 22.0) // 基础图形用粗线 for i := 0; i < len(pts)-1; i++ {
} pdf.Line(pts[i].X, pts[i].Y, pts[i+1].X, pts[i+1].Y)
for _, res := range rawResults {
scaledPts := make([]gopdf.Point, len(res.Points))
for i, p := range res.Points {
scaledPts[i] = gopdf.Point{X: ox + p.X*scale, Y: oy + p.Y*scale}
}
if len(scaledPts) > 1 {
if res.Closed {
pdf.Polygon(scaledPts, "D")
} else {
for j := 0; j < len(scaledPts)-1; j++ {
pdf.Line(scaledPts[j].X, scaledPts[j].Y, scaledPts[j+1].X, scaledPts[j+1].Y)
} }
} }
} }
} }
} }
func parseMultiPath(icon data.Icon, scale, ox, oy float64) []PathResult { // 支持贝塞尔曲线采样,确保图标圆润
var results []PathResult func parseSmoothPath(path string, scale, ox, oy float64) []gopdf.Point {
for _, path := range icon.Paths {
tokens := reSVGToken.FindAllString(path, -1)
var lx, ly, startX, startY float64
var pts []gopdf.Point var pts []gopdf.Point
var currentCmd string matches := reSVG.FindAllStringSubmatch(path, -1)
var isRel bool var lx, ly float64
for i := 0; i < len(tokens); { for i := 0; i < len(matches); {
token := tokens[i] cmd := matches[i][0]
if (token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z') { if cmd == "M" || cmd == "L" {
cmd := strings.ToUpper(token) x, _ := strconv.ParseFloat(matches[i+1][0], 64); y, _ := strconv.ParseFloat(matches[i+2][0], 64)
if cmd == "M" {
if len(pts) > 0 {
results = append(results, PathResult{Points: pts, Closed: false})
pts = nil
}
} else if cmd == "Z" {
if len(pts) > 0 {
results = append(results, PathResult{Points: pts, Closed: true})
pts = nil
}
lx, ly = startX, startY
i++
continue
}
currentCmd = cmd
isRel = (token[0] >= 'a' && token[0] <= 'z')
i++
continue
}
switch currentCmd {
case "M", "L":
if i+1 >= len(tokens) {
i = len(tokens)
break
}
x, _ := strconv.ParseFloat(tokens[i], 64)
y, _ := strconv.ParseFloat(tokens[i+1], 64)
if isRel {
x += lx
y += ly
}
if currentCmd == "M" {
startX, startY = x, y
}
lx, ly = x, y lx, ly = x, y
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale}) pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
i += 2 i += 3
if currentCmd == "M" { } else if cmd == "C" {
currentCmd = "L" x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
} x2, _ := strconv.ParseFloat(matches[i+3][0], 64); y2, _ := strconv.ParseFloat(matches[i+4][0], 64)
case "H": x, _ := strconv.ParseFloat(matches[i+5][0], 64); y, _ := strconv.ParseFloat(matches[i+6][0], 64)
x, _ := strconv.ParseFloat(tokens[i], 64)
if isRel {
x += lx
}
lx = x
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + ly*scale})
i++
case "V":
y, _ := strconv.ParseFloat(tokens[i], 64)
if isRel {
y += ly
}
ly = y
pts = append(pts, gopdf.Point{X: ox + lx*scale, Y: oy + y*scale})
i++
case "C":
if i+5 >= len(tokens) {
i = len(tokens)
break
}
x1, _ := strconv.ParseFloat(tokens[i], 64)
y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x2, _ := strconv.ParseFloat(tokens[i+2], 64)
y2, _ := strconv.ParseFloat(tokens[i+3], 64)
x, _ := strconv.ParseFloat(tokens[i+4], 64)
y, _ := strconv.ParseFloat(tokens[i+5], 64)
if isRel {
x1 += lx
y1 += ly
x2 += lx
y2 += ly
x += lx
y += ly
}
for t := 0.2; t <= 1.0; t += 0.2 {
invT := 1 - t
tx := invT*invT*invT*lx + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
ty := invT*invT*invT*ly + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
}
lx, ly = x, y
i += 6
case "Q":
if i+3 >= len(tokens) {
i = len(tokens)
break
}
x1, _ := strconv.ParseFloat(tokens[i], 64)
y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x, _ := strconv.ParseFloat(tokens[i+2], 64)
y, _ := strconv.ParseFloat(tokens[i+3], 64)
if isRel {
x1 += lx
y1 += ly
x += lx
y += ly
}
for t := 0.25; t <= 1.0; t += 0.25 { for t := 0.25; t <= 1.0; t += 0.25 {
invT := 1 - t tx := math.Pow(1-t, 3)*lx + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
tx := invT*invT*lx + 2*invT*t*x1 + t*t*x ty := math.Pow(1-t, 3)*ly + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
ty := invT*invT*ly + 2*invT*t*y1 + t*t*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale}) pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
} }
lx, ly = x, y; i += 7
lx, ly = x, y } else if cmd == "Q" {
i += 4 x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
case "A": x, _ := strconv.ParseFloat(matches[i+3][0], 64); y, _ := strconv.ParseFloat(matches[i+4][0], 64)
i += 7 for t := 0.25; t <= 1.0; t += 0.25 {
default: tx := math.Pow(1-t, 2)*lx + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
i++ ty := math.Pow(1-t, 2)*ly + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
} }
lx, ly = x, y; i += 5
} else { i++ }
} }
if len(pts) > 0 { return pts
results = append(results, PathResult{Points: pts, Closed: false})
}
}
return results
} }
-222
View File
@@ -1,222 +0,0 @@
package logic
import (
"bytes"
"math"
"math/rand"
"strconv"
"toolbox/pkg/learnnumber/data"
"github.com/signintech/gopdf"
)
type WritingRequest struct {
StartNum int `json:"start_num"`
EndNum int `json:"end_num"`
PaperSize string `json:"paper_size"`
}
type WritingNode struct {
X, Y, R float64
AngleA, AngleB float64
BulgeR, Dist float64
Number int
}
func GenerateWritingPDF(req WritingRequest) ([]byte, error) {
pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89}
if req.PaperSize == "Letter" {
rect = gopdf.Rect{W: 612, H: 792}
}
pdf.Start(gopdf.Config{PageSize: rect})
err := pdf.AddTTFFontData("basic", data.FontContent)
if err != nil {
return nil, err
}
currentNum := req.StartNum
// 只要还没达到 EndNum,就继续生成“整页”内容
for currentNum <= req.EndNum {
pdf.AddPage()
lastPlaced := drawOneFullPage(pdf, currentNum, rect.W, rect.H)
currentNum = lastPlaced + 1
}
var buf bytes.Buffer
_, err = pdf.WriteTo(&buf)
return buf.Bytes(), err
}
// 核心改变:不再受限于 end 参数,而是尝试填满整页
func drawOneFullPage(pdf *gopdf.GoPdf, start int, pW, pH float64) int {
margin := 80.0
boxW, boxH := pW-2*margin, pH-160.0
xBase, yBase := margin, 100.0
nodeR := 28.0
bulgeR := nodeR * 0.32
dist := nodeR + bulgeR + 18.0
checkR := nodeR * 1.3
var nodes []WritingNode
num := start
// 无限循环,直到页面塞不下为止
for {
placed := false
for retry := 0; retry < 1000; retry++ {
rx := xBase + dist + 20 + rand.Float64()*(boxW-2*dist-40)
ry := yBase + dist + 20 + rand.Float64()*(boxH-2*dist-40)
if isColliding(rx, ry, checkR, nodes) {
continue
}
angleA := rand.Float64() * 2 * math.Pi
angleB := angleA + math.Pi + (rand.Float64()-0.5)*1.5
nodes = append(nodes, WritingNode{X: rx, Y: ry, R: nodeR, AngleA: angleA, AngleB: angleB, BulgeR: bulgeR, Dist: dist, Number: num})
placed = true
break
}
if !placed {
// 页面已满
break
}
num++
}
// 空间优化
optimizeSpace(nodes, xBase, yBase, boxW, boxH, dist, checkR)
// 绘制
for _, n := range nodes {
drawFullNode(pdf, n)
}
// 返回本页最后一个数字
if len(nodes) > 0 {
return nodes[len(nodes)-1].Number
}
return start
}
func optimizeSpace(nodes []WritingNode, xBase, yBase, boxW, boxH, dist, checkR float64) {
if len(nodes) < 2 {
return
}
for iter := 0; iter < 120; iter++ {
for i := range nodes {
bestX, bestY := nodes[i].X, nodes[i].Y
maxMinDist := calcMinDist(bestX, bestY, i, nodes)
for k := 0; k < 8; k++ {
moveAngle := rand.Float64() * 2 * math.Pi
nx := nodes[i].X + math.Cos(moveAngle)*5.0
ny := nodes[i].Y + math.Sin(moveAngle)*5.0
if nx-dist < xBase || nx+dist > xBase+boxW || ny-dist < yBase || ny+dist > yBase+boxH {
continue
}
if isColliding(nx, ny, checkR, nodes[:i]) || isColliding(nx, ny, checkR, nodes[i+1:]) {
continue
}
newMinDist := calcMinDist(nx, ny, i, nodes)
if newMinDist > maxMinDist {
maxMinDist = newMinDist
bestX, bestY = nx, ny
}
}
nodes[i].X, nodes[i].Y = bestX, bestY
}
}
}
func isColliding(x, y, r float64, existing []WritingNode) bool {
for _, e := range existing {
dx := x - e.X
dy := y - e.Y
d := math.Sqrt(dx*dx + dy*dy)
if d < (r + e.R*1.3 + 30.0) {
return true
}
}
return false
}
func calcMinDist(x, y float64, idx int, nodes []WritingNode) float64 {
minD := 10000.0
for i, n := range nodes {
if i == idx {
continue
}
dx := x - n.X
dy := y - n.Y
d := math.Sqrt(dx*dx + dy*dy)
if d < minD {
minD = d
}
}
return minD
}
func getEdgePoint(x, y, r, angle float64, isCircle bool) (float64, float64) {
gap := 2.0
er := r + gap
if isCircle {
return x + math.Cos(angle)*er, y + math.Sin(angle)*er
}
absCos, absSin := math.Abs(math.Cos(angle)), math.Abs(math.Sin(angle))
var d float64
if absCos > absSin {
d = er / absCos
} else {
d = er / absSin
}
return x + math.Cos(angle)*d, y + math.Sin(angle)*d
}
func drawCenteredText(pdf *gopdf.GoPdf, cx, cy float64, text string, fontSize float64) {
_ = pdf.SetFont("basic", "", fontSize)
tw, _ := pdf.MeasureTextWidth(text)
pdf.SetXY(cx-tw/2, cy+fontSize*0.38)
_ = pdf.Text(text)
}
func drawFullNode(pdf *gopdf.GoPdf, n WritingNode) {
isOdd := n.Number%2 != 0
pdf.SetStrokeColor(0, 0, 0)
pdf.SetLineWidth(1.8)
if isOdd {
pdf.Oval(n.X-n.R, n.Y-n.R, n.X+n.R, n.Y+n.R)
} else {
pdf.RectFromUpperLeft(n.X-n.R, n.Y-n.R, n.R*2, n.R*2)
}
pdf.SetTextColor(220, 220, 220)
drawCenteredText(pdf, n.X, n.Y, strconv.Itoa(n.Number), n.R*1.35)
pdf.SetStrokeColor(180, 180, 180)
pdf.SetLineWidth(1.0)
ax, ay := n.X+math.Cos(n.AngleA)*n.Dist, n.Y+math.Sin(n.AngleA)*n.Dist
lx1, ly1 := getEdgePoint(n.X, n.Y, n.R, n.AngleA, isOdd)
lx2, ly2 := getEdgePoint(ax, ay, n.BulgeR, n.AngleA+math.Pi, !isOdd)
pdf.Line(lx1, ly1, lx2, ly2)
if isOdd {
pdf.RectFromUpperLeft(ax-n.BulgeR, ay-n.BulgeR, n.BulgeR*2, n.BulgeR*2)
} else {
pdf.Oval(ax-n.BulgeR, ay-n.BulgeR, ax+n.BulgeR, ay+n.BulgeR)
}
bx, by := n.X+math.Cos(n.AngleB)*n.Dist, n.Y+math.Sin(n.AngleB)*n.Dist
lx3, ly3 := getEdgePoint(n.X, n.Y, n.R, n.AngleB, isOdd)
lx4, ly4 := getEdgePoint(bx, by, n.BulgeR, n.AngleB+math.Pi, !isOdd)
pdf.Line(lx3, ly3, lx4, ly4)
if isOdd {
pdf.RectFromUpperLeft(bx-n.BulgeR, by-n.BulgeR, n.BulgeR*2, n.BulgeR*2)
} else {
pdf.Oval(bx-n.BulgeR, by-n.BulgeR, bx+n.BulgeR, by+n.BulgeR)
}
pdf.SetTextColor(120, 120, 120)
drawCenteredText(pdf, bx, by, strconv.Itoa(n.Number+1), math.Max(7, n.BulgeR*1.2))
}
+7 -28
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"toolbox/pkg/base" "toolbox/pkg/base"
"toolbox/pkg/learnnumber/data"
"toolbox/pkg/learnnumber/logic" "toolbox/pkg/learnnumber/logic"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -18,40 +17,15 @@ func init() {
func (t *learnNumberTool) ID() string { return "learn-number" } func (t *learnNumberTool) ID() string { return "learn-number" }
func (t *learnNumberTool) Name() string { return "幼儿数学助手" } func (t *learnNumberTool) Name() string { return "幼儿数学助手" }
func (t *learnNumberTool) Description() string { func (t *learnNumberTool) Description() string { return "包含数图形、基础加减法等趣味数学练习" }
return "包含数图形、基础加减法等趣味数学练习"
}
func (t *learnNumberTool) Emoji() string { return "🔢" }
func (t *learnNumberTool) Init() error { func (t *learnNumberTool) Init() error {
fmt.Println("Initializing Learn-Number tool...") fmt.Println("Initializing Learn-Number tool...")
// 从内嵌资源加载匿名对象 return nil
return data.LoadIconsFromEmbed()
} }
func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) { func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) {
r.POST("/counting", t.handleCounting) r.POST("/counting", t.handleCounting)
r.POST("/writing", t.handleWriting)
r.GET("/categories", t.handleCategories)
}
func (t *learnNumberTool) handleWriting(c *gin.Context) {
var req logic.WritingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pdfBytes, err := logic.GenerateWritingPDF(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}
func (t *learnNumberTool) handleCategories(c *gin.Context) {
// 返回分类信息供前端预览
c.JSON(http.StatusOK, data.IconCategories)
} }
func (t *learnNumberTool) handleCounting(c *gin.Context) { func (t *learnNumberTool) handleCounting(c *gin.Context) {
@@ -60,10 +34,15 @@ func (t *learnNumberTool) handleCounting(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if req.TotalCount <= 0 { req.TotalCount = 20 }
if req.TotalCount > 30 { req.TotalCount = 30 }
pdfBytes, err := logic.GenerateCountingPDF(req) pdfBytes, err := logic.GenerateCountingPDF(req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.Data(http.StatusOK, "application/pdf", pdfBytes) c.Data(http.StatusOK, "application/pdf", pdfBytes)
} }
-434
View File
@@ -1,434 +0,0 @@
package orbitviewer
import (
"fmt"
"math"
"net/http"
"sort"
"time"
"github.com/gin-gonic/gin"
)
type TrackPoint struct {
Timestamp int64 `json:"timestamp"` // Unix timestamp in seconds
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude float64 `json:"altitude"`
Azimuth *float64 `json:"azimuth,omitempty"`
Elevation *float64 `json:"elevation,omitempty"`
}
type TrackResponse struct {
SatelliteID string `json:"satellite_id"`
SatelliteName string `json:"satellite_name"`
NoradID int `json:"norad_id"`
CurrentTime time.Time `json:"current_time"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
AltitudeKm float64 `json:"altitude_km"`
VelocityKmS float64 `json:"velocity_km_s"`
FootprintKm float64 `json:"footprint_km"`
IsSunlit bool `json:"is_sunlit"`
SubSolarPoint Geodetic `json:"sub_solar_point"`
PastTrack []TrackPoint `json:"past_track"`
FutureTrack []TrackPoint `json:"future_track"`
OrbitalRing []TrackPoint `json:"orbital_ring,omitempty"`
ObserverLook *LookAngles `json:"observer_look,omitempty"`
}
type TrackRequest struct {
SatelliteID string `json:"satellite_id"`
CustomTLE *TLEInput `json:"custom_tle,omitempty"`
Observer *Geodetic `json:"observer,omitempty"`
Timestamp *string `json:"timestamp,omitempty"`
}
type TLEInput struct {
Name string `json:"name"`
Line1 string `json:"line1"`
Line2 string `json:"line2"`
}
type PassesRequest struct {
SatelliteID string `json:"satellite_id"`
CustomTLE *TLEInput `json:"custom_tle,omitempty"`
Observer Geodetic `json:"observer"`
Days int `json:"days"`
MinElevationDeg float64 `json:"min_elevation_deg"`
VisibleOnly bool `json:"visible_only"`
}
type PositionRequest struct {
SatelliteID string `json:"satellite_id"`
CustomTLE *TLEInput `json:"custom_tle,omitempty"`
Observer Geodetic `json:"observer"`
Timestamp *string `json:"timestamp,omitempty"`
}
func getSGP4Model(satID string, custom *TLEInput) (*SGP4Model, *SatelliteCatalogEntry, error) {
if custom != nil && custom.Line1 != "" && custom.Line2 != "" {
tle, err := ParseTLE(custom.Name, custom.Line1, custom.Line2)
if err != nil {
return nil, nil, fmt.Errorf("invalid custom TLE: %w", err)
}
model, err := NewSGP4Model(tle)
if err != nil {
return nil, nil, err
}
return model, &SatelliteCatalogEntry{
ID: "custom",
Name: tle.Name,
NoradID: tle.NoradID,
StandardMag: 3.0,
TLE: tle,
}, nil
}
if satID == "" {
satID = "iss"
}
entry, ok := DefaultCatalog.Get(satID)
if !ok {
return nil, nil, fmt.Errorf("satellite %s not found in catalog", satID)
}
model, err := NewSGP4Model(entry.TLE)
if err != nil {
return nil, nil, err
}
return model, entry, nil
}
func RegisterRoutes(r *gin.RouterGroup) {
// List satellites
r.GET("/satellites", func(c *gin.Context) {
entries := DefaultCatalog.GetAll()
sort.Slice(entries, func(i, j int) bool {
return entries[i].NoradID < entries[j].NoradID
})
c.JSON(http.StatusOK, gin.H{"satellites": entries})
})
// Refresh satellite TLE from CelesTrak
r.POST("/refresh", func(c *gin.Context) {
var req struct {
SatelliteID string `json:"satellite_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.SatelliteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "satellite_id is required"})
return
}
if err := DefaultCatalog.RefreshFromCelesTrak(req.SatelliteID); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
entry, _ := DefaultCatalog.Get(req.SatelliteID)
c.JSON(http.StatusOK, gin.H{"message": "refreshed successfully", "satellite": entry})
})
// Add custom TLE
r.POST("/custom", func(c *gin.Context) {
var req TLEInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
entry, err := DefaultCatalog.AddCustomTLE(req.Name, req.Line1, req.Line2)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"satellite": entry})
})
// Ground track and current telemetry
r.POST("/track", func(c *gin.Context) {
var req TrackRequest
if err := c.ShouldBindJSON(&req); err != nil {
// default to ISS
req.SatelliteID = "iss"
}
model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
t := time.Now().UTC()
if req.Timestamp != nil && *req.Timestamp != "" {
if parsed, err := time.Parse(time.RFC3339, *req.Timestamp); err == nil {
t = parsed.UTC()
}
}
geo, vel, err := model.GeodeticPosition(t)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
posECEF, _, _ := model.PositionECEF(t)
jd := JulianDate(t)
isSunlit := IsSatelliteIlluminated(posECEF, jd)
velKmS := math.Sqrt(vel.X*vel.X + vel.Y*vel.Y + vel.Z*vel.Z)
footprintKm := FootprintRadiusKm(geo.Altitude)
// Subsolar point
sunECEF := SunPositionECEF(jd)
subsolar := ECEFtoGeodetic(sunECEF)
currTrackPt := TrackPoint{
Timestamp: t.Unix(),
Latitude: math.Round(geo.Latitude*10000) / 10000,
Longitude: math.Round(geo.Longitude*10000) / 10000,
Altitude: math.Round(geo.Altitude*10) / 10,
}
if req.Observer != nil {
l := CalculateLookAngles(*req.Observer, posECEF)
az := math.Round(l.Azimuth*10) / 10
el := math.Round(l.Elevation*10) / 10
currTrackPt.Azimuth = &az
currTrackPt.Elevation = &el
}
// Past track (-45 min to 0, every 30 seconds)
var pastTrack []TrackPoint
for s := -45 * 60; s < 0; s += 30 {
ptTime := t.Add(time.Duration(s) * time.Second)
pGeo, _, err := model.GeodeticPosition(ptTime)
if err == nil {
pt := TrackPoint{
Timestamp: ptTime.Unix(),
Latitude: math.Round(pGeo.Latitude*1000) / 1000,
Longitude: math.Round(pGeo.Longitude*1000) / 1000,
Altitude: math.Round(pGeo.Altitude*10) / 10,
}
if req.Observer != nil {
pECEF, _, errECEF := model.PositionECEF(ptTime)
if errECEF == nil {
l := CalculateLookAngles(*req.Observer, pECEF)
az := math.Round(l.Azimuth*10) / 10
el := math.Round(l.Elevation*10) / 10
pt.Azimuth = &az
pt.Elevation = &el
}
}
pastTrack = append(pastTrack, pt)
}
}
pastTrack = append(pastTrack, currTrackPt)
// Future track (0 to +95 min, every 30 seconds)
var futureTrack []TrackPoint
futureTrack = append(futureTrack, currTrackPt)
for s := 30; s <= 95*60; s += 30 {
ptTime := t.Add(time.Duration(s) * time.Second)
pGeo, _, err := model.GeodeticPosition(ptTime)
if err == nil {
pt := TrackPoint{
Timestamp: ptTime.Unix(),
Latitude: math.Round(pGeo.Latitude*1000) / 1000,
Longitude: math.Round(pGeo.Longitude*1000) / 1000,
Altitude: math.Round(pGeo.Altitude*10) / 10,
}
if req.Observer != nil {
pECEF, _, errECEF := model.PositionECEF(ptTime)
if errECEF == nil {
l := CalculateLookAngles(*req.Observer, pECEF)
az := math.Round(l.Azimuth*10) / 10
el := math.Round(l.Elevation*10) / 10
pt.Azimuth = &az
pt.Elevation = &el
}
}
futureTrack = append(futureTrack, pt)
}
}
// Instantaneous 3D orbital ring at timestamp t (fixed inertial frame relative to observer)
var orbitalRing []TrackPoint
periodMin := 92.8
if model.TLE.MeanMotionRevDay > 0 {
periodMin = 1440.0 / model.TLE.MeanMotionRevDay
}
periodSec := periodMin * 60.0
steps := 120
stepSec := periodSec / float64(steps)
for i := 0; i <= steps; i++ {
tau := t.Add(time.Duration(float64(i)*stepSec) * time.Second)
posTEME, _, errTEME := model.PropagateTime(tau)
if errTEME == nil {
// Freeze Earth at time t (jd) so the ring is a true static circle in ECEF/topocentric frame
pRingECEF := TEMEtoECEF(posTEME, jd)
geoRing := ECEFtoGeodetic(pRingECEF)
pt := TrackPoint{
Timestamp: tau.Unix(),
Latitude: math.Round(geoRing.Latitude*1000) / 1000,
Longitude: math.Round(geoRing.Longitude*1000) / 1000,
Altitude: math.Round(geoRing.Altitude*10) / 10,
}
if req.Observer != nil {
l := CalculateLookAngles(*req.Observer, pRingECEF)
az := math.Round(l.Azimuth*10) / 10
el := math.Round(l.Elevation*10) / 10
pt.Azimuth = &az
pt.Elevation = &el
}
orbitalRing = append(orbitalRing, pt)
}
}
var look *LookAngles
if req.Observer != nil {
l := CalculateLookAngles(*req.Observer, posECEF)
look = &LookAngles{
Azimuth: math.Round(l.Azimuth*10) / 10,
Elevation: math.Round(l.Elevation*10) / 10,
Range: math.Round(l.Range*10) / 10,
}
}
c.JSON(http.StatusOK, TrackResponse{
SatelliteID: entry.ID,
SatelliteName: entry.Name,
NoradID: entry.NoradID,
CurrentTime: t,
Latitude: math.Round(geo.Latitude*10000) / 10000,
Longitude: math.Round(geo.Longitude*10000) / 10000,
AltitudeKm: math.Round(geo.Altitude*10) / 10,
VelocityKmS: math.Round(velKmS*100) / 100,
FootprintKm: math.Round(footprintKm),
IsSunlit: isSunlit,
SubSolarPoint: Geodetic{
Latitude: math.Round(subsolar.Latitude*100) / 100,
Longitude: math.Round(subsolar.Longitude*100) / 100,
Altitude: 0,
},
PastTrack: pastTrack,
FutureTrack: futureTrack,
OrbitalRing: orbitalRing,
ObserverLook: look,
})
})
// Pass prediction
r.POST("/passes", func(c *gin.Context) {
var req PassesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Days <= 0 || req.Days > 14 {
req.Days = 10
}
if req.MinElevationDeg <= 0 {
req.MinElevationDeg = 10.0
}
allPasses, err := PredictPasses(model, PredictPassesOptions{
Observer: req.Observer,
StartTime: time.Now().UTC(),
Days: req.Days,
MinElevationDeg: req.MinElevationDeg,
VisibleOnly: false,
StandardMag: entry.StandardMag,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filtered := allPasses
if req.VisibleOnly {
filtered = make([]SatellitePass, 0)
for _, p := range allPasses {
if p.Visibility == "visible" {
filtered = append(filtered, p)
}
}
}
c.JSON(http.StatusOK, gin.H{
"satellite_id": entry.ID,
"satellite_name": entry.Name,
"norad_id": entry.NoradID,
"observer": req.Observer,
"days": req.Days,
"visible_only": req.VisibleOnly,
"total_count": len(allPasses),
"count": len(filtered),
"passes": filtered,
})
})
// Instantaneous look angles & position (for AR mode & real-time sync)
r.POST("/position", func(c *gin.Context) {
var req PositionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
t := time.Now().UTC()
if req.Timestamp != nil && *req.Timestamp != "" {
if parsed, err := time.Parse(time.RFC3339Nano, *req.Timestamp); err == nil {
t = parsed.UTC()
} else if parsed, err := time.Parse(time.RFC3339, *req.Timestamp); err == nil {
t = parsed.UTC()
}
}
geo, vel, err := model.GeodeticPosition(t)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
posECEF, _, _ := model.PositionECEF(t)
look := CalculateLookAngles(req.Observer, posECEF)
jd := JulianDate(t)
isSunlit := IsSatelliteIlluminated(posECEF, jd)
sunEl := ObserverSunElevation(req.Observer, t)
obsECEF := GeodeticToECEF(req.Observer)
apparentMag := EstimateVisualMagnitude(entry.StandardMag, satECEF(posECEF), obsECEF, jd)
c.JSON(http.StatusOK, gin.H{
"timestamp": t.UnixNano() / 1e6, // ms
"satellite_id": entry.ID,
"satellite_name": entry.Name,
"latitude": math.Round(geo.Latitude*10000) / 10000,
"longitude": math.Round(geo.Longitude*10000) / 10000,
"altitude_km": math.Round(geo.Altitude*10) / 10,
"velocity_km_s": math.Round(math.Sqrt(vel.X*vel.X+vel.Y*vel.Y+vel.Z*vel.Z)*100) / 100,
"azimuth": math.Round(look.Azimuth*10) / 10,
"azimuth_cardinal": CardinalDirection(look.Azimuth),
"elevation": math.Round(look.Elevation*10) / 10,
"range_km": math.Round(look.Range*10) / 10,
"is_sunlit": isSunlit,
"sun_elevation": math.Round(sunEl*10) / 10,
"apparent_mag": apparentMag,
})
})
}
func satECEF(v Vector3) Vector3 {
return v
}
-201
View File
@@ -1,201 +0,0 @@
package orbitviewer
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func setupTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api/orbit-viewer")
RegisterRoutes(api)
return r
}
func TestAPISatellites(t *testing.T) {
r := setupTestRouter()
req, _ := http.NewRequest("GET", "/api/orbit-viewer/satellites", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", w.Code)
}
var resp struct {
Satellites []*SatelliteCatalogEntry `json:"satellites"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
if len(resp.Satellites) == 0 {
t.Errorf("Expected satellites in catalog, got 0")
}
hasISS := false
for _, sat := range resp.Satellites {
if sat.ID == "iss" {
hasISS = true
break
}
}
if !hasISS {
t.Errorf("Expected ISS in satellite catalog")
}
}
func TestAPITrack(t *testing.T) {
r := setupTestRouter()
payload := TrackRequest{
SatelliteID: "iss",
Observer: &Geodetic{
Latitude: 39.9042,
Longitude: 116.4074,
Altitude: 0.05,
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/orbit-viewer/track", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp TrackResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
if resp.SatelliteID != "iss" {
t.Errorf("Expected satellite_id iss, got %s", resp.SatelliteID)
}
if resp.AltitudeKm < 300 || resp.AltitudeKm > 500 {
t.Errorf("Unrealistic ISS altitude: %f km", resp.AltitudeKm)
}
if len(resp.PastTrack) == 0 || len(resp.FutureTrack) == 0 {
t.Errorf("Expected track points in response")
}
if resp.ObserverLook == nil {
t.Errorf("Expected ObserverLook populated")
}
}
func TestAPIPasses(t *testing.T) {
r := setupTestRouter()
payload := PassesRequest{
SatelliteID: "iss",
Observer: Geodetic{
Latitude: 37.7749,
Longitude: -122.4194,
Altitude: 0.03,
},
Days: 10,
MinElevationDeg: 10.0,
VisibleOnly: false,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/orbit-viewer/passes", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Count int `json:"count"`
Passes []SatellitePass `json:"passes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
if resp.Count == 0 {
t.Errorf("Expected passes for ISS over 10 days, got 0")
}
}
func TestAPIPosition(t *testing.T) {
r := setupTestRouter()
payload := PositionRequest{
SatelliteID: "iss",
Observer: Geodetic{
Latitude: 51.5074,
Longitude: -0.1278,
Altitude: 0.02,
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/orbit-viewer/position", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
if _, ok := resp["azimuth"]; !ok {
t.Errorf("Missing azimuth in position response")
}
if _, ok := resp["elevation"]; !ok {
t.Errorf("Missing elevation in position response")
}
if _, ok := resp["range_km"]; !ok {
t.Errorf("Missing range_km in position response")
}
}
func TestAPICustomTLE(t *testing.T) {
r := setupTestRouter()
payload := TLEInput{
Name: "TEST-CSS",
Line1: "1 48274U 21035A 24080.45678901 .00021456 00000-0 31456-3 0 9991",
Line2: "2 48274 41.4723 112.3456 0005123 85.2341 274.9812 15.61234567167890",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/orbit-viewer/custom", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Satellite *SatelliteCatalogEntry `json:"satellite"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
if resp.Satellite.NoradID != 48274 {
t.Errorf("Expected NORAD ID 48274, got %d", resp.Satellite.NoradID)
}
}
-282
View File
@@ -1,282 +0,0 @@
package orbitviewer
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// SatelliteCatalogEntry holds metadata and orbital data for a satellite.
type SatelliteCatalogEntry struct {
ID string `json:"id"`
Name string `json:"name"`
NoradID int `json:"norad_id"`
Description string `json:"description"`
StandardMag float64 `json:"standard_mag"`
TLE TLE `json:"tle"`
UpdatedAt time.Time `json:"updated_at"`
}
type CatalogManager struct {
mu sync.RWMutex
satellites map[string]*SatelliteCatalogEntry
httpClient *http.Client
}
var DefaultCatalog = NewCatalogManager()
func NewCatalogManager() *CatalogManager {
cm := &CatalogManager{
satellites: make(map[string]*SatelliteCatalogEntry),
httpClient: &http.Client{Timeout: 8 * time.Second},
}
cm.initPresets()
// Fetch fresh live TLEs from CelesTrak in background
go cm.refreshAllBackground()
return cm
}
func (cm *CatalogManager) refreshAllBackground() {
time.Sleep(500 * time.Millisecond)
cm.mu.RLock()
ids := make([]string, 0, len(cm.satellites))
for id := range cm.satellites {
ids = append(ids, id)
}
cm.mu.RUnlock()
for _, id := range ids {
_ = cm.RefreshFromCelesTrak(id)
time.Sleep(200 * time.Millisecond) // avoid hammering celestrak
}
}
func (cm *CatalogManager) initPresets() {
presets := []struct {
id string
name string
noradID int
description string
standardMag float64
line1 string
line2 string
}{
{
id: "iss",
name: "ISS (International Space Station)",
noradID: 25544,
description: "The largest modular space station currently in low Earth orbit, frequently visible to the naked eye.",
standardMag: -1.8,
line1: "1 25544U 98067A 26265.50000000 .00014524 00000-0 26388-3 0 9993",
line2: "2 25544 51.6416 195.4568 0004928 120.1234 239.9876 15.49876543445566",
},
{
id: "tiangong",
name: "Tiangong (CSS)",
noradID: 48274,
description: "Chinese space station in low Earth orbit between 340 and 450 km altitude.",
standardMag: -1.0,
line1: "1 48274U 21035A 26265.45678901 .00021456 00000-0 31456-3 0 9991",
line2: "2 48274 41.4723 112.3456 0005123 85.2341 274.9812 15.61234567167890",
},
{
id: "hubble",
name: "Hubble Space Telescope (HST)",
noradID: 20580,
description: "Famous NASA/ESA optical space observatory operating in orbit since 1990.",
standardMag: 1.5,
line1: "1 20580U 90037B 26265.34567890 .00001234 00000-0 54321-4 0 9992",
line2: "2 20580 28.4691 245.1234 0002845 280.1234 79.8765 15.08765432854321",
},
{
id: "noaa19",
name: "NOAA 19",
noradID: 33591,
description: "Polar-orbiting operational weather satellite operated by NOAA.",
standardMag: 3.5,
line1: "1 33591U 09005A 26265.23456789 .00000123 00000-0 65432-4 0 9995",
line2: "2 33591 98.7123 145.6789 0014234 65.4321 294.7890 14.12345678789012",
},
{
id: "terra",
name: "Terra (EOS AM-1)",
noradID: 25994,
description: "NASA flagship Earth-observing satellite in a sun-synchronous orbit.",
standardMag: 2.0,
line1: "1 25994U 99068A 26265.12345678 .00000234 00000-0 76543-4 0 9998",
line2: "2 25994 98.2123 210.3456 0001234 110.1234 250.0123 14.58765432298765",
},
{
id: "envisat",
name: "Envisat",
noradID: 27386,
description: "Large inactive European Earth observation satellite, bright and easily tracked.",
standardMag: 2.0,
line1: "1 27386U 02009A 26265.65432109 .00000345 00000-0 87654-4 0 9997",
line2: "2 27386 98.5432 78.9012 0001567 95.4321 264.7654 14.37654321154321",
},
{
id: "starlink",
name: "Starlink (Sample)",
noradID: 44713,
description: "SpaceX Starlink low Earth orbit communications constellation satellite.",
standardMag: 4.5,
line1: "1 44713U 19074A 26265.51234567 .00001567 00000-0 12345-3 0 9990",
line2: "2 44713 53.0543 180.1234 0001456 80.1234 280.0123 15.06543210234567",
},
}
for _, p := range presets {
tle, err := ParseTLE(p.name, p.line1, p.line2)
if err == nil {
cm.satellites[p.id] = &SatelliteCatalogEntry{
ID: p.id,
Name: p.name,
NoradID: p.noradID,
Description: p.description,
StandardMag: p.standardMag,
TLE: tle,
UpdatedAt: time.Now(),
}
}
}
}
// GetAll returns all catalog entries sorted.
func (cm *CatalogManager) GetAll() []*SatelliteCatalogEntry {
cm.mu.RLock()
defer cm.mu.RUnlock()
result := make([]*SatelliteCatalogEntry, 0, len(cm.satellites))
for _, e := range cm.satellites {
result = append(result, e)
}
return result
}
// Get returns an entry by ID or NORAD ID string.
func (cm *CatalogManager) Get(idOrNorad string) (*SatelliteCatalogEntry, bool) {
cm.mu.RLock()
defer cm.mu.RUnlock()
if e, ok := cm.satellites[idOrNorad]; ok {
return e, true
}
for _, e := range cm.satellites {
if fmt.Sprintf("%d", e.NoradID) == idOrNorad {
return e, true
}
}
return nil, false
}
// RefreshFromCelesTrak fetches updated TLE for a given catalog entry by NORAD ID.
func (cm *CatalogManager) RefreshFromCelesTrak(id string) error {
cm.mu.RLock()
entry, ok := cm.satellites[id]
cm.mu.RUnlock()
if !ok {
return fmt.Errorf("satellite ID %s not found in catalog", id)
}
urls := []string{
fmt.Sprintf("https://celestrak.org/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID),
fmt.Sprintf("https://celestrak.com/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID),
}
var body []byte
var lastErr error
for _, targetURL := range urls {
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
lastErr = err
continue
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/plain, */*")
resp, err := cm.httpClient.Do(req)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode == http.StatusOK {
data, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err == nil && len(data) > 60 {
body = data
lastErr = nil
break
}
} else {
_ = resp.Body.Close()
lastErr = fmt.Errorf("celestrak returned HTTP %d", resp.StatusCode)
}
}
if lastErr != nil && len(body) == 0 {
return lastErr
}
lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n")
var l1, l2, name string
for _, l := range lines {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "1 ") && len(l) >= 68 {
l1 = l
} else if strings.HasPrefix(l, "2 ") && len(l) >= 68 {
l2 = l
} else if l != "" && !strings.HasPrefix(l, "1 ") && !strings.HasPrefix(l, "2 ") && name == "" {
name = l
}
}
if l1 == "" || l2 == "" {
return fmt.Errorf("invalid TLE response from CelesTrak for NORAD %d", entry.NoradID)
}
if name == "" {
name = entry.Name
}
tle, err := ParseTLE(name, l1, l2)
if err != nil {
return err
}
cm.mu.Lock()
entry.TLE = tle
entry.UpdatedAt = time.Now()
cm.mu.Unlock()
return nil
}
// AddCustomTLE adds or updates a custom user-supplied TLE into the catalog.
func (cm *CatalogManager) AddCustomTLE(name, line1, line2 string) (*SatelliteCatalogEntry, error) {
tle, err := ParseTLE(name, line1, line2)
if err != nil {
return nil, err
}
id := fmt.Sprintf("custom-%d", tle.NoradID)
entry := &SatelliteCatalogEntry{
ID: id,
Name: tle.Name,
NoradID: tle.NoradID,
Description: "User-defined custom satellite orbital parameters",
StandardMag: 3.0,
TLE: tle,
UpdatedAt: time.Now(),
}
cm.mu.Lock()
cm.satellites[id] = entry
cm.mu.Unlock()
return entry, nil
}
-229
View File
@@ -1,229 +0,0 @@
package orbitviewer
import (
"math"
"time"
)
const (
EarthRadiusKm = 6378.137 // WGS-84 equatorial radius in km
EarthFlattening = 1.0 / 298.257223563
Deg2Rad = math.Pi / 180.0
Rad2Deg = 180.0 / math.Pi
)
// Vector3 represents a 3D vector [x, y, z] in km or km/s.
type Vector3 struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
// Geodetic represents latitude, longitude (in degrees) and altitude (in km).
type Geodetic struct {
Latitude float64 `json:"latitude"` // Degrees (-90 to +90)
Longitude float64 `json:"longitude"` // Degrees (-180 to +180)
Altitude float64 `json:"altitude"` // Kilometers above WGS-84 ellipsoid
}
// LookAngles represents observer-relative topocentric angles.
type LookAngles struct {
Azimuth float64 `json:"azimuth"` // Degrees (0 to 360, 0=North, 90=East)
Elevation float64 `json:"elevation"` // Degrees (-90 to +90)
Range float64 `json:"range"` // Distance in km
}
// JulianDate calculates the Julian Date for a given UTC time.
func JulianDate(t time.Time) float64 {
t = t.UTC()
year := t.Year()
month := int(t.Month())
day := t.Day()
hour := t.Hour()
min := t.Minute()
sec := t.Second()
nsec := t.Nanosecond()
dayFraction := (float64(hour) + float64(min)/60.0 + (float64(sec)+float64(nsec)/1e9)/3600.0) / 24.0
if month <= 2 {
year--
month += 12
}
a := math.Floor(float64(year) / 100.0)
b := 2.0 - a + math.Floor(a/4.0)
jd := math.Floor(365.25*float64(year+4716)) +
math.Floor(30.6001*float64(month+1)) +
float64(day) + dayFraction + b - 1524.5
return jd
}
// GMST calculates Greenwich Mean Sidereal Time in radians for a given Julian Date.
func GMST(jd float64) float64 {
d := jd - 2451545.0 // Days since J2000.0
// Standard formula in degrees:
gmstDeg := 280.46061837 + 360.98564736629*d
// Normalize to 0 .. 360
gmstDeg = math.Mod(gmstDeg, 360.0)
if gmstDeg < 0 {
gmstDeg += 360.0
}
return gmstDeg * Deg2Rad
}
// TEMEtoECEF converts True Equator Mean Equinox (TEME) coordinates to Earth-Centered Earth-Fixed (ECEF) coordinates.
func TEMEtoECEF(pos Vector3, jd float64) Vector3 {
theta := GMST(jd)
cosTheta := math.Cos(theta)
sinTheta := math.Sin(theta)
return Vector3{
X: pos.X*cosTheta + pos.Y*sinTheta,
Y: -pos.X*sinTheta + pos.Y*cosTheta,
Z: pos.Z,
}
}
// ECEFtoGeodetic converts ECEF coordinates (in km) to WGS-84 Geodetic (Lat, Lon, Alt).
func ECEFtoGeodetic(pos Vector3) Geodetic {
x := pos.X
y := pos.Y
z := pos.Z
a := EarthRadiusKm
f := EarthFlattening
b := a * (1.0 - f)
e2 := (a*a - b*b) / (a * a)
ep2 := (a*a - b*b) / (b * b)
p := math.Sqrt(x*x + y*y)
theta := math.Atan2(z*a, p*b)
sinTheta := math.Sin(theta)
cosTheta := math.Cos(theta)
lat := math.Atan2(z+ep2*b*sinTheta*sinTheta*sinTheta, p-e2*a*cosTheta*cosTheta*cosTheta)
lon := math.Atan2(y, x)
sinLat := math.Sin(lat)
n := a / math.Sqrt(1.0-e2*sinLat*sinLat)
alt := p/math.Cos(lat) - n
latDeg := lat * Rad2Deg
lonDeg := lon * Rad2Deg
// Normalize longitude to -180..180
for lonDeg > 180 {
lonDeg -= 360
}
for lonDeg < -180 {
lonDeg += 360
}
return Geodetic{
Latitude: latDeg,
Longitude: lonDeg,
Altitude: alt,
}
}
// GeodeticToECEF converts WGS-84 Geodetic (lat, lon degrees, alt km) to ECEF (km).
func GeodeticToECEF(geo Geodetic) Vector3 {
latRad := geo.Latitude * Deg2Rad
lonRad := geo.Longitude * Deg2Rad
a := EarthRadiusKm
f := EarthFlattening
e2 := 2*f - f*f
sinLat := math.Sin(latRad)
cosLat := math.Cos(latRad)
sinLon := math.Sin(lonRad)
cosLon := math.Cos(lonRad)
n := a / math.Sqrt(1.0-e2*sinLat*sinLat)
return Vector3{
X: (n + geo.Altitude) * cosLat * cosLon,
Y: (n + geo.Altitude) * cosLat * sinLon,
Z: (n*(1.0-e2) + geo.Altitude) * sinLat,
}
}
// CalculateLookAngles computes Topocentric Azimuth (deg), Elevation (deg), and Range (km)
// of a target ECEF position from an observer Geodetic location.
func CalculateLookAngles(obs Geodetic, targetECEF Vector3) LookAngles {
obsECEF := GeodeticToECEF(obs)
// Range vector in ECEF
rx := targetECEF.X - obsECEF.X
ry := targetECEF.Y - obsECEF.Y
rz := targetECEF.Z - obsECEF.Z
rangeDist := math.Sqrt(rx*rx + ry*ry + rz*rz)
if rangeDist < 1e-6 {
return LookAngles{Azimuth: 0, Elevation: 90, Range: 0}
}
latRad := obs.Latitude * Deg2Rad
lonRad := obs.Longitude * Deg2Rad
sinLat := math.Sin(latRad)
cosLat := math.Cos(latRad)
sinLon := math.Sin(lonRad)
cosLon := math.Cos(lonRad)
// Topocentric horizon coordinates (East, North, Up)
east := -sinLon*rx + cosLon*ry
north := -sinLat*cosLon*rx - sinLat*sinLon*ry + cosLat*rz
up := cosLat*cosLon*rx + cosLat*sinLon*ry + sinLat*rz
azRad := math.Atan2(east, north)
azDeg := azRad * Rad2Deg
if azDeg < 0 {
azDeg += 360.0
}
elRad := math.Asin(up / rangeDist)
elDeg := elRad * Rad2Deg
return LookAngles{
Azimuth: azDeg,
Elevation: elDeg,
Range: rangeDist,
}
}
// FootprintRadiusKm returns the visual/radio horizon footprint radius on Earth's surface for an altitude in km.
func FootprintRadiusKm(altKm float64) float64 {
if altKm <= 0 {
return 0
}
r := EarthRadiusKm
// Central angle theta: cos(theta) = r / (r + h)
cosTheta := r / (r + altKm)
if cosTheta > 1.0 {
cosTheta = 1.0
}
theta := math.Acos(cosTheta)
return r * theta // Arc distance on Earth surface
}
// CardinalDirection converts azimuth degrees to an 8 or 16-point cardinal compass string.
func CardinalDirection(az float64) string {
az = math.Mod(az, 360.0)
if az < 0 {
az += 360.0
}
dirs := []string{
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N",
}
idx := int(math.Round(az / 22.5))
if idx >= len(dirs) {
idx = 0
}
return dirs[idx]
}
-274
View File
@@ -1,274 +0,0 @@
package orbitviewer
import (
"fmt"
"math"
"time"
)
// PassPoint represents a single point in time during a pass.
type PassPoint struct {
Time time.Time `json:"time"`
TimeFormatted string `json:"time_formatted"`
Azimuth float64 `json:"azimuth"`
AzimuthCardinal string `json:"azimuth_cardinal"`
Elevation float64 `json:"elevation"`
RangeKm float64 `json:"range_km"`
SunElevation float64 `json:"sun_elevation"`
IsSunlit bool `json:"is_sunlit"`
}
// SatellitePass describes a predicted pass over an observer's location.
type SatellitePass struct {
StartTimeFormatted string `json:"start_time_formatted"`
Start PassPoint `json:"start"`
Max PassPoint `json:"max"`
End PassPoint `json:"end"`
DurationSec int `json:"duration_sec"`
DurationFormatted string `json:"duration_formatted"`
Visibility string `json:"visibility"` // "visible", "daylight", "eclipsed"
VisibilityLabel string `json:"visibility_label"`
EstimatedMag float64 `json:"estimated_mag"`
Trajectory []PassPoint `json:"trajectory"`
}
// PredictPassesOptions configures the pass prediction query.
type PredictPassesOptions struct {
Observer Geodetic `json:"observer"`
StartTime time.Time `json:"start_time"`
Days int `json:"days"` // default 10
MinElevationDeg float64 `json:"min_elevation_deg"` // default 10.0
VisibleOnly bool `json:"visible_only"` // default false
StandardMag float64 `json:"standard_mag"`
}
// PredictPasses scans the given time window and returns predicted satellite passes.
func PredictPasses(model *SGP4Model, opts PredictPassesOptions) ([]SatellitePass, error) {
if opts.Days <= 0 || opts.Days > 14 {
opts.Days = 10
}
if opts.MinElevationDeg < 0 {
opts.MinElevationDeg = 10.0
}
if opts.StartTime.IsZero() {
opts.StartTime = time.Now()
}
endTime := opts.StartTime.Add(time.Duration(opts.Days) * 24 * time.Hour)
step := 45 * time.Second // Coarse search step
var passes []SatellitePass
t := opts.StartTime
inPass := false
var passPoints []PassPoint
for t.Before(endTime) {
posECEF, _, err := model.PositionECEF(t)
if err != nil {
// In case of propagation issue, advance
t = t.Add(step)
continue
}
look := CalculateLookAngles(opts.Observer, posECEF)
isAbove := look.Elevation >= opts.MinElevationDeg
if isAbove {
if !inPass {
// Just entered pass: backtrack and find exact AOS (elevation crosses minElevation)
inPass = true
passPoints = nil
aosPoint := refineAOS(model, opts.Observer, t.Add(-step), t, opts.MinElevationDeg)
passPoints = append(passPoints, aosPoint)
}
jd := JulianDate(t)
sunEl := ObserverSunElevation(opts.Observer, t)
sunlit := IsSatelliteIlluminated(posECEF, jd)
passPoints = append(passPoints, PassPoint{
Time: t,
TimeFormatted: t.Format("15:04:05"),
Azimuth: math.Round(look.Azimuth*10) / 10,
AzimuthCardinal: CardinalDirection(look.Azimuth),
Elevation: math.Round(look.Elevation*10) / 10,
RangeKm: math.Round(look.Range),
SunElevation: math.Round(sunEl*10) / 10,
IsSunlit: sunlit,
})
// Inside pass, take finer 15s steps to capture peak elevation accurately
t = t.Add(15 * time.Second)
} else {
if inPass {
// Just exited pass: refine exact LOS (elevation drops below minElevation)
losPoint := refineLOS(model, opts.Observer, t.Add(-15*time.Second), t, opts.MinElevationDeg)
passPoints = append(passPoints, losPoint)
inPass = false
// Analyze the completed pass
if pass, ok := evaluatePass(passPoints, opts); ok {
if !opts.VisibleOnly || pass.Visibility == "visible" {
passes = append(passes, pass)
}
}
passPoints = nil
}
// Normal coarse stepping
t = t.Add(step)
}
}
return passes, nil
}
func evaluatePass(pts []PassPoint, opts PredictPassesOptions) (SatellitePass, bool) {
if len(pts) < 2 {
return SatellitePass{}, false
}
start := pts[0]
end := pts[len(pts)-1]
// Find max elevation point
maxPt := pts[0]
for _, p := range pts {
if p.Elevation > maxPt.Elevation {
maxPt = p
}
}
durationSec := int(end.Time.Sub(start.Time).Seconds())
if durationSec <= 30 {
return SatellitePass{}, false
}
// Classify visibility based on observer sun elevation and satellite illumination
visibility := "daylight"
label := "Daylight"
estimatedMag := 99.0
// Sun elevation < -6° means civil twilight / night for observer
if maxPt.SunElevation < -6.0 {
if maxPt.IsSunlit {
visibility = "visible"
label = "Visible"
// Calculate apparent magnitude at max elevation
jd := JulianDate(maxPt.Time)
satECEF, _, _ := CalculateSatECEF(opts.Observer, maxPt)
obsECEF := GeodeticToECEF(opts.Observer)
estimatedMag = EstimateVisualMagnitude(opts.StandardMag, satECEF, obsECEF, jd)
} else {
visibility = "eclipsed"
label = "In Shadow"
}
}
durMin := durationSec / 60
durSec := durationSec % 60
return SatellitePass{
StartTimeFormatted: start.Time.Local().Format("Jan 02, 15:04:05"),
Start: start,
Max: maxPt,
End: end,
DurationSec: durationSec,
DurationFormatted: fmt.Sprintf("%dm %ds", durMin, durSec),
Visibility: visibility,
VisibilityLabel: label,
EstimatedMag: estimatedMag,
Trajectory: pts,
}, true
}
// CalculateSatECEF reconstitutes sat position from look angles or propagation
func CalculateSatECEF(obs Geodetic, p PassPoint) (Vector3, Vector3, error) {
obsECEF := GeodeticToECEF(obs)
latRad := obs.Latitude * Deg2Rad
lonRad := obs.Longitude * Deg2Rad
azRad := p.Azimuth * Deg2Rad
elRad := p.Elevation * Deg2Rad
sinLat := math.Sin(latRad)
cosLat := math.Cos(latRad)
sinLon := math.Sin(lonRad)
cosLon := math.Cos(lonRad)
// Topocentric ENU
east := p.RangeKm * math.Cos(elRad) * math.Sin(azRad)
north := p.RangeKm * math.Cos(elRad) * math.Cos(azRad)
up := p.RangeKm * math.Sin(elRad)
rx := -sinLon*east - sinLat*cosLon*north + cosLat*cosLon*up
ry := cosLon*east - sinLat*sinLon*north + cosLat*sinLon*up
rz := cosLat*north + sinLat*up
return Vector3{
X: obsECEF.X + rx,
Y: obsECEF.Y + ry,
Z: obsECEF.Z + rz,
}, Vector3{}, nil
}
// refineAOS uses binary search to pinpoint when satellite rises above minEl
func refineAOS(model *SGP4Model, obs Geodetic, tLow, tHigh time.Time, minEl float64) PassPoint {
for i := 0; i < 6; i++ {
tMid := tLow.Add(tHigh.Sub(tLow) / 2)
posECEF, _, err := model.PositionECEF(tMid)
if err != nil {
break
}
look := CalculateLookAngles(obs, posECEF)
if look.Elevation >= minEl {
tHigh = tMid
} else {
tLow = tMid
}
}
posECEF, _, _ := model.PositionECEF(tHigh)
look := CalculateLookAngles(obs, posECEF)
jd := JulianDate(tHigh)
sunEl := ObserverSunElevation(obs, tHigh)
sunlit := IsSatelliteIlluminated(posECEF, jd)
return PassPoint{
Time: tHigh,
TimeFormatted: tHigh.Format("15:04:05"),
Azimuth: math.Round(look.Azimuth*10) / 10,
AzimuthCardinal: CardinalDirection(look.Azimuth),
Elevation: math.Round(look.Elevation*10) / 10,
RangeKm: math.Round(look.Range),
SunElevation: math.Round(sunEl*10) / 10,
IsSunlit: sunlit,
}
}
// refineLOS uses binary search to pinpoint when satellite drops below minEl
func refineLOS(model *SGP4Model, obs Geodetic, tLow, tHigh time.Time, minEl float64) PassPoint {
for i := 0; i < 6; i++ {
tMid := tLow.Add(tHigh.Sub(tLow) / 2)
posECEF, _, err := model.PositionECEF(tMid)
if err != nil {
break
}
look := CalculateLookAngles(obs, posECEF)
if look.Elevation >= minEl {
tLow = tMid
} else {
tHigh = tMid
}
}
posECEF, _, _ := model.PositionECEF(tLow)
look := CalculateLookAngles(obs, posECEF)
jd := JulianDate(tLow)
sunEl := ObserverSunElevation(obs, tLow)
sunlit := IsSatelliteIlluminated(posECEF, jd)
return PassPoint{
Time: tLow,
TimeFormatted: tLow.Format("15:04:05"),
Azimuth: math.Round(look.Azimuth*10) / 10,
AzimuthCardinal: CardinalDirection(look.Azimuth),
Elevation: math.Round(look.Elevation*10) / 10,
RangeKm: math.Round(look.Range),
SunElevation: math.Round(sunEl*10) / 10,
IsSunlit: sunlit,
}
}
-490
View File
@@ -1,490 +0,0 @@
package orbitviewer
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// WGS-72 constants used in standard SGP4/SDP4
const (
sgp4Ck2 = 5.413080e-4 // 0.5 * J2
sgp4Ck4 = 0.62098875e-6 // -0.375 * J4
sgp4E6a = 1.0e-6
sgp4Qo = 120.0 // Parameter for atmospheric model
sgp4So = 78.0 // Parameter for atmospheric model
sgp4Xke = 0.0743669161 // sqrt(GM) in (Earth radii)^1.5 / min
sgp4Xkmper = 6378.135 // Earth equatorial radius in km (WGS-72)
sgp4Xj2 = 1.082616e-3 // J2 harmonic
sgp4Xj3 = -0.253881e-5 // J3 harmonic
sgp4Xj4 = -1.65597e-6 // J4 harmonic
sgp4TwoPi = 2.0 * math.Pi
)
// TLE represents a decoded Two-Line Element set.
type TLE struct {
Name string `json:"name"`
Line1 string `json:"line1"`
Line2 string `json:"line2"`
NoradID int `json:"norad_id"`
Classification string `json:"classification"`
IntlDesignator string `json:"intl_designator"`
EpochYear int `json:"epoch_year"`
EpochDay float64 `json:"epoch_day"`
EpochTime time.Time `json:"epoch_time"`
BStar float64 `json:"bstar"`
InclinationDeg float64 `json:"inclination_deg"`
RaanDeg float64 `json:"raan_deg"`
Eccentricity float64 `json:"eccentricity"`
ArgPerigeeDeg float64 `json:"arg_perigee_deg"`
MeanAnomalyDeg float64 `json:"mean_anomaly_deg"`
MeanMotionRevDay float64 `json:"mean_motion_rev_day"` // revs per day
RevNumber int `json:"rev_number"`
}
// SGP4Model contains initialized constants and precomputed values for orbit propagation.
type SGP4Model struct {
TLE TLE
// Orbital elements at epoch
a0, e0, i0, om0, w0, m0 float64
n0 float64 // rad / min
bstar float64
// Precomputed constants
cosi0, sini0 float64
theta2, theta4 float64
beta0, beta02, beta04 float64
ao, no0 float64
isImpSimple bool
// Secular rate terms
xmdot, omgdot, xnodot float64
c1, c2, c3, c4, c5 float64
d2, d3, d4 float64
t2cof, t3cof, t4cof, t5cof float64
eta float64
omgcof, xmcof float64
delmo, sinmao float64
}
// ParseTLE parses two (or three) lines of standard NORAD TLE text into a TLE struct.
func ParseTLE(name, line1, line2 string) (TLE, error) {
line1 = strings.TrimSpace(line1)
line2 = strings.TrimSpace(line2)
if len(line1) < 68 || len(line2) < 68 {
return TLE{}, errors.New("invalid TLE: line length must be at least 68 characters")
}
if line1[0] != '1' || line2[0] != '2' {
return TLE{}, errors.New("invalid TLE: line 1 must start with '1' and line 2 with '2'")
}
noradID1, err := strconv.Atoi(strings.TrimSpace(line1[2:7]))
if err != nil {
return TLE{}, fmt.Errorf("invalid NORAD ID in line 1: %w", err)
}
noradID2, err := strconv.Atoi(strings.TrimSpace(line2[2:7]))
if err != nil || noradID1 != noradID2 {
return TLE{}, fmt.Errorf("NORAD ID mismatch between line 1 (%d) and line 2 (%d)", noradID1, noradID2)
}
class := string(line1[7])
intlDesig := strings.TrimSpace(line1[9:17])
epochYr2, err := strconv.Atoi(strings.TrimSpace(line1[18:20]))
if err != nil {
return TLE{}, fmt.Errorf("invalid epoch year: %w", err)
}
var fullYear int
if epochYr2 < 57 {
fullYear = 2000 + epochYr2
} else {
fullYear = 1900 + epochYr2
}
epochDay, err := strconv.ParseFloat(strings.TrimSpace(line1[20:32]), 64)
if err != nil {
return TLE{}, fmt.Errorf("invalid epoch day: %w", err)
}
// Calculate epoch timestamp
startOfYear := time.Date(fullYear, 1, 1, 0, 0, 0, 0, time.UTC)
epochDuration := time.Duration(epochDay * float64(24*time.Hour))
epochTime := startOfYear.Add(epochDuration - 24*time.Hour)
// BSTAR drag term: e.g. " 12345-4" -> 0.12345e-4
bstarStr := strings.TrimSpace(line1[53:61])
bstar, err := parseExponential(bstarStr)
if err != nil {
bstar = 0.0
}
// Line 2 elements
inc, err := strconv.ParseFloat(strings.TrimSpace(line2[8:16]), 64)
if err != nil {
return TLE{}, fmt.Errorf("invalid inclination: %w", err)
}
raan, err := strconv.ParseFloat(strings.TrimSpace(line2[17:25]), 64)
if err != nil {
return TLE{}, fmt.Errorf("invalid RAAN: %w", err)
}
eccStr := "0." + strings.TrimSpace(line2[26:33])
ecc, err := strconv.ParseFloat(eccStr, 64)
if err != nil {
return TLE{}, fmt.Errorf("invalid eccentricity: %w", err)
}
argPerigee, err := strconv.ParseFloat(strings.TrimSpace(line2[34:42]), 64)
if err != nil {
return TLE{}, fmt.Errorf("invalid arg of perigee: %w", err)
}
meanAnomaly, err := strconv.ParseFloat(strings.TrimSpace(line2[43:51]), 64)
if err != nil {
return TLE{}, fmt.Errorf("invalid mean anomaly: %w", err)
}
meanMotion, err := strconv.ParseFloat(strings.TrimSpace(line2[52:63]), 64)
if err != nil {
return TLE{}, fmt.Errorf("invalid mean motion: %w", err)
}
revNum, _ := strconv.Atoi(strings.TrimSpace(line2[63:68]))
if name == "" {
name = fmt.Sprintf("SAT-%d", noradID1)
}
return TLE{
Name: name,
Line1: line1,
Line2: line2,
NoradID: noradID1,
Classification: class,
IntlDesignator: intlDesig,
EpochYear: fullYear,
EpochDay: epochDay,
EpochTime: epochTime,
BStar: bstar,
InclinationDeg: inc,
RaanDeg: raan,
Eccentricity: ecc,
ArgPerigeeDeg: argPerigee,
MeanAnomalyDeg: meanAnomaly,
MeanMotionRevDay: meanMotion,
RevNumber: revNum,
}, nil
}
func parseExponential(s string) (float64, error) {
s = strings.TrimSpace(s)
if s == "" || s == "00000-0" || s == "00000+0" {
return 0, nil
}
sign := 1.0
switch s[0] {
case '-':
sign = -1.0
s = s[1:]
case '+':
s = s[1:]
}
// Format is typically 5 digits followed by sign and exponent: e.g. "12345-4"
n := len(s)
if n >= 2 && (s[n-2] == '-' || s[n-2] == '+') {
mantissaStr := "0." + s[:n-2]
expStr := s[n-2:]
m, err := strconv.ParseFloat(mantissaStr, 64)
if err != nil {
return 0, err
}
e, err := strconv.Atoi(expStr)
if err != nil {
return 0, err
}
return sign * m * math.Pow10(e), nil
}
return strconv.ParseFloat(s, 64)
}
// NewSGP4Model initializes the SGP4 propagation model from a TLE.
func NewSGP4Model(tle TLE) (*SGP4Model, error) {
m := &SGP4Model{
TLE: tle,
i0: tle.InclinationDeg * Deg2Rad,
om0: tle.RaanDeg * Deg2Rad,
e0: tle.Eccentricity,
w0: tle.ArgPerigeeDeg * Deg2Rad,
m0: tle.MeanAnomalyDeg * Deg2Rad,
n0: tle.MeanMotionRevDay * sgp4TwoPi / 1440.0, // rad/min
bstar: tle.BStar,
}
if m.e0 < 0 || m.e0 >= 0.999 {
return nil, fmt.Errorf("eccentricity %.4f out of range for SGP4", m.e0)
}
// Constants
m.cosi0 = math.Cos(m.i0)
m.sini0 = math.Sin(m.i0)
m.theta2 = m.cosi0 * m.cosi0
m.theta4 = m.theta2 * m.theta2
m.beta02 = 1.0 - m.e0*m.e0
m.beta0 = math.Sqrt(m.beta02)
m.beta04 = m.beta02 * m.beta02
// Recover original mean motion (no0) and semi-major axis (ao)
a1 := math.Pow(sgp4Xke/m.n0, 2.0/3.0)
del1 := 1.5 * sgp4Ck2 * (3.0*m.theta2 - 1.0) / (m.beta0 * m.beta02 * a1 * a1)
ao := a1 * (1.0 - del1*(1.0/3.0+del1*(1.0+del1*134.0/81.0)))
delo := 1.5 * sgp4Ck2 * (3.0*m.theta2 - 1.0) / (m.beta0 * m.beta02 * ao * ao)
m.no0 = m.n0 / (1.0 + delo)
m.ao = ao / (1.0 - delo)
m.a0 = m.ao
// Perigee altitude
perigeeKm := (m.ao*(1.0-m.e0) - 1.0) * sgp4Xkmper
m.isImpSimple = false
if perigeeKm < 220.0 {
m.isImpSimple = true
}
s4 := sgp4So/sgp4Xkmper + 1.0
qoms24 := math.Pow((sgp4Qo-sgp4So)/sgp4Xkmper, 4.0)
if perigeeKm < 156.0 {
s4 = perigeeKm - 78.0
if perigeeKm <= 98.0 {
s4 = 20.0
}
qoms24 = math.Pow((sgp4Qo-s4)/sgp4Xkmper, 4.0)
s4 = s4/sgp4Xkmper + 1.0
}
pinv := 1.0 / (m.ao * m.ao * m.beta04)
tsi := 1.0 / (m.ao - s4)
m.eta = m.ao * m.e0 * tsi
etasq := m.eta * m.eta
eeta := m.e0 * m.eta
psisq := math.Abs(1.0 - etasq)
coef := qoms24 * math.Pow(tsi, 4.0)
coef1 := coef / math.Pow(psisq, 3.5)
m.c2 = coef1 * m.no0 * (m.ao*(1.0+1.5*etasq+eeta*(4.0+etasq)) +
0.75*sgp4Ck2*tsi/psisq*(8.0+3.0*etasq*(8.0+etasq))*(3.0*m.theta2-1.0))
m.c1 = m.bstar * m.c2
m.c3 = coef * tsi * (-2.0 * sgp4Xj3 / sgp4Ck2) * m.no0 * m.sini0 / m.e0
if m.e0 < 1e-4 {
m.c3 = 0.0
}
m.c4 = 2.0 * m.no0 * coef1 * m.ao * m.beta02 * (m.eta*(2.0+0.5*etasq) +
m.e0*(0.5+2.0*etasq) -
2.0*sgp4Ck2*tsi/(m.ao*psisq)*(3.0*(1.0-3.0*m.theta2)*(1.0-2.0*eeta+etasq*(1.5-0.5*eeta))+
0.75*(1.0-m.theta2)*(2.0*etasq-eeta*(1.0+etasq))*math.Cos(2.0*m.w0)))
m.c5 = 2.0 * coef1 * m.ao * m.beta02 * (1.0 + 2.75*(etasq+eeta) + eeta*etasq)
// Secular rates
temp1 := 3.0 * sgp4Ck2 * pinv * m.no0
temp2 := temp1 * sgp4Ck2 * pinv
temp3 := 1.25 * sgp4Ck4 * pinv * pinv * m.no0
m.xmdot = m.no0 + 0.5*temp1*m.beta0*(3.0*m.theta2-1.0) +
0.0625*temp2*m.beta0*(13.0-78.0*m.theta2+137.0*m.theta4)
m.omgdot = -0.5*temp1*(1.0-5.0*m.theta2) +
0.0625*temp2*(7.0-114.0*m.theta2+395.0*m.theta4) +
temp3*(3.0-36.0*m.theta2+49.0*m.theta4)
m.xnodot = -temp1*m.cosi0 +
(0.5*temp2*(4.0-19.0*m.theta2)+2.0*temp3*(3.0-7.0*m.theta2))*m.cosi0
if !m.isImpSimple {
c1sq := m.c1 * m.c1
m.d2 = 4.0 * m.ao * tsi * c1sq
temp := m.d2 * tsi * m.c1 / 3.0
m.d3 = (17.0*m.ao + s4) * temp
m.d4 = 0.5 * temp * m.ao * tsi * (221.0*m.ao + 31.0*s4) * m.c1
m.t3cof = m.d2 + 2.0*c1sq
m.t4cof = 0.25 * (3.0*m.d3 + m.c1*(12.0*m.d2+10.0*c1sq))
m.t5cof = 0.2 * (3.0*m.d4 + 12.0*m.c1*m.d3 + 6.0*m.d2*m.d2 + 15.0*c1sq*(2.0*m.d2+c1sq))
m.omgcof = m.bstar * m.c3 * math.Cos(m.w0)
m.xmcof = 0.0
if m.e0 > 1e-4 && eeta > 0 {
m.xmcof = -2.0 / 3.0 * coef * m.bstar / eeta
}
m.delmo = math.Pow(1.0+m.eta*math.Cos(m.m0), 3.0)
m.sinmao = math.Sin(m.m0)
}
return m, nil
}
// PropagateMinutes calculates position (km) and velocity (km/s) in TEME at tsince minutes from epoch.
func (m *SGP4Model) PropagateMinutes(tsince float64) (Vector3, Vector3, error) {
// Update for secular gravity and atmospheric drag
xmdf := m.m0 + m.xmdot*tsince
omgadf := m.w0 + m.omgdot*tsince
xnoddf := m.om0 + m.xnodot*tsince
omega := omgadf
xmp := xmdf
tsq := tsince * tsince
xnode := xnoddf + 0.5*m.c1*tsq
tempa := 1.0 - m.c1*tsince
tempe := m.bstar * m.c4 * tsince
templ := m.t2cof * tsq
if !m.isImpSimple {
delomg := m.omgcof * tsince
delm := m.xmcof * (math.Pow(1.0+m.eta*math.Cos(xmdf), 3) - m.delmo)
temp := delomg + delm
xmp = xmdf + temp
omega = omgadf - temp
tcube := tsq * tsince
tfour := tsq * tsq
tempa = tempa - m.d2*tsq - m.d3*tcube - m.d4*tfour
tempe = tempe + m.bstar*m.c5*(math.Sin(xmp)-m.sinmao)
templ = templ + m.t3cof*tcube + tfour*(m.t4cof+tsince*m.t5cof)
}
a := m.ao * tempa * tempa
e := m.e0 - tempe
xl := xmp + omega + xnode + m.no0*templ
beta := math.Sqrt(1.0 - e*e)
n := sgp4Xke / math.Pow(a, 1.5)
// Long period periodic terms
axn := e * math.Cos(omega)
temp := 1.0 / (a * beta * beta)
xll := temp * 0.5 * sgp4Ck2 * 1.5 * (3.0*m.theta2 - 1.0)
ayn := e*math.Sin(omega) + temp*(-0.5*sgp4Xj3/sgp4Ck2*m.sini0)
xlt := xl - temp*0.25*(-0.5*sgp4Xj3/sgp4Ck2*m.sini0)*axn*(3.0+5.0*m.cosi0)/(1.0+m.cosi0)
// Solve Kepler's Equation for (U = E + w)
u := math.Mod(xlt-xnode, sgp4TwoPi)
if u < 0 {
u += sgp4TwoPi
}
eo1 := u
tem5 := 1.0
for iter := 0; iter < 10 && math.Abs(tem5) >= sgp4E6a; iter++ {
sineo1 := math.Sin(eo1)
coseo1 := math.Cos(eo1)
tem5 = 1.0 - coseo1*axn - sineo1*ayn
tem5 = (u - ayn*coseo1 + axn*sineo1 - eo1) / tem5
if math.Abs(tem5) > 1.0 {
if tem5 > 0 {
tem5 = 1.0
} else {
tem5 = -1.0
}
}
eo1 += tem5
}
// Short period preliminary quantities
sineo1 := math.Sin(eo1)
coseo1 := math.Cos(eo1)
ecose := axn*coseo1 + ayn*sineo1
esine := axn*sineo1 - ayn*coseo1
el2 := axn*axn + ayn*ayn
pl := a * (1.0 - el2)
if pl < 0 {
return Vector3{}, Vector3{}, errors.New("satellite decayed into Earth")
}
r := a * (1.0 - ecose)
rdot := sgp4Xke * math.Sqrt(a) * esine / r
rfdot := sgp4Xke * math.Sqrt(pl) / r
betal := math.Sqrt(1.0 - el2)
temp = esine / (1.0 + betal)
sinu := a / r * (sineo1 - ayn - axn*temp)
cosu := a / r * (coseo1 - axn + ayn*temp)
su := math.Atan2(sinu, cosu)
sin2u := 2.0 * sinu * cosu
cos2u := 2.0*cosu*cosu - 1.0
temp = 1.0 / pl
temp1 := sgp4Ck2 * temp
temp2 := temp1 * temp
// Update for short periodics
rk := r*(1.0-1.5*temp2*betal*(3.0*m.theta2-1.0)) + 0.5*temp1*(1.0-m.theta2)*cos2u
uk := su - 0.25*temp2*(7.0*m.theta2-1.0)*sin2u
xnodek := xnode + 1.5*temp2*m.cosi0*sin2u
xinck := m.i0 + 1.5*temp2*m.cosi0*m.sini0*cos2u
rdotk := rdot - n*temp1*(1.0-m.theta2)*sin2u
rfdotk := rfdot + n*temp1*((1.0-m.theta2)*cos2u+1.5*(3.0*m.theta2-1.0))
// Orientation vectors in TEME
sinuk := math.Sin(uk)
cosuk := math.Cos(uk)
sinik := math.Sin(xinck)
cosik := math.Cos(xinck)
sinnok := math.Sin(xnodek)
cosnok := math.Cos(xnodek)
mx := -sinnok * cosik
my := cosnok * cosik
ux := mx*sinuk + cosnok*cosuk
uy := my*sinuk + sinnok*cosuk
uz := sinik * sinuk
vx := mx*cosuk - cosnok*sinuk
vy := my*cosuk - sinnok*sinuk
vz := sinik * cosuk
// Position and velocity in km and km/s
pos := Vector3{
X: rk * ux * sgp4Xkmper,
Y: rk * uy * sgp4Xkmper,
Z: rk * uz * sgp4Xkmper,
}
vel := Vector3{
X: (rdotk*ux + rfdotk*vx) * sgp4Xkmper / 60.0,
Y: (rdotk*uy + rfdotk*vy) * sgp4Xkmper / 60.0,
Z: (rdotk*uz + rfdotk*vz) * sgp4Xkmper / 60.0,
}
_ = xll
return pos, vel, nil
}
// Additional helper field values
var (
_ = sgp4Xj4
)
// PropagateTime calculates position (km) and velocity (km/s) in TEME at an absolute UTC time.
func (m *SGP4Model) PropagateTime(t time.Time) (Vector3, Vector3, error) {
tsince := t.Sub(m.TLE.EpochTime).Minutes()
return m.PropagateMinutes(tsince)
}
// PositionECEF calculates ECEF coordinates at time t.
func (m *SGP4Model) PositionECEF(t time.Time) (Vector3, Vector3, error) {
posTEME, velTEME, err := m.PropagateTime(t)
if err != nil {
return Vector3{}, Vector3{}, err
}
jd := JulianDate(t)
posECEF := TEMEtoECEF(posTEME, jd)
// Approximate velocity in ECEF
velECEF := TEMEtoECEF(velTEME, jd)
return posECEF, velECEF, nil
}
// GeodeticPosition calculates WGS-84 Geodetic position (lat, lon, alt) at time t.
func (m *SGP4Model) GeodeticPosition(t time.Time) (Geodetic, Vector3, error) {
posECEF, velECEF, err := m.PositionECEF(t)
if err != nil {
return Geodetic{}, Vector3{}, err
}
geo := ECEFtoGeodetic(posECEF)
return geo, velECEF, nil
}
-199
View File
@@ -1,199 +0,0 @@
package orbitviewer
import (
"math"
"testing"
"time"
)
// Standard ISS TLE sample
const (
testISSName = "ISS (ZARYA)"
testISSLine1 = "1 25544U 98067A 24080.56944444 .00014524 00000-0 26388-3 0 9993"
testISSLine2 = "2 25544 51.6416 195.4568 0004928 120.1234 239.9876 15.49876543445566"
)
func TestParseTLE(t *testing.T) {
tle, err := ParseTLE(testISSName, testISSLine1, testISSLine2)
if err != nil {
t.Fatalf("ParseTLE failed: %v", err)
}
if tle.NoradID != 25544 {
t.Errorf("Expected NoradID 25544, got %d", tle.NoradID)
}
if math.Abs(tle.InclinationDeg-51.6416) > 1e-4 {
t.Errorf("Expected Inclination 51.6416, got %f", tle.InclinationDeg)
}
if math.Abs(tle.Eccentricity-0.0004928) > 1e-6 {
t.Errorf("Expected Eccentricity 0.0004928, got %f", tle.Eccentricity)
}
}
func TestSGP4Propagation(t *testing.T) {
tle, err := ParseTLE(testISSName, testISSLine1, testISSLine2)
if err != nil {
t.Fatalf("ParseTLE failed: %v", err)
}
model, err := NewSGP4Model(tle)
if err != nil {
t.Fatalf("NewSGP4Model failed: %v", err)
}
// Propagate at epoch (tsince = 0)
pos, vel, err := model.PropagateMinutes(0)
if err != nil {
t.Fatalf("PropagateMinutes(0) failed: %v", err)
}
rKm := math.Sqrt(pos.X*pos.X + pos.Y*pos.Y + pos.Z*pos.Z)
// ISS orbit radius should be around 6700 - 6850 km (altitude 350 - 450 km)
if rKm < 6650 || rKm > 6900 {
t.Errorf("Unrealistic orbital radius: %f km", rKm)
}
vKmS := math.Sqrt(vel.X*vel.X + vel.Y*vel.Y + vel.Z*vel.Z)
// Orbital velocity for LEO should be around 7.5 - 7.8 km/s
if vKmS < 7.2 || vKmS > 8.0 {
t.Errorf("Unrealistic orbital velocity: %f km/s", vKmS)
}
// Propagate 90 minutes (about 1 orbit)
geo, _, err := model.GeodeticPosition(tle.EpochTime.Add(90 * time.Minute))
if err != nil {
t.Fatalf("GeodeticPosition failed: %v", err)
}
if geo.Altitude < 350 || geo.Altitude > 460 {
t.Errorf("Unrealistic altitude after 90 min: %f km", geo.Altitude)
}
if geo.Latitude < -55 || geo.Latitude > 55 {
t.Errorf("Latitude %f exceeds inclination range +/-52 deg by too much", geo.Latitude)
}
}
func TestLookAnglesAndSun(t *testing.T) {
// Observer in San Francisco
obsSF := Geodetic{
Latitude: 37.7749,
Longitude: -122.4194,
Altitude: 0.03, // 30 meters
}
// Target directly above observer at 400km altitude
targetECEF := GeodeticToECEF(Geodetic{
Latitude: 37.7749,
Longitude: -122.4194,
Altitude: 400.0,
})
angles := CalculateLookAngles(obsSF, targetECEF)
if math.Abs(angles.Elevation-90.0) > 0.1 {
t.Errorf("Expected elevation ~90 deg for overhead satellite, got %f", angles.Elevation)
}
if math.Abs(angles.Range-400.0) > 1.0 {
t.Errorf("Expected range ~400 km, got %f", angles.Range)
}
// Test Sun elevation at noon vs midnight
noonUTC := time.Date(2024, 6, 21, 20, 0, 0, 0, time.UTC) // roughly solar noon in SF (12:00 PDT)
elNoon := ObserverSunElevation(obsSF, noonUTC)
if elNoon < 60.0 {
t.Errorf("Expected high solar elevation at summer solstice noon, got %f", elNoon)
}
midnightUTC := time.Date(2024, 6, 21, 8, 0, 0, 0, time.UTC) // midnight in SF
elMidnight := ObserverSunElevation(obsSF, midnightUTC)
if elMidnight > -15.0 {
t.Errorf("Expected negative solar elevation at midnight, got %f", elMidnight)
}
}
func TestPredictPasses(t *testing.T) {
tle, err := ParseTLE(testISSName, testISSLine1, testISSLine2)
if err != nil {
t.Fatalf("ParseTLE failed: %v", err)
}
model, err := NewSGP4Model(tle)
if err != nil {
t.Fatalf("NewSGP4Model failed: %v", err)
}
obs := Geodetic{
Latitude: 40.7128, // New York
Longitude: -74.0060,
Altitude: 0.01,
}
// Predict passes for 5 days starting at epoch
passes, err := PredictPasses(model, PredictPassesOptions{
Observer: obs,
StartTime: tle.EpochTime,
Days: 5,
MinElevationDeg: 10.0,
VisibleOnly: false,
StandardMag: -1.8,
})
if err != nil {
t.Fatalf("PredictPasses failed: %v", err)
}
if len(passes) == 0 {
t.Errorf("Expected at least a few passes over 5 days for ISS over NYC, got 0")
}
t.Logf("Found %d passes over 5 days", len(passes))
for i, p := range passes {
t.Logf("Pass #%d: Start=%s, MaxEl=%.1f deg (%s), End=%s, Dur=%s, Vis=%s, Mag=%.1f",
i+1, p.Start.TimeFormatted, p.Max.Elevation, p.Max.AzimuthCardinal,
p.End.TimeFormatted, p.DurationFormatted, p.Visibility, p.EstimatedMag)
}
}
func TestTiangongNYC(t *testing.T) {
entry, ok := DefaultCatalog.Get("tiangong")
if !ok {
t.Fatalf("Tiangong not in default catalog")
}
model, err := NewSGP4Model(entry.TLE)
if err != nil {
t.Fatalf("NewSGP4Model failed: %v", err)
}
obsNYC := Geodetic{
Latitude: 40.7128,
Longitude: -74.0060,
Altitude: 0.01,
}
// 1. All passes
allPasses, err := PredictPasses(model, PredictPassesOptions{
Observer: obsNYC,
StartTime: time.Now().UTC(),
Days: 10,
MinElevationDeg: 10.0,
VisibleOnly: false,
StandardMag: -1.0,
})
if err != nil {
t.Fatalf("PredictPasses failed: %v", err)
}
t.Logf("Tiangong over NYC - ALL passes: %d", len(allPasses))
for i, p := range allPasses {
t.Logf("All pass #%d: Start=%s, MaxEl=%.1f, Vis=%s, Mag=%.1f", i+1, p.Start.Time.Format(time.RFC3339), p.Max.Elevation, p.Visibility, p.EstimatedMag)
}
// 2. Visible passes only
visPasses, _ := PredictPasses(model, PredictPassesOptions{
Observer: obsNYC,
StartTime: time.Now().UTC(),
Days: 10,
MinElevationDeg: 10.0,
VisibleOnly: true,
StandardMag: -1.0,
})
t.Logf("Tiangong over NYC - VISIBLE ONLY passes: %d", len(visPasses))
}
-120
View File
@@ -1,120 +0,0 @@
package orbitviewer
import (
"math"
"time"
)
// SunPositionECEF returns the position vector of the Sun in ECEF coordinates (km) for a given Julian Date.
func SunPositionECEF(jd float64) Vector3 {
d := jd - 2451545.0 // days from J2000.0
// Mean anomaly of the Sun
g := (357.529 + 0.98560028*d) * Deg2Rad
// Mean longitude of the Sun
q := 280.459 + 0.98564736*d
// Geocentric apparent ecliptic longitude
l := (q + 1.915*math.Sin(g) + 0.020*math.Sin(2*g)) * Deg2Rad
// Obliquity of ecliptic
e := (23.439 - 0.00000036*d) * Deg2Rad
// Distance to Sun in AU
rAU := 1.00014 - 0.01671*math.Cos(g) - 0.00014*math.Cos(2*g)
rKm := rAU * 149597870.7 // km
// Sun position in Equatorial coordinates (ECI)
sinL := math.Sin(l)
cosL := math.Cos(l)
cosE := math.Cos(e)
sinE := math.Sin(e)
sunECI := Vector3{
X: rKm * cosL,
Y: rKm * cosE * sinL,
Z: rKm * sinE * sinL,
}
// Rotate ECI to ECEF by GMST
return TEMEtoECEF(sunECI, jd)
}
// ObserverSunElevation calculates the Sun's elevation angle (in degrees) for an observer location at time t.
func ObserverSunElevation(obs Geodetic, t time.Time) float64 {
jd := JulianDate(t)
sunECEF := SunPositionECEF(jd)
angles := CalculateLookAngles(obs, sunECEF)
return angles.Elevation
}
// IsSatelliteIlluminated determines if a satellite at satECEF is illuminated by the Sun (not eclipsed by Earth).
// Uses a cylindrical Earth shadow model with EarthRadiusKm.
func IsSatelliteIlluminated(satECEF Vector3, jd float64) bool {
sunECEF := SunPositionECEF(jd)
// Vector from Earth center to Sun (unit vector)
sunDist := math.Sqrt(sunECEF.X*sunECEF.X + sunECEF.Y*sunECEF.Y + sunECEF.Z*sunECEF.Z)
ux := sunECEF.X / sunDist
uy := sunECEF.Y / sunDist
uz := sunECEF.Z / sunDist
// Projection of satellite vector along Sun direction
dot := satECEF.X*ux + satECEF.Y*uy + satECEF.Z*uz
// If satellite is on day side of Earth (dot >= 0), it is illuminated
if dot >= 0 {
return true
}
// Satellite is on night side of Earth (dot < 0).
// Distance squared from shadow axis:
satDistSq := satECEF.X*satECEF.X + satECEF.Y*satECEF.Y + satECEF.Z*satECEF.Z
distFromAxisSq := satDistSq - dot*dot
// If distance from Earth shadow axis is greater than Earth radius, satellite is in sunlight
return distFromAxisSq > (EarthRadiusKm * EarthRadiusKm)
}
// EstimateVisualMagnitude estimates apparent visual magnitude for a satellite.
// standardMag: intrinsic magnitude at 1000 km distance and 50% illumination (e.g. -1.3 for ISS, ~5.0 for typical cube/small sat).
func EstimateVisualMagnitude(standardMag float64, satECEF, obsECEF Vector3, jd float64) float64 {
sunECEF := SunPositionECEF(jd)
// Satellite to Observer vector
dx := obsECEF.X - satECEF.X
dy := obsECEF.Y - satECEF.Y
dz := obsECEF.Z - satECEF.Z
rangeKm := math.Sqrt(dx*dx + dy*dy + dz*dz)
if rangeKm < 1.0 {
rangeKm = 1.0
}
// Satellite to Sun vector
sx := sunECEF.X - satECEF.X
sy := sunECEF.Y - satECEF.Y
sz := sunECEF.Z - satECEF.Z
sunDist := math.Sqrt(sx*sx + sy*sy + sz*sz)
// Cosine of phase angle (angle between Sun and Observer as seen from Satellite)
cosPhase := (dx*sx + dy*sy + dz*sz) / (rangeKm * sunDist)
if cosPhase > 1.0 {
cosPhase = 1.0
} else if cosPhase < -1.0 {
cosPhase = -1.0
}
phaseAngle := math.Acos(cosPhase)
// Phase function (diffuse sphere approximation):
// phi(beta) = (sin(beta) + (pi - beta)*cos(beta)) / pi
sinP := math.Sin(phaseAngle)
cosP := math.Cos(phaseAngle)
phi := (sinP + (math.Pi-phaseAngle)*cosP) / math.Pi
if phi <= 0.001 {
phi = 0.001
}
// Apparent magnitude:
// m = M_std - 15.75 + 5 * log10(range) - 2.5 * log10(phi)
// (Normalized so standardMag is roughly apparent mag at 1000km range and optimal phase)
mag := standardMag + 5.0*math.Log10(rangeKm/1000.0) - 2.5*math.Log10(phi)
return math.Round(mag*10.0) / 10.0
}
-37
View File
@@ -1,37 +0,0 @@
package orbitviewer
import (
"toolbox/pkg/base"
"github.com/gin-gonic/gin"
)
type OrbitViewerTool struct{}
func (t *OrbitViewerTool) ID() string {
return "orbit-viewer"
}
func (t *OrbitViewerTool) Name() string {
return "卫星轨道观测与AR定位"
}
func (t *OrbitViewerTool) Description() string {
return "实时卫星轨迹模拟、未来10天最佳观测窗口预测及移动端AR实景指天找星工具。"
}
func (t *OrbitViewerTool) Emoji() string {
return "🛰️"
}
func (t *OrbitViewerTool) Init() error {
return nil
}
func (t *OrbitViewerTool) RegisterRoutes(r *gin.RouterGroup) {
RegisterRoutes(r)
}
func init() {
base.Register(&OrbitViewerTool{})
}
+81 -237
View File
@@ -23,9 +23,7 @@ func RegisterFontSFNT(id string, f *sfnt.Font) { sfntMap[id] = f }
// HasGlyph 检查字体是否包含某个字符 // HasGlyph 检查字体是否包含某个字符
func HasGlyph(fontId string, char rune) bool { func HasGlyph(fontId string, char rune) bool {
f, ok := sfntMap[fontId] f, ok := sfntMap[fontId]
if !ok { if !ok { return false }
return false
}
var buffer sfnt.Buffer var buffer sfnt.Buffer
idx, err := f.GlyphIndex(&buffer, char) idx, err := f.GlyphIndex(&buffer, char)
return err == nil && idx != 0 return err == nil && idx != 0
@@ -35,14 +33,10 @@ func HasGlyph(fontId string, char rune) bool {
func GeneratePDFExtended(chars []HanziData, mode, paperSize, fontId string) ([]byte, error) { func GeneratePDFExtended(chars []HanziData, mode, paperSize, fontId string) ([]byte, error) {
pdf := &gopdf.GoPdf{} pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89} rect := gopdf.Rect{W: 595.28, H: 841.89}
if paperSize == "Letter" { if paperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
rect = gopdf.Rect{W: 612, H: 792}
}
pdf.Start(gopdf.Config{PageSize: rect}) pdf.Start(gopdf.Config{PageSize: rect})
if len(embeddedFont) > 0 { if len(embeddedFont) > 0 { _ = pdf.AddTTFFontData("font", embeddedFont) }
_ = pdf.AddTTFFontData("font", embeddedFont)
}
// 注册所有可用字体 // 注册所有可用字体
for id, path := range fontMap { for id, path := range fontMap {
@@ -64,17 +58,13 @@ func GeneratePDFExtended(chars []HanziData, mode, paperSize, fontId string) ([]b
} }
func addTeachingPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) { func addTeachingPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
cols := 2 cols := 2; gap, margin := 17.0, 40.0
gap, margin := 17.0, 40.0
size := math.Min((pW-2*margin-gap)/2, (pH-2*margin-2*gap)/3) size := math.Min((pW-2*margin-gap)/2, (pH-2*margin-2*gap)/3)
totalW, totalH := 2*size+gap, 3*size+2*gap totalW, totalH := 2*size+gap, 3*size+2*gap
marginX, marginY := (pW-totalW)/2, (pH-totalH)/2 marginX, marginY := (pW-totalW)/2, (pH-totalH)/2
for i := 0; i < len(chars); i += 6 { for i := 0; i < len(chars); i += 6 {
pdf.AddPage() pdf.AddPage()
end := i + 6 end := i + 6; if end > len(chars) { end = len(chars) }
if end > len(chars) {
end = len(chars)
}
for idx, data := range chars[i:end] { for idx, data := range chars[i:end] {
drawCharacter(pdf, data, marginX+float64(idx%cols)*(size+gap), marginY+float64(idx/cols)*(size+gap), size, flipY, len(data.Strokes), true) drawCharacter(pdf, data, marginX+float64(idx%cols)*(size+gap), marginY+float64(idx/cols)*(size+gap), size, flipY, len(data.Strokes), true)
} }
@@ -82,73 +72,43 @@ func addTeachingPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH fl
} }
func addStepPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) { func addStepPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
cols, margin := 9, 30.0 cols, margin := 9, 30.0; size := (pW - 2*margin) / float64(cols)
size := (pW - 2*margin) / float64(cols) pdf.AddPage(); currY := margin
pdf.AddPage()
currY := margin
for _, data := range chars { for _, data := range chars {
numS := len(data.Strokes) numS := len(data.Strokes); rowsN := 1; if numS > 8 { rowsN = 1 + (numS - 8 + 7) / 8 }
rowsN := 1 if currY + float64(rowsN)*size > pH - margin { pdf.AddPage(); currY = margin }
if numS > 8 { totalG := rowsN * cols; strokeC := 0
rowsN = 1 + (numS-8+7)/8
}
if currY+float64(rowsN)*size > pH-margin {
pdf.AddPage()
currY = margin
}
totalG := rowsN * cols
strokeC := 0
for gIdx := 0; gIdx < totalG; gIdx++ { for gIdx := 0; gIdx < totalG; gIdx++ {
r, c := gIdx/cols, gIdx%cols r, c := gIdx/cols, gIdx%cols; x, y := margin+float64(c)*size, currY+float64(r)*size
x, y := margin+float64(c)*size, currY+float64(r)*size if r == 0 && c == 0 { drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), false); strokeC = 1
if r == 0 && c == 0 { } else if r > 0 && c == 0 { drawMiZiGe(pdf, x, y, size)
drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), false) } else if strokeC <= numS { drawStepBox(pdf, data, x, y, size, flipY, strokeC); strokeC++
strokeC = 1 } else { drawMiZiGe(pdf, x, y, size) }
} else if r > 0 && c == 0 {
drawMiZiGe(pdf, x, y, size)
} else if strokeC <= numS {
drawStepBox(pdf, data, x, y, size, flipY, strokeC)
strokeC++
} else {
drawMiZiGe(pdf, x, y, size)
}
} }
currY += float64(rowsN) * size currY += float64(rowsN) * size
} }
} }
func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, targetFont string) { func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, targetFont string) {
margin := 40.0 margin := 40.0; cols, rows := 10, 15
cols, rows := 10, 15
colW, rowH := (pW-2*margin)/float64(cols), (pH-2*margin)/float64(rows) colW, rowH := (pW-2*margin)/float64(cols), (pH-2*margin)/float64(rows)
fontSize := colW * 0.75 fontSize := colW * 0.75
pdf.AddPage() pdf.AddPage(); drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
cIdx, rIdx := 0, 0 cIdx, rIdx := 0, 0
for _, data := range chars { for _, data := range chars {
if data.Character == "\n" { if data.Character == "\n" {
cIdx++ cIdx++; rIdx = 0
rIdx = 0 if cIdx >= cols { pdf.AddPage(); cIdx = 0; drawManuscriptGrid(pdf, pW, pH, margin, cols, colW) }
if cIdx >= cols {
pdf.AddPage()
cIdx = 0
drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
}
continue continue
} }
if rIdx >= rows { if rIdx >= rows {
cIdx++ cIdx++; rIdx = 0
rIdx = 0 if cIdx >= cols { pdf.AddPage(); cIdx = 0; drawManuscriptGrid(pdf, pW, pH, margin, cols, colW) }
if cIdx >= cols {
pdf.AddPage()
cIdx = 0
drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
}
} }
x, y := pW-margin-float64(cIdx+1)*colW, margin+float64(rIdx)*rowH x, y := pW - margin - float64(cIdx+1)*colW, margin + float64(rIdx)*rowH
charRune := []rune(data.Character)[0] charRune := []rune(data.Character)[0]
// 智能字体选择 // 智能字体选择
@@ -158,15 +118,12 @@ func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, tar
if !HasGlyph(targetFont, charRune) { if !HasGlyph(targetFont, charRune) {
// 尝试降级到字库最全的宋体或楷体 // 尝试降级到字库最全的宋体或楷体
if HasGlyph("songti", charRune) { if HasGlyph("songti", charRune) {
renderFont = "songti" renderFont = "songti"; isFallback = true
isFallback = true
} else if HasGlyph("kaiti", charRune) { } else if HasGlyph("kaiti", charRune) {
renderFont = "kaiti" renderFont = "kaiti"; isFallback = true
isFallback = true
} else { } else {
// 全部缺失,保留空白 // 全部缺失,保留空白
rIdx++ rIdx++; continue
continue
} }
} }
@@ -176,12 +133,12 @@ func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, tar
smallSize := fontSize * 0.5 smallSize := fontSize * 0.5
_ = pdf.SetFont(renderFont, "", smallSize) _ = pdf.SetFont(renderFont, "", smallSize)
// 计算右上角位置:X靠右,Y靠上 // 计算右上角位置:X靠右,Y靠上
pdf.SetXY(x+colW-smallSize-2, y+2) pdf.SetXY(x + colW - smallSize - 2, y + 2)
_ = pdf.Cell(nil, data.Character) _ = pdf.Cell(nil, data.Character)
} else { } else {
// 正常显示 // 正常显示
_ = pdf.SetFont(renderFont, "", fontSize) _ = pdf.SetFont(renderFont, "", fontSize)
pdf.SetXY(x+(colW-fontSize*0.9)/2, y+(rowH-fontSize)/2) pdf.SetXY(x + (colW-fontSize*0.9)/2, y + (rowH-fontSize)/2)
_ = pdf.Cell(nil, data.Character) _ = pdf.Cell(nil, data.Character)
} }
rIdx++ rIdx++
@@ -189,221 +146,108 @@ func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, tar
} }
func drawManuscriptGrid(pdf *gopdf.GoPdf, pW, pH, margin float64, cols int, colW float64) { func drawManuscriptGrid(pdf *gopdf.GoPdf, pW, pH, margin float64, cols int, colW float64) {
pdf.SetStrokeColor(200, 0, 0) pdf.SetStrokeColor(200, 0, 0); pdf.SetLineWidth(0.6)
pdf.SetLineWidth(0.6) for c := 0; c <= cols; c++ { x := pW - margin - float64(c)*colW; pdf.Line(x, margin, x, pH-margin) }
for c := 0; c <= cols; c++ { pdf.Line(margin, margin, pW-margin, margin); pdf.Line(margin, pH-margin, pW-margin, pH-margin)
x := pW - margin - float64(c)*colW
pdf.Line(x, margin, x, pH-margin)
}
pdf.Line(margin, margin, pW-margin, margin)
pdf.Line(margin, pH-margin, pW-margin, pH-margin)
} }
func drawCharacter(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, strokeLimit int, showAnnotations bool) { func drawCharacter(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, strokeLimit int, showAnnotations bool) {
if showAnnotations { if showAnnotations { drawGrid(pdf, x, y, size) } else { drawMiZiGe(pdf, x, y, size) }
drawGrid(pdf, x, y, size) p, drawS := size*0.12, size-(size*0.12*2); scale := drawS/1024.0
} else {
drawMiZiGe(pdf, x, y, size)
}
p, drawS := size*0.12, size-(size*0.12*2)
scale := drawS / 1024.0
if strokeLimit == len(data.Strokes) { if strokeLimit == len(data.Strokes) {
if showAnnotations { if showAnnotations { pdf.SetFillColor(240, 240, 240) } else { pdf.SetFillColor(0, 0, 0) }
pdf.SetFillColor(240, 240, 240) } else { pdf.SetFillColor(180, 180, 180) }
} else {
pdf.SetFillColor(0, 0, 0)
}
} else {
pdf.SetFillColor(180, 180, 180)
}
for sIdx := 0; sIdx < len(data.Strokes); sIdx++ { for sIdx := 0; sIdx < len(data.Strokes); sIdx++ {
if sIdx >= strokeLimit && strokeLimit != len(data.Strokes) { if sIdx >= strokeLimit && strokeLimit != len(data.Strokes) { break }
break
}
pts := parseSVGPath(data.Strokes[sIdx], scale, x+p, y+p, flipY) pts := parseSVGPath(data.Strokes[sIdx], scale, x+p, y+p, flipY)
if len(pts) > 2 { if len(pts) > 2 { pdf.Polygon(pts, "F") }
pdf.Polygon(pts, "F")
}
} }
if showAnnotations && strokeLimit == len(data.Strokes) { if showAnnotations && strokeLimit == len(data.Strokes) {
fS := size * 0.035 fS := size * 0.035
_ = pdf.SetFont("font", "", fS) _ = pdf.SetFont("font", "", fS)
for idx, median := range data.Medians { for idx, median := range data.Medians {
mP := transformPoints(median, scale, x+p, y+p, flipY) mP := transformPoints(median, scale, x+p, y+p, flipY); if len(mP) < 2 { continue }
if len(mP) < 2 { pdf.SetStrokeColor(255, 0, 0); pdf.SetLineWidth(0.6)
continue for j := 0; j < len(mP)-1; j++ { pdf.Line(mP[j].X, mP[j].Y, mP[j+1].X, mP[j+1].Y) }
} pdf.SetFillColor(255, 0, 0); drawArrow(pdf, mP[len(mP)-1], mP[len(mP)-2], size*0.035)
pdf.SetStrokeColor(255, 0, 0) r := size*0.025; dx, dy := mP[1].X-mP[0].X, mP[1].Y-mP[0].Y; dist := math.Sqrt(dx*dx+dy*dy); ux, uy := -1.0, -1.0
pdf.SetLineWidth(0.6) if dist > 0.001 { ux, uy = dx/dist, dy/dist }; cX, cY := mP[0].X-ux*r, mP[0].Y-uy*r
for j := 0; j < len(mP)-1; j++ { pdf.SetStrokeColor(255, 0, 0); pdf.SetLineWidth(0.5); pdf.Oval(cX-r, cY-r, cX+r, cY+r)
pdf.Line(mP[j].X, mP[j].Y, mP[j+1].X, mP[j+1].Y) numS := strconv.Itoa(idx+1); tw := fS*0.6*float64(len(numS))/2; if len(numS) > 1 { tw = fS*0.5 }
} pdf.SetFillColor(255, 0, 0); pdf.SetXY(cX-tw/2, cY-fS/2); _ = pdf.Cell(nil, numS)
pdf.SetFillColor(255, 0, 0)
drawArrow(pdf, mP[len(mP)-1], mP[len(mP)-2], size*0.035)
r := size * 0.025
dx, dy := mP[1].X-mP[0].X, mP[1].Y-mP[0].Y
dist := math.Sqrt(dx*dx + dy*dy)
ux, uy := -1.0, -1.0
if dist > 0.001 {
ux, uy = dx/dist, dy/dist
}
cX, cY := mP[0].X-ux*r, mP[0].Y-uy*r
pdf.SetStrokeColor(255, 0, 0)
pdf.SetLineWidth(0.5)
pdf.Oval(cX-r, cY-r, cX+r, cY+r)
numS := strconv.Itoa(idx + 1)
tw := fS * 0.6 * float64(len(numS)) / 2
if len(numS) > 1 {
tw = fS * 0.5
}
pdf.SetFillColor(255, 0, 0)
pdf.SetXY(cX-tw/2, cY-fS/2)
_ = pdf.Cell(nil, numS)
} }
} }
} }
func drawStepBox(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, limit int) { func drawStepBox(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, limit int) {
drawMiZiGe(pdf, x, y, size) drawMiZiGe(pdf, x, y, size); p, scale := size*0.12, (size-(size*0.12*2))/1024.0
p, scale := size*0.12, (size-(size*0.12*2))/1024.0 pdf.SetFillColor(180, 180, 180); for i := 0; i < limit; i++ {
pdf.SetFillColor(180, 180, 180) pts := parseSVGPath(data.Strokes[i], scale, x+p, y+p, flipY); if len(pts) > 2 { pdf.Polygon(pts, "F") }
for i := 0; i < limit; i++ {
pts := parseSVGPath(data.Strokes[i], scale, x+p, y+p, flipY)
if len(pts) > 2 {
pdf.Polygon(pts, "F")
}
} }
} }
func drawGrid(pdf *gopdf.GoPdf, x, y, size float64) { func drawGrid(pdf *gopdf.GoPdf, x, y, size float64) {
pdf.SetStrokeColor(220, 220, 220) pdf.SetStrokeColor(220, 220, 220); pdf.SetLineWidth(0.4); pdf.RectFromUpperLeft(x, y, size, size)
pdf.SetLineWidth(0.4) mid := size/2; drawDashedLine(pdf, x, y+mid, x+size, y+mid); drawDashedLine(pdf, x+mid, y, x+mid, y+size)
pdf.RectFromUpperLeft(x, y, size, size)
mid := size / 2
drawDashedLine(pdf, x, y+mid, x+size, y+mid)
drawDashedLine(pdf, x+mid, y, x+mid, y+size)
} }
func drawMiZiGe(pdf *gopdf.GoPdf, x, y, size float64) { func drawMiZiGe(pdf *gopdf.GoPdf, x, y, size float64) {
pdf.SetStrokeColor(220, 220, 220) pdf.SetStrokeColor(220, 220, 220); pdf.SetLineWidth(0.4); pdf.RectFromUpperLeft(x, y, size, size)
pdf.SetLineWidth(0.4) mid := size/2; drawDashedLine(pdf, x, y+mid, x+size, y+mid); drawDashedLine(pdf, x+mid, y, x+mid, y+size); drawDashedLine(pdf, x, y, x+size, y+size); drawDashedLine(pdf, x, y+size, x+size, y)
pdf.RectFromUpperLeft(x, y, size, size)
mid := size / 2
drawDashedLine(pdf, x, y+mid, x+size, y+mid)
drawDashedLine(pdf, x+mid, y, x+mid, y+size)
drawDashedLine(pdf, x, y, x+size, y+size)
drawDashedLine(pdf, x, y+size, x+size, y)
} }
func drawDashedLine(pdf *gopdf.GoPdf, x1, y1, x2, y2 float64) { func drawDashedLine(pdf *gopdf.GoPdf, x1, y1, x2, y2 float64) {
dash := 2.0 dash := 2.0; dx, dy := x2-x1, y2-y1; dist := math.Sqrt(dx*dx + dy*dy); if dist < 0.001 { return }
dx, dy := x2-x1, y2-y1 ux, uy := dx/dist, dy/dist; for i := 0.0; i < dist; i += dash * 2 { end := i + dash; if end > dist { end = dist }; pdf.Line(x1+ux*i, y1+uy*i, x1+ux*end, y1+uy*end) }
dist := math.Sqrt(dx*dx + dy*dy)
if dist < 0.001 {
return
}
ux, uy := dx/dist, dy/dist
for i := 0.0; i < dist; i += dash * 2 {
end := i + dash
if end > dist {
end = dist
}
pdf.Line(x1+ux*i, y1+uy*i, x1+ux*end, y1+uy*end)
}
} }
func drawArrow(pdf *gopdf.GoPdf, target, prev gopdf.Point, length float64) { func drawArrow(pdf *gopdf.GoPdf, target, prev gopdf.Point, length float64) {
angle := math.Atan2(target.Y-prev.Y, target.X-prev.X) angle := math.Atan2(target.Y-prev.Y, target.X-prev.X); arrowA := math.Pi/6
arrowA := math.Pi / 6 p1X := target.X+length*math.Cos(angle+math.Pi+arrowA); p1Y := target.Y+length*math.Sin(angle+math.Pi+arrowA)
p1X := target.X + length*math.Cos(angle+math.Pi+arrowA) p2X := target.X+length*math.Cos(angle+math.Pi-arrowA); p2Y := target.Y+length*math.Sin(angle+math.Pi-arrowA)
p1Y := target.Y + length*math.Sin(angle+math.Pi+arrowA)
p2X := target.X + length*math.Cos(angle+math.Pi-arrowA)
p2Y := target.Y + length*math.Sin(angle+math.Pi-arrowA)
pdf.Polygon([]gopdf.Point{target, {X: p1X, Y: p1Y}, {X: p2X, Y: p2Y}}, "F") pdf.Polygon([]gopdf.Point{target, {X: p1X, Y: p1Y}, {X: p2X, Y: p2Y}}, "F")
} }
func transformPoints(pts []Point, scale, ox, oy float64, flipY bool) []gopdf.Point { func transformPoints(pts []Point, scale, ox, oy float64, flipY bool) []gopdf.Point {
res := make([]gopdf.Point, len(pts)) res := make([]gopdf.Point, len(pts)); for i, p := range pts { y := p[1]; if flipY { y = 1024-y }; res[i] = gopdf.Point{X: ox+p[0]*scale, Y: oy+y*scale} }
for i, p := range pts {
y := p[1]
if flipY {
y = 1024 - y
}
res[i] = gopdf.Point{X: ox + p[0]*scale, Y: oy + y*scale}
}
return res return res
} }
var reSVG = regexp.MustCompile(`([MLQCZ])|(-?\d+\.?\d*)`) var reSVG = regexp.MustCompile(`([MLQCZ])|(-?\d+\.?\d*)`)
func parseSVGPath(path string, scale, ox, oy float64, flipY bool) []gopdf.Point { func parseSVGPath(path string, scale, ox, oy float64, flipY bool) []gopdf.Point {
var pts []gopdf.Point var pts []gopdf.Point; matches := reSVG.FindAllStringSubmatch(path, -1); var lastX, lastY float64
matches := reSVG.FindAllStringSubmatch(path, -1)
var lastX, lastY float64
for idx := 0; idx < len(matches); { for idx := 0; idx < len(matches); {
item := matches[idx][0] item := matches[idx][0]
switch item { if item == "M" || item == "L" {
case "M", "L":
if idx+2 < len(matches) { if idx+2 < len(matches) {
x, _ := strconv.ParseFloat(matches[idx+1][0], 64) x, _ := strconv.ParseFloat(matches[idx+1][0], 64); y, _ := strconv.ParseFloat(matches[idx+2][0], 64); lastX, lastY = x, y
y, _ := strconv.ParseFloat(matches[idx+2][0], 64) if flipY { y = 1024 - y }; pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale}); idx += 3
lastX, lastY = x, y } else { idx++ }
if flipY { } else if item == "C" {
y = 1024 - y
}
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
idx += 3
} else {
idx++
}
case "C":
if idx+6 < len(matches) { if idx+6 < len(matches) {
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64) x1, _ := strconv.ParseFloat(matches[idx+1][0], 64); y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64) x2, _ := strconv.ParseFloat(matches[idx+3][0], 64); y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64) x, _ := strconv.ParseFloat(matches[idx+5][0], 64); y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
x, _ := strconv.ParseFloat(matches[idx+5][0], 64)
y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
for t := 0.2; t <= 1.0; t += 0.2 { for t := 0.2; t <= 1.0; t += 0.2 {
invT := 1 - t tx := math.Pow(1-t, 3)*lastX + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
tx := invT*invT*invT*lastX + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x ty := math.Pow(1-t, 3)*lastY + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
ty := invT*invT*invT*lastY + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y ty_f := ty; if flipY { ty_f = 1024 - ty }; pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
ty_f := ty
if flipY {
ty_f = 1024 - ty
} }
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale}) lastX, lastY = x, y; idx += 7
} } else { idx++ }
lastX, lastY = x, y } else if item == "Q" {
idx += 7
} else {
idx++
}
case "Q":
if idx+4 < len(matches) { if idx+4 < len(matches) {
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64) x1, _ := strconv.ParseFloat(matches[idx+1][0], 64); y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64) x, _ := strconv.ParseFloat(matches[idx+3][0], 64); y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
x, _ := strconv.ParseFloat(matches[idx+3][0], 64)
y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
for t := 0.2; t <= 1.0; t += 0.2 { for t := 0.2; t <= 1.0; t += 0.2 {
invT := 1 - t tx := math.Pow(1-t, 2)*lastX + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
tx := invT*invT*lastX + 2*invT*t*x1 + t*t*x ty := math.Pow(1-t, 2)*lastY + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
ty := invT*invT*lastY + 2*invT*t*y1 + t*t*y ty_f := ty; if flipY { ty_f = 1024 - ty }; pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
ty_f := ty
if flipY {
ty_f = 1024 - ty
} }
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale}) lastX, lastY = x, y; idx += 5
} else { idx++ }
} else { idx++ }
} }
lastX, lastY = x, y
idx += 5
} else {
idx++
}
default:
idx++
}
}
return pts return pts
} }
+7 -25
View File
@@ -30,10 +30,7 @@ func init() {
func (t *zitieTool) ID() string { return "zitie" } func (t *zitieTool) ID() string { return "zitie" }
func (t *zitieTool) Name() string { return "汉字字帖生成" } func (t *zitieTool) Name() string { return "汉字字帖生成" }
func (t *zitieTool) Description() string { func (t *zitieTool) Description() string { return "提供智能缺字处理和古风排版的专业字帖工具" }
return "提供智能缺字处理和古风排版的专业字帖工具"
}
func (t *zitieTool) Emoji() string { return "🎨" }
func (t *zitieTool) Init() error { func (t *zitieTool) Init() error {
fmt.Println("Initializing Zitie tool with font check...") fmt.Println("Initializing Zitie tool with font check...")
@@ -54,9 +51,7 @@ func (t *zitieTool) Init() error {
for id, src := range fonts { for id, src := range fonts {
bytes, err := fontFS.ReadFile(src) bytes, err := fontFS.ReadFile(src)
if err != nil { if err != nil { continue }
continue
}
// 1. 同步到磁盘用于 gopdf // 1. 同步到磁盘用于 gopdf
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("own_tools_%s.ttf", id)) tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("own_tools_%s.ttf", id))
@@ -112,9 +107,7 @@ func (t *zitieTool) handleManuscript(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if req.FontType == "" { if req.FontType == "" { req.FontType = "kaiti" }
req.FontType = "kaiti"
}
data := t.filterChars(req.Chars, true) data := t.filterChars(req.Chars, true)
t.generateAndResponse(c, data, "manuscript", req.PaperSize, req.FontType) t.generateAndResponse(c, data, "manuscript", req.PaperSize, req.FontType)
} }
@@ -127,25 +120,14 @@ func (t *zitieTool) filterChars(input string, keepNewline bool) []logic.HanziDat
for _, r := range input { for _, r := range input {
charStr := string(r) charStr := string(r)
if r == '\n' { if r == '\n' {
if keepNewline { if keepNewline { res = append(res, logic.HanziData{Character: "\n"}) }
res = append(res, logic.HanziData{Character: "\n"})
}
continue
}
if r == ' ' || r == '\r' || r == '\t' {
continue continue
} }
if r == ' ' || r == '\r' || r == '\t' { continue }
isPunc := false isPunc := false
for _, p := range puncs { for _, p := range puncs { if r == p { isPunc = true; break } }
if r == p { if isPunc { continue }
isPunc = true
break
}
}
if isPunc {
continue
}
if hd, ok := t.allChars[charStr]; ok { if hd, ok := t.allChars[charStr]; ok {
hd.Character = charStr hd.Character = charStr