落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
255 lines
6.7 KiB
Go
255 lines
6.7 KiB
Go
// 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"` // 0–30 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) }
|