Files
digital-psychology/apps/api/internal/star/natal/natal.go
T
jackyu66gitandCursor bd22d9dddd feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:37:53 +08:00

255 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package natal computes tropical whole-sign natal charts via Swiss Ephemeris.
package natal
import (
"fmt"
"math"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris"
)
// Body is a chart point.
type Body struct {
Key string `json:"key"`
Title string `json:"title"`
Lon float64 `json:"lon"`
SignKey string `json:"sign_key"`
Sign string `json:"sign"`
Degree float64 `json:"degree"` // 030 within sign
House int `json:"house"`
Element string `json:"element"`
Modality string `json:"modality"`
}
// House is a whole-sign house.
type House struct {
Num int `json:"num"`
Sign string `json:"sign"`
Key string `json:"sign_key"`
}
// Chart is a natal chart snapshot.
type Chart struct {
Sun, Moon, Rise Body
Planets []Body
Houses []House
Lat, Lng float64
PlaceLabel string
HasTime bool
HasPlace bool
Note string
// Instant is the UTC moment used for ephemeris (noon default when time missing).
Instant time.Time `json:"-"`
}
type signMeta struct {
Key, Label, Element, Modality string
}
var signs = []signMeta{
{"aries", "白羊", "火", "开创"},
{"taurus", "金牛", "土", "固定"},
{"gemini", "双子", "风", "变动"},
{"cancer", "巨蟹", "水", "开创"},
{"leo", "狮子", "火", "固定"},
{"virgo", "处女", "土", "变动"},
{"libra", "天秤", "风", "开创"},
{"scorpio", "天蝎", "水", "固定"},
{"sagittarius", "射手", "火", "变动"},
{"capricorn", "摩羯", "土", "开创"},
{"aquarius", "水瓶", "风", "固定"},
{"pisces", "双鱼", "水", "变动"},
}
var bodyTitles = map[string]string{
"sun": "太阳", "moon": "月亮", "rise": "上升",
"mercury": "水星", "venus": "金星", "mars": "火星",
"jupiter": "木星", "saturn": "土星", "uranus": "天王星",
"neptune": "海王星", "pluto": "冥王星",
}
// Compute builds a natal chart. birthTime is HH:MM local; place is city name.
func Compute(birthDate time.Time, birthTime *string, place *string) (Chart, error) {
lat, lng := DefaultLat, DefaultLng
placeLabel := "默认(未填出生地)"
hasPlace := false
if place != nil && *place != "" {
if la, ln, name, ok := ResolvePlace(*place); ok {
lat, lng, placeLabel, hasPlace = la, ln, name, true
} else {
placeLabel = *place + "(未匹配城市,用默认坐标)"
}
}
h, mi := 12, 0 // noon default when time missing
hasTime := false
if birthTime != nil && *birthTime != "" {
if hh, mm, ok := parseHM(*birthTime); ok {
h, mi, hasTime = hh, mm, true
}
}
// Treat civil time as UTC+8 for CN MVP (deterministic).
loc := time.FixedZone("CST", 8*3600)
local := time.Date(birthDate.Year(), birthDate.Month(), birthDate.Day(), h, mi, 0, 0, loc)
utc := local.UTC()
return ComputeAt(utc, lat, lng, placeLabel, hasTime, hasPlace)
}
// ComputeAt builds a chart for an exact UTC instant and coordinates.
func ComputeAt(utc time.Time, lat, lng float64, placeLabel string, hasTime, hasPlace bool) (Chart, error) {
utc = utc.UTC()
jd := ephemeris.JulianDayUT(utc)
lons, err := ephemeris.AllPlanetLons(jd)
if err != nil {
return Chart{}, fmt.Errorf("ephemeris: %w", err)
}
asc, err := ephemeris.Ascendant(jd, lat, lng)
if err != nil {
return Chart{}, fmt.Errorf("ephemeris asc: %w", err)
}
ch := ChartFromLons(lons, asc, lat, lng, placeLabel, hasTime, hasPlace)
ch.Instant = utc
ch.Note = fmt.Sprintf("热带黄道 · 整宫制 · 星历 %s。", ephemeris.Backend())
if !hasTime {
ch.Note += " 未填出生时,上升与宫位按正午估算。"
}
if !hasPlace {
ch.Note += " 填写出生地可提升上升准确度。"
}
return ch, nil
}
// ChartFromLons builds a whole-sign chart from body longitudes and ASC.
func ChartFromLons(lons map[string]float64, asc float64, lat, lng float64, placeLabel string, hasTime, hasPlace bool) Chart {
ascSignIdx := signIndex(asc)
mk := func(key string, lon float64) Body {
title := bodyTitles[key]
if title == "" {
title = key
}
idx := signIndex(lon)
s := signs[idx]
house := wholeSignHouse(ascSignIdx, idx)
return Body{
Key: key, Title: title, Lon: norm360(lon),
SignKey: s.Key, Sign: s.Label, Degree: math.Mod(norm360(lon), 30),
House: house, Element: s.Element, Modality: s.Modality,
}
}
sun := mk("sun", lons["sun"])
moon := mk("moon", lons["moon"])
rise := mk("rise", asc)
planets := []Body{
sun, moon, rise,
mk("mercury", lons["mercury"]),
mk("venus", lons["venus"]),
mk("mars", lons["mars"]),
mk("jupiter", lons["jupiter"]),
mk("saturn", lons["saturn"]),
mk("uranus", lons["uranus"]),
mk("neptune", lons["neptune"]),
mk("pluto", lons["pluto"]),
}
houses := make([]House, 12)
for i := 0; i < 12; i++ {
idx := (ascSignIdx + i) % 12
houses[i] = House{Num: i + 1, Sign: signs[idx].Label, Key: signs[idx].Key}
}
return Chart{
Sun: sun, Moon: moon, Rise: rise, Planets: planets, Houses: houses,
Lat: lat, Lng: lng, PlaceLabel: placeLabel, HasTime: hasTime, HasPlace: hasPlace,
}
}
// BodyLon returns longitude for a key, or 0.
func BodyLon(c Chart, key string) float64 {
for _, p := range c.Planets {
if p.Key == key {
return p.Lon
}
}
return 0
}
// SignMetaByKey returns label/element for a sign key.
func SignMetaByKey(key string) (label, element, modality string) {
for _, s := range signs {
if s.Key == key {
return s.Label, s.Element, s.Modality
}
}
return key, "", ""
}
// SignByIndex returns sign meta.
func SignByIndex(i int) (key, label, element, modality string) {
s := signs[((i%12)+12)%12]
return s.Key, s.Label, s.Element, s.Modality
}
func wholeSignHouse(ascIdx, bodyIdx int) int {
return ((bodyIdx-ascIdx)%12+12)%12 + 1
}
func signIndex(lon float64) int {
return int(math.Floor(norm360(lon)/30.0)) % 12
}
func parseHM(s string) (h, m int, ok bool) {
var hh, mm int
n, err := fmt.Sscanf(s, "%d:%d", &hh, &mm)
if err != nil || n < 1 || hh < 0 || hh > 23 {
return 0, 0, false
}
if n == 1 {
mm = 0
}
if mm < 0 || mm > 59 {
return 0, 0, false
}
return hh, mm, true
}
func norm360(x float64) float64 {
x = math.Mod(x, 360)
if x < 0 {
x += 360
}
return x
}
// AngleDiff returns smallest absolute ecliptic separation.
func AngleDiff(a, b float64) float64 {
d := math.Abs(norm360(a) - norm360(b))
if d > 180 {
d = 360 - d
}
return d
}
// MidpointLon returns the shorter-arc midpoint of two longitudes.
func MidpointLon(a, b float64) float64 {
a, b = norm360(a), norm360(b)
d := b - a
if d > 180 {
d -= 360
} else if d < -180 {
d += 360
}
return norm360(a + d/2)
}
// SignIndex is exported for overlay house mapping.
func SignIndex(lon float64) int { return signIndex(lon) }
// WholeSignHouse exported for overlay.
func WholeSignHouse(ascIdx, bodyIdx int) int { return wholeSignHouse(ascIdx, bodyIdx) }