package synastry import ( "fmt" "time" "github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris" "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" ) // ChartPack holds all synastry chart modes for one pair. type ChartPack struct { Composite natal.Chart Davison natal.Chart MarksMe natal.Chart MarksOther natal.Chart Overlay OverlayChart CompositeProgressed natal.Chart DavisonProgressed natal.Chart MarksProgressed natal.Chart AsOf time.Time } // OverlayHouse is B's planet in A's whole-sign house. type OverlayHouse struct { PlanetKey string `json:"planet_key"` Planet string `json:"planet"` Sign string `json:"sign"` House int `json:"house"` HouseTip string `json:"house_tip"` } // OverlayChart is the pairing/overlay table. type OverlayChart struct { HousesA []natal.House `json:"houses_a"` Entries []OverlayHouse `json:"entries"` Tip string `json:"tip"` } // BuildCharts computes five main charts + three progressed variants. func BuildCharts(a, b natal.Chart, asOf time.Time) (ChartPack, error) { if asOf.IsZero() { asOf = time.Now().In(time.FixedZone("CST", 8*3600)) } comp := CompositeChart(a, b) dav, err := DavisonChart(a, b) if err != nil { return ChartPack{}, err } marksMe := MarksChart(dav, a) marksOther := MarksChart(dav, b) overlay := Overlay(a, b) progA, err := ProgressChart(a, asOf) if err != nil { return ChartPack{}, err } progB, err := ProgressChart(b, asOf) if err != nil { return ChartPack{}, err } compProg := CompositeChart(progA, progB) davProg, err := ProgressChart(dav, asOf) if err != nil { return ChartPack{}, err } marksProg := MarksChart(davProg, progA) return ChartPack{ Composite: comp, Davison: dav, MarksMe: marksMe, MarksOther: marksOther, Overlay: overlay, CompositeProgressed: compProg, DavisonProgressed: davProg, MarksProgressed: marksProg, AsOf: asOf, }, nil } // CompositeChart midpoints each body longitude (shortest arc); ASC mid of rises. func CompositeChart(a, b natal.Chart) natal.Chart { lons := map[string]float64{} for _, k := range ephemeris.PlanetOrder { lons[k] = natal.MidpointLon(natal.BodyLon(a, k), natal.BodyLon(b, k)) } asc := natal.MidpointLon(a.Rise.Lon, b.Rise.Lon) lat := (a.Lat + b.Lat) / 2 lng := midLng(a.Lng, b.Lng) ch := natal.ChartFromLons(lons, asc, lat, lng, "组合盘", a.HasTime && b.HasTime, a.HasPlace || b.HasPlace) ch.Instant = midTime(a.Instant, b.Instant) ch.Note = "组合盘:双方行星黄经中点,看关系整体气质。" return ch } // DavisonChart uses midpoint birth time + midpoint coordinates, then recomputes. func DavisonChart(a, b natal.Chart) (natal.Chart, error) { inst := midTime(a.Instant, b.Instant) lat := (a.Lat + b.Lat) / 2 lng := midLng(a.Lng, b.Lng) ch, err := natal.ComputeAt(inst, lat, lng, "时空盘", true, true) if err != nil { return natal.Chart{}, err } ch.Note = "时空盘:出生时刻与坐标中点再排盘,看长期现实走向。" return ch, nil } // MarksChart midpoints each Davison body with a natal chart (我/TA 视角). func MarksChart(davison, natalCh natal.Chart) natal.Chart { lons := map[string]float64{} for _, k := range ephemeris.PlanetOrder { lons[k] = natal.MidpointLon(natal.BodyLon(davison, k), natal.BodyLon(natalCh, k)) } asc := natal.MidpointLon(davison.Rise.Lon, natalCh.Rise.Lon) ch := natal.ChartFromLons(lons, asc, natalCh.Lat, natalCh.Lng, "马克斯盘", true, true) ch.Instant = midTime(davison.Instant, natalCh.Instant) ch.Note = "马克斯盘:时空盘与本命中点,看关系中的内在态度。" return ch } // Overlay maps B planets into A's whole-sign houses. func Overlay(a, b natal.Chart) OverlayChart { ascIdx := natal.SignIndex(a.Rise.Lon) tips := houseTips() entries := make([]OverlayHouse, 0, len(b.Planets)) for _, p := range b.Planets { if p.Key == "rise" { continue } h := natal.WholeSignHouse(ascIdx, natal.SignIndex(p.Lon)) tip := tips[h] entries = append(entries, OverlayHouse{ PlanetKey: p.Key, Planet: p.Title, Sign: p.Sign, House: h, HouseTip: tip, }) } return OverlayChart{ HousesA: a.Houses, Entries: entries, Tip: "配对盘:对方行星落入我方整宫制宫位,提示生活领域互动。", } } // ProgressChart applies secondary progression (1 day ≈ 1 year) from chart.Instant to asOf. func ProgressChart(ch natal.Chart, asOf time.Time) (natal.Chart, error) { if ch.Instant.IsZero() { return ch, nil } years := asOf.UTC().Sub(ch.Instant).Hours() / 24.0 / 365.24219 if years < 0 { years = 0 } // Add whole days to avoid time.Duration overflow/precision issues for large ages. days := int(years + 0.5) progUTC := ch.Instant.AddDate(0, 0, days) out, err := natal.ComputeAt(progUTC, ch.Lat, ch.Lng, ch.PlaceLabel+"·次限", ch.HasTime, ch.HasPlace) if err != nil { return natal.Chart{}, err } out.Note = fmt.Sprintf("次限推运至 %s(约 %.1f 年)。", asOf.Format("2006-01-02"), years) return out, nil } func midTime(a, b time.Time) time.Time { if a.IsZero() && b.IsZero() { return time.Time{} } if a.IsZero() { return b } if b.IsZero() { return a } return a.Add(b.Sub(a) / 2) } func midLng(a, b float64) float64 { // Geographic mean (CN MVP; both births typically East Asia). return (a + b) / 2 } func houseTips() map[int]string { return map[int]string{ 1: "自我与第一印象", 2: "资源与安全感", 3: "沟通与日常", 4: "家庭与根基", 5: "恋爱表达与创造", 6: "协作与健康节奏", 7: "一对一关系", 8: "深度与共享", 9: "视野与信念", 10: "事业与对外形象", 11: "社群与愿景", 12: "内在与疗愈", } } func chartSummaryOut(c natal.Chart, tip string) map[string]any { asp := natal.Aspects(c) aspMaps := natal.AspectsAsMaps(asp) previewN := 4 if len(aspMaps) < previewN { previewN = len(aspMaps) } t := tip if t == "" { t = c.Note } return map[string]any{ "asc_lon": c.Rise.Lon, "planets": bodiesOut(c), "houses": housesOut(c), "note": c.Note, "place": c.PlaceLabel, "tip": t, "aspects_preview": aspMaps[:previewN], "sun": c.Sun.Sign, "moon": c.Moon.Sign, "rise": c.Rise.Sign, } } func overlayOut(o OverlayChart) map[string]any { entries := make([]map[string]any, 0, len(o.Entries)) for _, e := range o.Entries { entries = append(entries, map[string]any{ "planet_key": e.PlanetKey, "planet": e.Planet, "sign": e.Sign, "house": e.House, "house_tip": e.HouseTip, }) } return map[string]any{ "tip": o.Tip, "entries": entries, "houses_a": housesOut(natal.Chart{Houses: o.HousesA}), } }