Compare commits
1 Commits
main
..
f474b5a51e
| Author | SHA1 | Date | |
|---|---|---|---|
| f474b5a51e |
@@ -1,5 +1,4 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
build/
|
|
||||||
/toolbox
|
/toolbox
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
|
|||||||
+8
-60
@@ -4,16 +4,13 @@ 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/zitie" // 匿名导入以触发 init()
|
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
|
||||||
|
|
||||||
@@ -58,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")
|
||||||
@@ -84,36 +64,11 @@ 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世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测"
|
|
||||||
}
|
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"Title": title,
|
"Title": "Own-Tools",
|
||||||
"Description": desc,
|
"GA_ID": gaID,
|
||||||
"Keywords": keywords,
|
|
||||||
"CanonicalURL": canonical,
|
|
||||||
"GA_ID": gaID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
// 因为已经 Sub 了,模板名就是文件名
|
// 因为已经 Sub 了,模板名就是文件名
|
||||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||||
@@ -122,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)
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
testPort = "9999"
|
|
||||||
baseURL = "http://localhost:" + testPort
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
+69
-38
@@ -1,49 +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" .}}
|
|
||||||
|
|
||||||
<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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -131,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);
|
||||||
});
|
});
|
||||||
@@ -144,46 +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世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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'));
|
||||||
|
|
||||||
// 显示目标面板
|
// 显示目标面板
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -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>
|
</div>
|
||||||
<option value="shapes">基础几何图形</option>
|
<div style="flex: 1; min-width: 250px;">
|
||||||
</select>
|
<label style="font-size: 13px; font-weight: 600; color: #86868b; display: block; margin-bottom: 12px;">总图案数量 (种类数 - 30)</label>
|
||||||
|
<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 style="width: 240px;">
|
||||||
|
<label>纸张大小</label>
|
||||||
|
<select id="math_paper_size">
|
||||||
|
<option value="A4">A4</option>
|
||||||
|
<option value="Letter">Letter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<button onclick="generateMathPDF()">生成数学练习帖</button>
|
||||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">种类 (Max 6)</label>
|
|
||||||
<input type="number" id="icon_types" min="1" max="6" value="3" class="apple-input">
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label style="font-size: 13px; font-weight: 600; color: #86868b; margin-bottom: 8px; display: block;">总量 (Max 30)</label>
|
|
||||||
<input type="number" id="total_count" min="1" max="30" 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;">页数 (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>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button onclick="generateMathPDF()" id="btn-math-counting">生成练习帖</button>
|
|
||||||
</div>
|
|
||||||
<div id="icon-preview-container" style="margin-top: 24px; padding: 20px; background: #fbfbfd; border-radius: 12px; border: 1px solid #e5e5e7;">
|
|
||||||
<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}}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-278
@@ -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})
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
// 硬性限制:页数 1-10 页,防止资源耗尽
|
drawProblem(pdf, req, 0, rect.W, rect.H/2)
|
||||||
if req.PageCount < 1 {
|
drawProblem(pdf, req, rect.H/2, rect.W, rect.H/2)
|
||||||
req.PageCount = 1
|
|
||||||
}
|
|
||||||
if req.PageCount > 10 {
|
|
||||||
req.PageCount = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < req.PageCount; i++ {
|
|
||||||
pdf.AddPage()
|
|
||||||
drawProblemV4(pdf, req, 0, rect.W, rect.H/2)
|
|
||||||
drawProblemV4(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) }
|
||||||
}
|
|
||||||
|
total := req.TotalCount; if total < numTypes { total = numTypes }
|
||||||
|
if total > 30 { total = 30 }
|
||||||
|
|
||||||
numTypes := req.IconTypes
|
// 随机选出图标种类
|
||||||
if numTypes < 1 {
|
allIcons := rand.Perm(len(data.CountingIcons))
|
||||||
numTypes = 1
|
selectedIcons := []data.Icon{}
|
||||||
}
|
counts := []int{}
|
||||||
if numTypes > 6 {
|
|
||||||
numTypes = 6
|
// 先每种分配1个
|
||||||
}
|
rem := total - numTypes
|
||||||
if numTypes > len(catIcons) {
|
|
||||||
numTypes = len(catIcons)
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.TotalCount
|
|
||||||
if total < numTypes {
|
|
||||||
total = numTypes
|
|
||||||
}
|
|
||||||
if total > 30 {
|
|
||||||
total = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
allIconsPerm := rand.Perm(len(catIcons))
|
|
||||||
var selectedIcons []data.Icon
|
|
||||||
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
|
||||||
|
|
||||||
var placed []PlacedIcon
|
iconTypeIdx := 0
|
||||||
iconTypeIdx, currentInType := 0, 0
|
countInCurrentType := 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)
|
baseX := x + float64(cIdx)*cellW + paddingX
|
||||||
collision := false
|
baseY := y + float64(rIdx)*cellH + paddingY
|
||||||
for _, p := range placed {
|
|
||||||
dx := randX - p.X
|
// 渲染图标
|
||||||
dy := randY - p.Y
|
drawIcon(pdf, selectedIcons[iconTypeIdx], baseX + iconSize/2, baseY + iconSize/2, iconSize)
|
||||||
if math.Sqrt(dx*dx+dy*dy) < (r + p.Radius + 10.0) {
|
|
||||||
collision = true
|
countInCurrentType++
|
||||||
break
|
if countInCurrentType >= counts[iconTypeIdx] {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !collision {
|
|
||||||
drawSimpleIcon(pdf, selectedIcons[iconTypeIdx], randX, randY, r*2)
|
|
||||||
placed = append(placed, PlacedIcon{X: randX, Y: randY, Radius: r})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentInType++
|
|
||||||
if currentInType >= 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
|
|
||||||
}
|
ox, oy := cX - size/2, cY - size/2
|
||||||
var minX, minY, maxX, maxY = 1e9, 1e9, -1e9, -1e9
|
for _, pStr := range icon.Paths {
|
||||||
for _, res := range rawResults {
|
pts := parseSmoothPath(pStr, scale, ox, oy)
|
||||||
for _, p := range res.Points {
|
if len(pts) > 1 {
|
||||||
if p.X < minX {
|
// 如果是闭合路径
|
||||||
minX = p.X
|
if strings.Contains(strings.ToUpper(pStr), "Z") {
|
||||||
}
|
pdf.Polygon(pts, "D")
|
||||||
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)
|
|
||||||
if strings.Contains(strings.ToLower(icon.Name), "item") || len(icon.Paths) > 1 {
|
|
||||||
pdf.SetLineWidth(size / 60.0) // 复杂素材用细线 (30% less than 45 is approx 60)
|
|
||||||
} else {
|
|
||||||
pdf.SetLineWidth(size / 22.0) // 基础图形用粗线
|
|
||||||
}
|
|
||||||
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 {
|
} else {
|
||||||
for j := 0; j < len(scaledPts)-1; j++ {
|
for i := 0; i < len(pts)-1; i++ {
|
||||||
pdf.Line(scaledPts[j].X, scaledPts[j].Y, scaledPts[j+1].X, scaledPts[j+1].Y)
|
pdf.Line(pts[i].X, pts[i].Y, pts[i+1].X, pts[i+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 {
|
var pts []gopdf.Point
|
||||||
tokens := reSVGToken.FindAllString(path, -1)
|
matches := reSVG.FindAllStringSubmatch(path, -1)
|
||||||
var lx, ly, startX, startY float64
|
var lx, ly float64
|
||||||
var pts []gopdf.Point
|
for i := 0; i < len(matches); {
|
||||||
var currentCmd string
|
cmd := matches[i][0]
|
||||||
var isRel bool
|
if cmd == "M" || cmd == "L" {
|
||||||
for i := 0; i < len(tokens); {
|
x, _ := strconv.ParseFloat(matches[i+1][0], 64); y, _ := strconv.ParseFloat(matches[i+2][0], 64)
|
||||||
token := tokens[i]
|
lx, ly = x, y
|
||||||
if (token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z') {
|
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
|
||||||
cmd := strings.ToUpper(token)
|
i += 3
|
||||||
if cmd == "M" {
|
} else if cmd == "C" {
|
||||||
if len(pts) > 0 {
|
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
|
||||||
results = append(results, PathResult{Points: pts, Closed: false})
|
x2, _ := strconv.ParseFloat(matches[i+3][0], 64); y2, _ := strconv.ParseFloat(matches[i+4][0], 64)
|
||||||
pts = nil
|
x, _ := strconv.ParseFloat(matches[i+5][0], 64); y, _ := strconv.ParseFloat(matches[i+6][0], 64)
|
||||||
}
|
for t := 0.25; t <= 1.0; t += 0.25 {
|
||||||
} else if cmd == "Z" {
|
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
|
||||||
if len(pts) > 0 {
|
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
|
||||||
results = append(results, PathResult{Points: pts, Closed: true})
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
||||||
pts = nil
|
|
||||||
}
|
|
||||||
lx, ly = startX, startY
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentCmd = cmd
|
|
||||||
isRel = (token[0] >= 'a' && token[0] <= 'z')
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
switch currentCmd {
|
lx, ly = x, y; i += 7
|
||||||
case "M", "L":
|
} else if cmd == "Q" {
|
||||||
if i+1 >= len(tokens) {
|
x1, _ := strconv.ParseFloat(matches[i+1][0], 64); y1, _ := strconv.ParseFloat(matches[i+2][0], 64)
|
||||||
i = len(tokens)
|
x, _ := strconv.ParseFloat(matches[i+3][0], 64); y, _ := strconv.ParseFloat(matches[i+4][0], 64)
|
||||||
break
|
for t := 0.25; t <= 1.0; t += 0.25 {
|
||||||
}
|
tx := math.Pow(1-t, 2)*lx + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
|
||||||
x, _ := strconv.ParseFloat(tokens[i], 64)
|
ty := math.Pow(1-t, 2)*ly + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
|
||||||
y, _ := strconv.ParseFloat(tokens[i+1], 64)
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
||||||
if isRel {
|
|
||||||
x += lx
|
|
||||||
y += ly
|
|
||||||
}
|
|
||||||
if currentCmd == "M" {
|
|
||||||
startX, startY = x, y
|
|
||||||
}
|
|
||||||
lx, ly = x, y
|
|
||||||
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
|
|
||||||
i += 2
|
|
||||||
if currentCmd == "M" {
|
|
||||||
currentCmd = "L"
|
|
||||||
}
|
|
||||||
case "H":
|
|
||||||
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 {
|
|
||||||
invT := 1 - t
|
|
||||||
tx := invT*invT*lx + 2*invT*t*x1 + t*t*x
|
|
||||||
ty := invT*invT*ly + 2*invT*t*y1 + t*t*y
|
|
||||||
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
|
||||||
}
|
|
||||||
|
|
||||||
lx, ly = x, y
|
|
||||||
i += 4
|
|
||||||
case "A":
|
|
||||||
i += 7
|
|
||||||
default:
|
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
}
|
lx, ly = x, y; i += 5
|
||||||
if len(pts) > 0 {
|
} else { i++ }
|
||||||
results = append(results, PathResult{Points: pts, Closed: false})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return results
|
return pts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
+9
-30
@@ -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"
|
||||||
@@ -16,42 +15,17 @@ func init() {
|
|||||||
base.Register(&learnNumberTool{})
|
base.Register(&learnNumberTool{})
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-250
@@ -16,16 +16,14 @@ var (
|
|||||||
sfntMap = make(map[string]*sfnt.Font)
|
sfntMap = make(map[string]*sfnt.Font)
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetFontData(data []byte) { embeddedFont = data }
|
func SetFontData(data []byte) { embeddedFont = data }
|
||||||
func RegisterFontPath(id, path string) { fontMap[id] = path }
|
func RegisterFontPath(id, path string) { fontMap[id] = path }
|
||||||
func RegisterFontSFNT(id string, f *sfnt.Font) { sfntMap[id] = f }
|
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,91 +72,58 @@ 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]
|
||||||
|
|
||||||
// 智能字体选择
|
// 智能字体选择
|
||||||
renderFont := targetFont
|
renderFont := targetFont
|
||||||
isFallback := false
|
isFallback := false
|
||||||
|
|
||||||
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)
|
for idx := 0; idx < len(matches); {
|
||||||
var lastX, lastY float64
|
item := matches[idx][0]
|
||||||
for idx := 0; idx < len(matches); {
|
if item == "M" || item == "L" {
|
||||||
item := matches[idx][0]
|
if idx+2 < len(matches) {
|
||||||
switch item {
|
x, _ := strconv.ParseFloat(matches[idx+1][0], 64); y, _ := strconv.ParseFloat(matches[idx+2][0], 64); lastX, lastY = x, y
|
||||||
case "M", "L":
|
if flipY { y = 1024 - y }; pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale}); idx += 3
|
||||||
if idx+2 < len(matches) {
|
} else { idx++ }
|
||||||
x, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
} else if item == "C" {
|
||||||
y, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
if idx+6 < len(matches) {
|
||||||
lastX, lastY = x, y
|
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64); y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||||
if flipY {
|
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64); y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
||||||
y = 1024 - y
|
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 {
|
||||||
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
|
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
|
||||||
idx += 3
|
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
|
||||||
} else {
|
ty_f := ty; if flipY { ty_f = 1024 - ty }; pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
|
||||||
idx++
|
|
||||||
}
|
|
||||||
case "C":
|
|
||||||
if idx+6 < len(matches) {
|
|
||||||
x1, _ := strconv.ParseFloat(matches[idx+1][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)
|
|
||||||
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 {
|
|
||||||
invT := 1 - t
|
|
||||||
tx := invT*invT*invT*lastX + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
lastX, lastY = x, y
|
|
||||||
idx += 7
|
|
||||||
} else {
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
case "Q":
|
|
||||||
if idx+4 < len(matches) {
|
|
||||||
x1, _ := strconv.ParseFloat(matches[idx+1][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)
|
|
||||||
for t := 0.2; t <= 1.0; t += 0.2 {
|
|
||||||
invT := 1 - t
|
|
||||||
tx := invT*invT*lastX + 2*invT*t*x1 + t*t*x
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
lastX, lastY = x, y
|
|
||||||
idx += 5
|
|
||||||
} else {
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
idx++
|
|
||||||
}
|
}
|
||||||
}
|
lastX, lastY = x, y; idx += 7
|
||||||
|
} else { idx++ }
|
||||||
|
} else if item == "Q" {
|
||||||
|
if idx+4 < len(matches) {
|
||||||
|
x1, _ := strconv.ParseFloat(matches[idx+1][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)
|
||||||
|
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||||
|
tx := math.Pow(1-t, 2)*lastX + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
|
||||||
|
ty := math.Pow(1-t, 2)*lastY + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
|
||||||
|
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++ }
|
||||||
|
}
|
||||||
return pts
|
return pts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package logic
|
package logic
|
||||||
|
|
||||||
type HanziData struct {
|
type HanziData struct {
|
||||||
Character string `json:"character"`
|
Character string `json:"character"`
|
||||||
Strokes []string `json:"strokes"`
|
Strokes []string `json:"strokes"`
|
||||||
Medians [][]Point `json:"medians"`
|
Medians [][]Point `json:"medians"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Point [2]float64
|
type Point [2]float64
|
||||||
|
|||||||
+19
-37
@@ -28,12 +28,9 @@ func init() {
|
|||||||
base.Register(&zitieTool{})
|
base.Register(&zitieTool{})
|
||||||
}
|
}
|
||||||
|
|
||||||
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...")
|
||||||
@@ -41,23 +38,21 @@ func (t *zitieTool) Init() error {
|
|||||||
if err := json.Unmarshal(allDataContent, &t.allChars); err != nil {
|
if err := json.Unmarshal(allDataContent, &t.allChars); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal all.json: %v", err)
|
return fmt.Errorf("failed to unmarshal all.json: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fontBytes, _ := fontFS.ReadFile("data/font.ttf")
|
fontBytes, _ := fontFS.ReadFile("data/font.ttf")
|
||||||
logic.SetFontData(fontBytes)
|
logic.SetFontData(fontBytes)
|
||||||
|
|
||||||
fonts := map[string]string{
|
fonts := map[string]string{
|
||||||
"kaiti": "data/kaiti.ttf",
|
"kaiti": "data/kaiti.ttf",
|
||||||
"lishu": "data/lishu.ttf",
|
"lishu": "data/lishu.ttf",
|
||||||
"xingshu": "data/xingshu.ttf",
|
"xingshu": "data/xingshu.ttf",
|
||||||
"songti": "data/songti.ttf",
|
"songti": "data/songti.ttf",
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
||||||
_ = os.WriteFile(tmpPath, bytes, 0644)
|
_ = os.WriteFile(tmpPath, bytes, 0644)
|
||||||
@@ -70,7 +65,7 @@ func (t *zitieTool) Init() error {
|
|||||||
fmt.Printf("Font [%s] analyzed and registered\n", id)
|
fmt.Printf("Font [%s] analyzed and registered\n", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +78,7 @@ func (t *zitieTool) RegisterRoutes(r *gin.RouterGroup) {
|
|||||||
type ZitieRequest struct {
|
type ZitieRequest struct {
|
||||||
Chars string `json:"chars" binding:"required"`
|
Chars string `json:"chars" binding:"required"`
|
||||||
PaperSize string `json:"paper_size"`
|
PaperSize string `json:"paper_size"`
|
||||||
FontType string `json:"font_type"`
|
FontType string `json:"font_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *zitieTool) handleTeaching(c *gin.Context) {
|
func (t *zitieTool) handleTeaching(c *gin.Context) {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -123,29 +116,18 @@ func (t *zitieTool) filterChars(input string, keepNewline bool) []logic.HanziDat
|
|||||||
var res []logic.HanziData
|
var res []logic.HanziData
|
||||||
// 扩充标点符号列表
|
// 扩充标点符号列表
|
||||||
puncs := ",。!?;:、“”()《》〈〉…·.?!,:;\"'()<> 「」【】『』〔〕"
|
puncs := ",。!?;:、“”()《》〈〉…·.?!,:;\"'()<> 「」【】『』〔〕"
|
||||||
|
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
if r == ' ' || r == '\r' || r == '\t' {
|
if r == ' ' || r == '\r' || r == '\t' { continue }
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user