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,226 @@
|
||||
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}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
// Report is free summary + gated detail for a synastry GrowthReport.
|
||||
type Report struct {
|
||||
Summary map[string]any
|
||||
Detail map[string]any
|
||||
}
|
||||
|
||||
// BuildReport builds multi-chart synastry content (五主盘 + 三推运).
|
||||
func BuildReport(a, b natal.Chart, aName, bName string, asOf time.Time) (Report, error) {
|
||||
if aName == "" {
|
||||
aName = "我"
|
||||
}
|
||||
if bName == "" {
|
||||
bName = "TA"
|
||||
}
|
||||
if asOf.IsZero() {
|
||||
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
|
||||
}
|
||||
idx := Compute(a, b, aName, bName)
|
||||
cross := CrossAspects(a, b)
|
||||
crossMaps := natal.AspectsAsMaps(cross)
|
||||
previewN := 4
|
||||
if len(crossMaps) < previewN {
|
||||
previewN = len(crossMaps)
|
||||
}
|
||||
|
||||
pack, err := BuildCharts(a, b, asOf)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
asOfStr := asOf.Format("2006-01-02")
|
||||
|
||||
chartsSummary := map[string]any{
|
||||
"compare": map[string]any{
|
||||
"tip": "比较盘:双方叠合,看互动与吸引。",
|
||||
"chart_a": map[string]any{
|
||||
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
|
||||
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
|
||||
"sun": a.Sun.Sign, "moon": a.Moon.Sign, "rise": a.Rise.Sign,
|
||||
},
|
||||
"chart_b": map[string]any{
|
||||
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
|
||||
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
|
||||
"sun": b.Sun.Sign, "moon": b.Moon.Sign, "rise": b.Rise.Sign,
|
||||
},
|
||||
"aspects_preview": crossMaps[:previewN],
|
||||
},
|
||||
"composite": chartSummaryOut(pack.Composite, "组合盘:关系整体气质。"),
|
||||
"davison": chartSummaryOut(pack.Davison, "时空盘:长期现实走向。"),
|
||||
"marks_me": chartSummaryOut(pack.MarksMe, "马克斯盘·我:我对这段关系的内在态度。"),
|
||||
"marks_other": chartSummaryOut(pack.MarksOther, "马克斯盘·TA:TA对这段关系的内在态度。"),
|
||||
"overlay": overlayOut(pack.Overlay),
|
||||
"composite_progressed": chartSummaryOut(pack.CompositeProgressed, "组合次限:关系当下成长态。"),
|
||||
"davison_progressed": chartSummaryOut(pack.DavisonProgressed, "时空次限:现实课题的阶段性。"),
|
||||
"marks_progressed": chartSummaryOut(pack.MarksProgressed, "马盘推运:我对关系的阶段心态。"),
|
||||
}
|
||||
|
||||
summary := map[string]any{
|
||||
"title": "合盘·基础",
|
||||
"headline": idx.Summary,
|
||||
"one_liner": fmt.Sprintf("%s与%s:五盘合观互动、气质与现实节奏。", aName, bName),
|
||||
"overview": fmt.Sprintf("%s(日%s月%s升%s)× %s(日%s月%s升%s)。", aName, a.Sun.Sign, a.Moon.Sign, a.Rise.Sign, bName, b.Sun.Sign, b.Moon.Sign, b.Rise.Sign),
|
||||
"me_name": aName,
|
||||
"other_name": bName,
|
||||
"love_index": idx.Love,
|
||||
"friend_index": idx.Friend,
|
||||
"marriage_index": idx.Marriage,
|
||||
"match_indices": idx.AsMap(),
|
||||
"love_note": idx.LoveNote,
|
||||
"friend_note": idx.FriendNote,
|
||||
"marriage_note": idx.MarriageNote,
|
||||
"as_of": asOfStr,
|
||||
// backward-compat top-level compare charts
|
||||
"chart_a": map[string]any{
|
||||
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
|
||||
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
|
||||
},
|
||||
"chart_b": map[string]any{
|
||||
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
|
||||
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
|
||||
},
|
||||
"aspects_preview": crossMaps[:previewN],
|
||||
"charts": chartsSummary,
|
||||
"keywords": []string{"合盘", "比较盘", "组合盘", "时空盘", fmt.Sprintf("恋爱%d", idx.Love)},
|
||||
"strengths_preview": []string{
|
||||
fmt.Sprintf("恋爱指数 %d", idx.Love),
|
||||
fmt.Sprintf("友情指数 %d", idx.Friend),
|
||||
fmt.Sprintf("婚姻指数 %d", idx.Marriage),
|
||||
},
|
||||
"blind_spots_preview": []string{"完整相位、推运深文案与落宫解读见深度版。"},
|
||||
}
|
||||
|
||||
bullets := make([]string, 0, min(10, len(cross)))
|
||||
for i, c := range cross {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
bullets = append(bullets, c.Label)
|
||||
}
|
||||
|
||||
compAsp := natal.AspectsAsMaps(natal.Aspects(pack.Composite))
|
||||
davAsp := natal.AspectsAsMaps(natal.Aspects(pack.Davison))
|
||||
progAsp := natal.AspectsAsMaps(natal.Aspects(pack.CompositeProgressed))
|
||||
|
||||
detail := map[string]any{
|
||||
"title": "合盘·完整分析",
|
||||
"as_of": asOfStr,
|
||||
"aspects": crossMaps,
|
||||
"charts": map[string]any{
|
||||
"compare": map[string]any{"aspects": crossMaps},
|
||||
"composite": map[string]any{"aspects": compAsp, "planets": bodiesOut(pack.Composite)},
|
||||
"davison": map[string]any{"aspects": davAsp, "planets": bodiesOut(pack.Davison)},
|
||||
"marks_me": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksMe)), "planets": bodiesOut(pack.MarksMe)},
|
||||
"marks_other": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksOther)), "planets": bodiesOut(pack.MarksOther)},
|
||||
"overlay": overlayOut(pack.Overlay),
|
||||
"composite_progressed": map[string]any{"aspects": progAsp, "planets": bodiesOut(pack.CompositeProgressed), "note": pack.CompositeProgressed.Note},
|
||||
"davison_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.DavisonProgressed)), "planets": bodiesOut(pack.DavisonProgressed), "note": pack.DavisonProgressed.Note},
|
||||
"marks_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksProgressed)), "planets": bodiesOut(pack.MarksProgressed), "note": pack.MarksProgressed.Note},
|
||||
},
|
||||
"sections": []map[string]any{
|
||||
{"title": "恋爱互动", "body": idx.LoveNote, "bullets": []string{"说清需要比猜测更有效", "张力相位日宜慢半拍回应"}},
|
||||
{"title": "友情节奏", "body": idx.FriendNote, "bullets": []string{"共同兴趣维系轻松感", "尊重彼此独处需求"}},
|
||||
{"title": "长期相处", "body": idx.MarriageNote, "bullets": []string{"共同节奏与边界同等重要", "把期待写成说明书"}},
|
||||
{"title": "跨盘相位", "body": "比较盘相位描述双方能量如何彼此激活或拉扯。", "bullets": bullets},
|
||||
{"title": "组合与时空", "body": "组合盘看关系气质,时空盘看共同现实课题;次限呈现阶段性成长。", "bullets": []string{
|
||||
fmt.Sprintf("组合盘日%s月%s升%s", pack.Composite.Sun.Sign, pack.Composite.Moon.Sign, pack.Composite.Rise.Sign),
|
||||
fmt.Sprintf("时空盘日%s月%s升%s", pack.Davison.Sun.Sign, pack.Davison.Moon.Sign, pack.Davison.Rise.Sign),
|
||||
fmt.Sprintf("推运日期 %s", asOfStr),
|
||||
}},
|
||||
{"title": "配对落宫", "body": pack.Overlay.Tip, "bullets": overlayBullets(pack.Overlay)},
|
||||
},
|
||||
"growth_plan": []map[string]any{
|
||||
{"phase": "本周", "focus": "约一次轻松同步:各说一件欣赏与一件需要。"},
|
||||
{"phase": "本月", "focus": "为高频摩擦点写一条可执行约定。"},
|
||||
{"phase": "长期", "focus": "每季度复盘三指数变化与相处舒适度。"},
|
||||
},
|
||||
"conversation_scripts": []string{
|
||||
"我想听听你怎么看我们的节奏,不急着定结论。",
|
||||
"我需要一点空间整理,不是拒绝连接。",
|
||||
"我们共同目标是……,这一步只做一件事。",
|
||||
},
|
||||
"behavior_pattern": idx.LoveNote,
|
||||
"relation_style": idx.FriendNote,
|
||||
"growth_direction": idx.MarriageNote,
|
||||
}
|
||||
return Report{Summary: summary, Detail: detail}, nil
|
||||
}
|
||||
|
||||
func overlayBullets(o OverlayChart) []string {
|
||||
out := make([]string, 0, 6)
|
||||
for i, e := range o.Entries {
|
||||
if i >= 6 {
|
||||
break
|
||||
}
|
||||
if e.PlanetKey == "sun" || e.PlanetKey == "moon" || e.PlanetKey == "venus" || e.PlanetKey == "mars" {
|
||||
out = append(out, fmt.Sprintf("%s在我方%d宫(%s)", e.Planet, e.House, e.HouseTip))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && len(o.Entries) > 0 {
|
||||
e := o.Entries[0]
|
||||
out = append(out, fmt.Sprintf("%s在我方%d宫", e.Planet, e.House))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CrossAspects finds aspects between chart A bodies and chart B bodies.
|
||||
func CrossAspects(a, b natal.Chart) []natal.Aspect {
|
||||
keys := []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"}
|
||||
byA := map[string]natal.Body{}
|
||||
byB := map[string]natal.Body{}
|
||||
for _, p := range a.Planets {
|
||||
byA[p.Key] = p
|
||||
}
|
||||
for _, p := range b.Planets {
|
||||
byB[p.Key] = p
|
||||
}
|
||||
defs := []struct {
|
||||
Type string
|
||||
Angle float64
|
||||
Orb float64
|
||||
Label string
|
||||
}{
|
||||
{"conjunction", 0, 8, "合相"},
|
||||
{"sextile", 60, 6, "六合"},
|
||||
{"square", 90, 7, "刑相"},
|
||||
{"trine", 120, 7, "拱相"},
|
||||
{"opposition", 180, 8, "对冲"},
|
||||
}
|
||||
out := make([]natal.Aspect, 0)
|
||||
for _, ka := range keys {
|
||||
ba, oka := byA[ka]
|
||||
if !oka {
|
||||
continue
|
||||
}
|
||||
for _, kb := range keys {
|
||||
bb, okb := byB[kb]
|
||||
if !okb {
|
||||
continue
|
||||
}
|
||||
diff := natal.AngleDiff(ba.Lon, bb.Lon)
|
||||
for _, def := range defs {
|
||||
orb := absF(diff - def.Angle)
|
||||
if orb <= def.Orb {
|
||||
out = append(out, natal.Aspect{
|
||||
A: "a:" + ba.Key, B: "b:" + bb.Key,
|
||||
ATitle: aNameTitle(ba.Title), BTitle: bNameTitle(bb.Title),
|
||||
Type: def.Type, Angle: def.Angle, Orb: round1(orb),
|
||||
Label: fmt.Sprintf("我方%s%s对方%s(容许%.1f°)", ba.Title, def.Label, bb.Title, orb),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(out); i++ {
|
||||
for j := i + 1; j < len(out); j++ {
|
||||
if out[j].Orb < out[i].Orb {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 24 {
|
||||
out = out[:24]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func aNameTitle(t string) string { return "我·" + t }
|
||||
func bNameTitle(t string) string { return "TA·" + t }
|
||||
|
||||
func bodiesOut(c natal.Chart) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(c.Planets))
|
||||
for _, p := range c.Planets {
|
||||
out = append(out, map[string]any{
|
||||
"key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey,
|
||||
"degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House,
|
||||
"lon": p.Lon, "element": p.Element, "modality": p.Modality,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func housesOut(c natal.Chart) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(c.Houses))
|
||||
for _, h := range c.Houses {
|
||||
out = append(out, map[string]any{"num": h.Num, "sign": h.Sign, "sign_key": h.Key})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func absF(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func round1(x float64) float64 {
|
||||
return float64(int(x*10+0.5)) / 10
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
func TestBuildReport(t *testing.T) {
|
||||
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("上海"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
rep, err := BuildReport(a, b, "我", "TA", asOf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Summary["love_index"] == nil || rep.Summary["chart_a"] == nil {
|
||||
t.Fatalf("summary incomplete: %#v", rep.Summary)
|
||||
}
|
||||
charts, ok := rep.Summary["charts"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("charts missing")
|
||||
}
|
||||
for _, k := range []string{"compare", "composite", "davison", "marks_me", "marks_other", "overlay",
|
||||
"composite_progressed", "davison_progressed", "marks_progressed"} {
|
||||
if charts[k] == nil {
|
||||
t.Fatalf("missing chart key %s", k)
|
||||
}
|
||||
}
|
||||
if rep.Summary["as_of"] != "2026-08-02" {
|
||||
t.Fatalf("as_of=%v", rep.Summary["as_of"])
|
||||
}
|
||||
if rep.Detail["aspects"] == nil {
|
||||
t.Fatal("detail aspects missing")
|
||||
}
|
||||
cross := CrossAspects(a, b)
|
||||
if len(cross) == 0 {
|
||||
t.Fatal("expected cross aspects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeDeterministic(t *testing.T) {
|
||||
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("10:30"), strPtr("上海"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("北京"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c1 := CompositeChart(a, b)
|
||||
c2 := CompositeChart(a, b)
|
||||
if c1.Sun.Lon != c2.Sun.Lon || c1.Rise.Sign != c2.Rise.Sign {
|
||||
t.Fatal("composite not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package synastry computes love/friend/marriage match indices from two natal charts.
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
// Indices are 0–100 match scores.
|
||||
type Indices struct {
|
||||
Love int `json:"love_index"`
|
||||
Friend int `json:"friend_index"`
|
||||
Marriage int `json:"marriage_index"`
|
||||
LoveNote string `json:"love_note"`
|
||||
FriendNote string `json:"friend_note"`
|
||||
MarriageNote string `json:"marriage_note"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Compute returns match indices for charts A and B.
|
||||
func Compute(a, b natal.Chart, aName, bName string) Indices {
|
||||
if aName == "" {
|
||||
aName = "我"
|
||||
}
|
||||
if bName == "" {
|
||||
bName = "TA"
|
||||
}
|
||||
sunDiff := natal.AngleDiff(a.Sun.Lon, b.Sun.Lon)
|
||||
moonDiff := natal.AngleDiff(a.Moon.Lon, b.Moon.Lon)
|
||||
venA := bodyLon(a, "venus")
|
||||
venB := bodyLon(b, "venus")
|
||||
marsA := bodyLon(a, "mars")
|
||||
marsB := bodyLon(b, "mars")
|
||||
venDiff := natal.AngleDiff(venA, venB)
|
||||
marsVen := natal.AngleDiff(marsA, venB)
|
||||
venMars := natal.AngleDiff(venA, marsB)
|
||||
|
||||
// Closer aspects (0/60/120 soft; 90/180 hard but charged) → higher score blended
|
||||
love := clamp(58+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+aspectBoost(marsVen)/2+aspectBoost(venMars)/2, 42, 98)
|
||||
friend := clamp(60+aspectBoost(sunDiff)+aspectBoost(moonDiff)*3/2+elemBoost(a.Sun, b.Sun), 45, 97)
|
||||
marriage := clamp(55+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+houseBoost(a, b), 40, 96)
|
||||
|
||||
return Indices{
|
||||
Love: love, Friend: friend, Marriage: marriage,
|
||||
LoveNote: fmt.Sprintf("%s与%s恋爱指数 %d:太阳相距约%.0f°,金星互动影响吸引力。", aName, bName, love, sunDiff),
|
||||
FriendNote: fmt.Sprintf("友情指数 %d:月亮与太阳元素是否合拍,决定轻松感。", friend),
|
||||
MarriageNote: fmt.Sprintf("婚姻/长期指数 %d:看重稳定与共同节奏,而非一时火花。", marriage),
|
||||
Summary: fmt.Sprintf("%s × %s:恋爱 %d · 友情 %d · 婚姻 %d", aName, bName, love, friend, marriage),
|
||||
}
|
||||
}
|
||||
|
||||
func bodyLon(c natal.Chart, key string) float64 {
|
||||
for _, p := range c.Planets {
|
||||
if p.Key == key {
|
||||
return p.Lon
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func aspectBoost(diff float64) int {
|
||||
// reward conjunction, sextile, trine; mild for square/opposition
|
||||
targets := []float64{0, 60, 90, 120, 180}
|
||||
best := 180.0
|
||||
for _, t := range targets {
|
||||
d := abs(diff - t)
|
||||
if d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case best <= 8:
|
||||
if near(diff, 90) || near(diff, 180) {
|
||||
return 8
|
||||
}
|
||||
return 14
|
||||
case best <= 12:
|
||||
return 8
|
||||
default:
|
||||
return int(6 - best/30)
|
||||
}
|
||||
}
|
||||
|
||||
func near(diff, target float64) bool {
|
||||
return abs(diff-target) <= 10
|
||||
}
|
||||
|
||||
func elemBoost(a, b natal.Body) int {
|
||||
if a.Element == b.Element {
|
||||
return 6
|
||||
}
|
||||
// fire-air / earth-water traditionally supportive
|
||||
pair := a.Element + b.Element
|
||||
if pair == "火风" || pair == "风火" || pair == "土水" || pair == "水土" {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func houseBoost(a, b natal.Chart) int {
|
||||
// same rising modality → slight boost
|
||||
if a.Rise.Modality == b.Rise.Modality {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// AsMap for JSON.
|
||||
func (i Indices) AsMap() map[string]any {
|
||||
return map[string]any{
|
||||
"love_index": i.Love, "friend_index": i.Friend, "marriage_index": i.Marriage,
|
||||
"love_note": i.LoveNote, "friend_note": i.FriendNote, "marriage_note": i.MarriageNote,
|
||||
"summary": i.Summary,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package synastry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
func TestComputeIndices(t *testing.T) {
|
||||
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := natal.Compute(time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := Compute(a, b, "我", "TA")
|
||||
if idx.Love < 40 || idx.Friend < 40 || idx.Marriage < 40 {
|
||||
t.Fatalf("scores too low: %+v", idx)
|
||||
}
|
||||
if idx.Summary == "" {
|
||||
t.Fatal("empty summary")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user