feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
// 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) }
|
||||
Reference in New Issue
Block a user