// 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) }