astro_chart 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,35 @@
1
1
  module AstroChart
2
2
  class Chart
3
- attr_reader :birth_date, :birth_time, :latitude, :longitude, :timezone
3
+ # Supported house systems: "P" (Placidus) / "W" (Whole Sign) /
4
+ # "E" (Equal, from the ascendant) / "O" (Porphyry).
5
+ HOUSE_SYSTEMS = ["P", "W", "E", "O"].freeze
6
+
7
+ # Bodies that can never be retrograde in geocentric longitude.
8
+ NEVER_RETROGRADE = ["太陽", "月亮"].freeze
9
+
10
+ attr_reader :birth_date, :birth_time, :latitude, :longitude, :timezone,
11
+ :house_system
4
12
 
5
13
  # birth_date: "YYYY-MM-DD"
6
14
  # birth_time: "HH:MM"
7
15
  # latitude / longitude: Float
8
16
  # timezone: IANA timezone string (e.g. "Asia/Taipei")
9
- def initialize(birth_date:, birth_time:, latitude:, longitude:, timezone:)
10
- @birth_date = birth_date
11
- @birth_time = birth_time
12
- @latitude = latitude.to_f
13
- @longitude = longitude.to_f
14
- @timezone = timezone
17
+ # house_system: "P" (Placidus, default), "W" (Whole Sign),
18
+ # "E" (Equal, from the ascendant) or "O" (Porphyry)
19
+ def initialize(birth_date:, birth_time:, latitude:, longitude:, timezone:,
20
+ house_system: "P")
21
+ unless HOUSE_SYSTEMS.include?(house_system)
22
+ raise ArgumentError,
23
+ "unknown house system #{house_system.inspect} " \
24
+ "(expected \"P\", \"W\", \"E\" or \"O\")"
25
+ end
26
+
27
+ @birth_date = birth_date
28
+ @birth_time = birth_time
29
+ @latitude = latitude.to_f
30
+ @longitude = longitude.to_f
31
+ @timezone = timezone
32
+ @house_system = house_system
15
33
  end
16
34
 
17
35
  # Generate complete chart data.
@@ -19,12 +37,17 @@ module AstroChart
19
37
  def generate
20
38
  jd = TimeConversion.to_julian_day(birth_date, birth_time, timezone)
21
39
 
22
- cusps, ascendant = Houses.calculate(jd, latitude, longitude)
40
+ cusps, ascendant = Houses.calculate(jd, latitude, longitude, system: house_system)
23
41
  asc_zodiac = Zodiac.sign_name(ascendant)
24
42
 
25
43
  positions = Planets.calculate_positions(jd)
26
44
  planet_details = Planets.build_details(positions, cusps)
27
45
 
46
+ # Retrograde flags for the 12 physical bodies (太陽/月亮 hard-false,
47
+ # 南交點 mirrors 北交點 — they share the same axis).
48
+ retro = retrograde_flags(jd)
49
+ planet_details.each { |p| p["retrograde"] = retro.fetch(p["planet"], false) }
50
+
28
51
  kp = Planets.key_points_data(positions, cusps, ascendant)
29
52
 
30
53
  # Merge aspects into planet details
@@ -42,8 +65,12 @@ module AstroChart
42
65
  planet["aspects"] = kp[key] if key
43
66
  end
44
67
 
45
- # Append ruler points
46
- planet_details.concat(kp["additional_points"])
68
+ # Derived points (福點 / 莉莉絲), placed before the ruler points
69
+ planet_details.concat(derived_points(jd, positions, cusps, ascendant))
70
+
71
+ # Append ruler points (定位星 carry no retrograde motion of their own)
72
+ ruler_points = kp["additional_points"].map { |p| p.merge("retrograde" => false) }
73
+ planet_details.concat(ruler_points)
47
74
 
48
75
  houses_data = cusps.each_with_index.map do |deg, i|
49
76
  {
@@ -53,6 +80,10 @@ module AstroChart
53
80
  }
54
81
  end
55
82
 
83
+ # 12-body positions feed pattern detection; the 10 classical planets
84
+ # (nodes excluded) feed the element/modality statistics.
85
+ classical = positions.reject { |name, _| name == "北交點" || name == "南交點" }
86
+
56
87
  {
57
88
  "input" => {
58
89
  "birth_date" => birth_date,
@@ -69,10 +100,61 @@ module AstroChart
69
100
  "degree" => (ascendant % 30).round(4),
70
101
  "total_degree" => ascendant.round(4),
71
102
  },
72
- "planets" => planet_details,
73
- "houses" => houses_data,
103
+ "house_system" => house_system,
104
+ "planets" => planet_details,
105
+ "houses" => houses_data,
106
+ "patterns" => Patterns.detect(positions),
107
+ "element_stats" => Stats.elements(classical),
74
108
  },
75
109
  }
76
110
  end
111
+
112
+ private
113
+
114
+ # { "太陽" => false, ..., "南交點" => bool } — retrograde flags per body.
115
+ def retrograde_flags(jd)
116
+ flags = {}
117
+ Ephemeris::PLANETS.each do |name, planet_id|
118
+ flags[name] = if NEVER_RETROGRADE.include?(name)
119
+ false
120
+ else
121
+ Ephemeris.retrograde?(jd, planet_id)
122
+ end
123
+ end
124
+ flags["南交點"] = flags["北交點"]
125
+ flags
126
+ end
127
+
128
+ # 福點 (Part of Fortune) + 莉莉絲 (mean Black Moon Lilith) entries,
129
+ # shaped like regular planet entries. Both are hard-false retrograde:
130
+ # 福點 is a derived point with no motion of its own; 莉莉絲 is the mean
131
+ # lunar apogee, which always moves prograde.
132
+ def derived_points(jd, positions, cusps, ascendant)
133
+ # Sect (day/night) is an astronomical fact defined by the Sun relative
134
+ # to the ASC-DSC horizon axis, so it is judged from the horizon itself
135
+ # — never from the selected display house system's cusps (whole-sign
136
+ # cusps sit on sign boundaries, not on the horizon).
137
+ day_chart = Points.day_chart_from_horizon?(positions["太陽"], ascendant)
138
+ fortune = Points.fortune(
139
+ asc: ascendant,
140
+ sun: positions["太陽"],
141
+ moon: positions["月亮"],
142
+ day_chart: !!day_chart
143
+ )
144
+ lilith = Points.lilith(jd)
145
+
146
+ [point_entry("福點", fortune, cusps), point_entry("莉莉絲", lilith, cusps)]
147
+ end
148
+
149
+ def point_entry(name, pos, cusps)
150
+ {
151
+ "planet" => name,
152
+ "zodiac" => Zodiac.sign_name(pos),
153
+ "house" => Houses.find_house(pos, cusps),
154
+ "degree" => (pos % 30).round(4),
155
+ "total_degree" => pos.round(4),
156
+ "retrograde" => false,
157
+ }
158
+ end
77
159
  end
78
160
  end
@@ -0,0 +1,101 @@
1
+ module AstroChart
2
+ # Composite chart (組合盤): the midpoint chart of two natal charts.
3
+ #
4
+ # For every body present in BOTH charts, the composite position is the
5
+ # shorter-arc midpoint of the two natal longitudes. Aspects are then
6
+ # computed among the composite positions themselves.
7
+ #
8
+ # Backend-independent: works purely on Chart#generate result hashes,
9
+ # no ephemeris call happens here.
10
+ #
11
+ # result = Composite.between(chart_a, chart_b)
12
+ # result["planets"] # 12 個組合盤中點位置
13
+ # result["aspects"] # 組合盤位置彼此之間的相位(依 orb 排序)
14
+ #
15
+ # Houses are intentionally out of scope: midpoint-composite house systems
16
+ # are convention-contested (midpoint of cusps vs. recomputed for a
17
+ # reference location/time), so no single answer is returned here.
18
+ module Composite
19
+ # The 12 bodies considered: the 11 ephemeris bodies + 南交點.
20
+ BODIES = (Ephemeris::PLANETS.keys + ["南交點"]).freeze
21
+
22
+ # Full composite between two Chart#generate result hashes.
23
+ #
24
+ # Returns:
25
+ # {
26
+ # "planets" => [{ "planet", "zodiac", "degree", "total_degree" }],
27
+ # "aspects" => [{ "planet_a", "planet_b", "aspect_type", "orb" }],
28
+ # }
29
+ def self.between(chart_a, chart_b)
30
+ pos_a = positions_from_chart(chart_a)
31
+ pos_b = positions_from_chart(chart_b)
32
+
33
+ composite_positions = {}
34
+ BODIES.each do |name|
35
+ a = pos_a[name]
36
+ b = pos_b[name]
37
+ next if a.nil? || b.nil?
38
+
39
+ composite_positions[name] = midpoint(a, b)
40
+ end
41
+
42
+ {
43
+ "planets" => build_planets(composite_positions),
44
+ "aspects" => build_aspects(composite_positions),
45
+ }
46
+ end
47
+
48
+ # Shorter-arc midpoint of two ecliptic longitudes (degrees, [0, 360)).
49
+ # 350° & 10° => 0° (not 180°).
50
+ def self.midpoint(a, b)
51
+ (a + (((b - a + 540.0) % 360.0) - 180.0) / 2.0) % 360.0
52
+ end
53
+
54
+ # Extract { name => total_degree } for BODIES from a Chart#generate hash.
55
+ # Ruler points appended by key_points_data are ignored (not in BODIES).
56
+ def self.positions_from_chart(chart)
57
+ planets = chart&.dig("chart", "planets")
58
+ raise ArgumentError, "chart has no planets data" if planets.nil? || planets.empty?
59
+
60
+ planets.each_with_object({}) do |p, out|
61
+ name = p["planet"]
62
+ next unless BODIES.include?(name)
63
+
64
+ degree = p["total_degree"]
65
+ out[name] = degree if degree
66
+ end
67
+ end
68
+
69
+ def self.build_planets(positions)
70
+ positions.map do |name, pos|
71
+ {
72
+ "planet" => name,
73
+ "zodiac" => Zodiac.sign_name(pos),
74
+ "degree" => (pos % 30).round(4),
75
+ "total_degree" => pos.round(4),
76
+ }
77
+ end
78
+ end
79
+
80
+ def self.build_aspects(positions)
81
+ names = positions.keys
82
+ results = []
83
+
84
+ names.combination(2) do |name_a, name_b|
85
+ aspect_type, orb = Aspects.calculate(positions[name_a], positions[name_b])
86
+ next if aspect_type.nil?
87
+
88
+ results << {
89
+ "planet_a" => name_a,
90
+ "planet_b" => name_b,
91
+ "aspect_type" => aspect_type,
92
+ "orb" => orb,
93
+ }
94
+ end
95
+
96
+ results.sort_by { |r| r["orb"] }
97
+ end
98
+
99
+ private_class_method :build_planets, :build_aspects
100
+ end
101
+ end
@@ -0,0 +1,220 @@
1
+ require_relative "zodiac"
2
+
3
+ module AstroChart
4
+ # Essential dignities (必然尊貴) — the classical Hellenistic / medieval
5
+ # scheme of five dignities and their planetary rulers, plus the two major
6
+ # debilities. Pure lookup tables; no ephemeris, fully deterministic.
7
+ #
8
+ # Rulers are the SEVEN traditional planets (太陽 月亮 水星 金星 火星 木星 土星).
9
+ # These use the TRADITIONAL rulerships (天蠍→火星, 水瓶→土星, 雙魚→木星), which
10
+ # differ from the modern rulers in Zodiac.ruler (冥王星/天王星/海王星). Dignity
11
+ # theory predates the outer planets, so it is defined on the traditional set.
12
+ #
13
+ # The five dignities and their scores (Lilly / Ptolemaic weighting):
14
+ # 廟 domicile +5 planet rules the sign
15
+ # 旺 exaltation +4 planet is exalted in the sign (with an exact degree)
16
+ # 三分性 triplicity +3 planet rules the sign's element by sect
17
+ # 界 term/bound +2 planet rules the bound the degree falls in
18
+ # 外觀 face/decan +1 planet rules the 10° face the degree falls in
19
+ # Debilities: 陷 detriment (−5, opposite domicile), 弱 fall (−4, opposite
20
+ # exaltation).
21
+ #
22
+ # Terms: two schemes are bundled — 埃及界 (Egyptian, the default and most
23
+ # widely used) and 托勒密界 (Ptolemaic). Both are cross-validated against
24
+ # Astrolog's tables; the Egyptian table additionally matches several
25
+ # independent references degree-for-degree.
26
+ module Dignities
27
+ PLANETS = ["太陽", "月亮", "水星", "金星", "火星", "木星", "土星"].freeze
28
+
29
+ # 傳統守護星 (traditional domicile rulers), by sign.
30
+ DOMICILE = {
31
+ "牡羊座" => "火星", "金牛座" => "金星", "雙子座" => "水星", "巨蟹座" => "月亮",
32
+ "獅子座" => "太陽", "處女座" => "水星", "天秤座" => "金星", "天蠍座" => "火星",
33
+ "射手座" => "木星", "摩羯座" => "土星", "水瓶座" => "土星", "雙魚座" => "木星",
34
+ }.freeze
35
+
36
+ # 旺 (exaltation): sign => [planet, exact degree]. Signs without an
37
+ # exaltation ruler are absent.
38
+ EXALTATION = {
39
+ "牡羊座" => ["太陽", 19], "金牛座" => ["月亮", 3], "巨蟹座" => ["木星", 15],
40
+ "處女座" => ["水星", 15], "天秤座" => ["土星", 21], "摩羯座" => ["火星", 28],
41
+ "雙魚座" => ["金星", 27],
42
+ }.freeze
43
+
44
+ # 三分性 (triplicity), Dorothean scheme: element => day/night/participating
45
+ # rulers. Elements repeat every 4 signs from 牡羊 (火).
46
+ ELEMENTS = ["火", "土", "風", "水"].freeze
47
+ TRIPLICITY = {
48
+ "火" => { day: "太陽", night: "木星", participating: "土星" },
49
+ "土" => { day: "金星", night: "月亮", participating: "火星" },
50
+ "風" => { day: "土星", night: "水星", participating: "木星" },
51
+ "水" => { day: "金星", night: "火星", participating: "月亮" },
52
+ }.freeze
53
+
54
+ # 埃及界 (Egyptian terms): sign => [[ruler, ending_degree], ...5], the
55
+ # ending degree measured within the sign (0-30). Verified against multiple
56
+ # references.
57
+ EGYPTIAN_TERMS = {
58
+ "牡羊座" => [["木星", 6], ["金星", 12], ["水星", 20], ["火星", 25], ["土星", 30]],
59
+ "金牛座" => [["金星", 8], ["水星", 14], ["木星", 22], ["土星", 27], ["火星", 30]],
60
+ "雙子座" => [["水星", 6], ["木星", 12], ["金星", 17], ["火星", 24], ["土星", 30]],
61
+ "巨蟹座" => [["火星", 7], ["金星", 13], ["水星", 19], ["木星", 26], ["土星", 30]],
62
+ "獅子座" => [["木星", 6], ["金星", 11], ["土星", 18], ["水星", 24], ["火星", 30]],
63
+ "處女座" => [["水星", 7], ["金星", 17], ["木星", 21], ["火星", 28], ["土星", 30]],
64
+ "天秤座" => [["土星", 6], ["水星", 14], ["木星", 21], ["金星", 28], ["火星", 30]],
65
+ "天蠍座" => [["火星", 7], ["金星", 11], ["水星", 19], ["木星", 24], ["土星", 30]],
66
+ "射手座" => [["木星", 12], ["金星", 17], ["水星", 21], ["土星", 26], ["火星", 30]],
67
+ "摩羯座" => [["水星", 7], ["木星", 14], ["金星", 22], ["土星", 26], ["火星", 30]],
68
+ "水瓶座" => [["水星", 7], ["金星", 13], ["木星", 20], ["火星", 25], ["土星", 30]],
69
+ "雙魚座" => [["金星", 12], ["木星", 16], ["水星", 19], ["火星", 28], ["土星", 30]],
70
+ }.freeze
71
+
72
+ # 托勒密界 (Ptolemaic terms): Ptolemy's alternative bounds from the
73
+ # Tetrabiblos. Decoded from Astrolog's rgnTermPtolemy table (the same
74
+ # decoding reproduces Astrolog's Egyptian table identically to EGYPTIAN_TERMS
75
+ # above, which validates it). Resolves the classically disputed cells as
76
+ # 獅子 first term = 土星 and 天蠍 first term = 火星.
77
+ PTOLEMAIC_TERMS = {
78
+ "牡羊座" => [["木星", 6], ["金星", 14], ["水星", 21], ["火星", 26], ["土星", 30]],
79
+ "金牛座" => [["金星", 8], ["水星", 15], ["木星", 22], ["土星", 26], ["火星", 30]],
80
+ "雙子座" => [["水星", 7], ["木星", 14], ["金星", 21], ["土星", 25], ["火星", 30]],
81
+ "巨蟹座" => [["火星", 6], ["木星", 13], ["水星", 20], ["金星", 27], ["土星", 30]],
82
+ "獅子座" => [["土星", 6], ["水星", 13], ["金星", 19], ["木星", 25], ["火星", 30]],
83
+ "處女座" => [["水星", 7], ["金星", 13], ["木星", 18], ["土星", 24], ["火星", 30]],
84
+ "天秤座" => [["土星", 6], ["金星", 11], ["木星", 19], ["水星", 24], ["火星", 30]],
85
+ "天蠍座" => [["火星", 6], ["木星", 14], ["金星", 21], ["水星", 27], ["土星", 30]],
86
+ "射手座" => [["木星", 8], ["金星", 14], ["水星", 19], ["土星", 25], ["火星", 30]],
87
+ "摩羯座" => [["金星", 6], ["水星", 12], ["木星", 19], ["火星", 25], ["土星", 30]],
88
+ "水瓶座" => [["土星", 6], ["水星", 12], ["金星", 20], ["木星", 25], ["火星", 30]],
89
+ "雙魚座" => [["金星", 8], ["木星", 14], ["水星", 20], ["火星", 26], ["土星", 30]],
90
+ }.freeze
91
+
92
+ TERM_SCHEMES = { egyptian: EGYPTIAN_TERMS, ptolemaic: PTOLEMAIC_TERMS }.freeze
93
+
94
+ # 外觀 (faces / decans), Chaldean order: the 36 faces cycle the seven
95
+ # planets in Chaldean sequence (土木火日金水月) starting at 火星 for 牡羊 0°.
96
+ # Generated rather than tabulated — face n = CHALDEAN[(⌊lon/10⌋) mod 7].
97
+ CHALDEAN_FROM_MARS = ["火星", "太陽", "金星", "水星", "月亮", "土星", "木星"].freeze
98
+
99
+ DIGNITY_SCORES = { domicile: 5, exaltation: 4, triplicity: 3, term: 2, face: 1 }.freeze
100
+
101
+ module_function
102
+
103
+ def sign_of(longitude)
104
+ Zodiac.sign_name(longitude)
105
+ end
106
+
107
+ def element_of(longitude)
108
+ ELEMENTS[((longitude % 360).floor / 30) % 4]
109
+ end
110
+
111
+ # Degree within the current sign (0-30).
112
+ def degree_in_sign(longitude)
113
+ (longitude % 360.0) % 30.0
114
+ end
115
+
116
+ # +5 ruler of the sign (traditional).
117
+ def domicile_ruler(longitude)
118
+ DOMICILE[sign_of(longitude)]
119
+ end
120
+
121
+ # −5 planet in detriment here = ruler of the opposite sign.
122
+ def detriment_ruler(longitude)
123
+ DOMICILE[sign_of(longitude + 180.0)]
124
+ end
125
+
126
+ # +4 exalted planet in this sign, or nil.
127
+ def exaltation_ruler(longitude)
128
+ entry = EXALTATION[sign_of(longitude)]
129
+ entry && entry[0]
130
+ end
131
+
132
+ # −4 planet in fall here = the planet exalted in the opposite sign, or nil.
133
+ def fall_ruler(longitude)
134
+ entry = EXALTATION[sign_of(longitude + 180.0)]
135
+ entry && entry[0]
136
+ end
137
+
138
+ # Triplicity rulers for the sign's element.
139
+ # Returns { day:, night:, participating: }.
140
+ def triplicity_rulers(longitude)
141
+ TRIPLICITY[element_of(longitude)]
142
+ end
143
+
144
+ # +3 triplicity ruler active for the given sect (:day / :night).
145
+ def triplicity_ruler(longitude, sect: :day)
146
+ trip = triplicity_rulers(longitude)
147
+ sect == :night ? trip[:night] : trip[:day]
148
+ end
149
+
150
+ # +2 term/bound ruler at this degree (Egyptian by default).
151
+ def term_ruler(longitude, scheme: :egyptian)
152
+ table = TERM_SCHEMES[scheme]
153
+ unless table
154
+ raise ArgumentError,
155
+ "unknown term scheme #{scheme.inspect} (available: #{TERM_SCHEMES.keys.inspect})"
156
+ end
157
+ deg = degree_in_sign(longitude)
158
+ table[sign_of(longitude)].each do |ruler, ending|
159
+ return ruler if deg < ending
160
+ end
161
+ table[sign_of(longitude)].last[0] # exactly 30.0 → last term
162
+ end
163
+
164
+ # +1 face/decan ruler at this degree (Chaldean order).
165
+ def face_ruler(longitude)
166
+ CHALDEAN_FROM_MARS[((longitude % 360).floor / 10) % 7]
167
+ end
168
+
169
+ # Total essential-dignity score a planet holds at a longitude (sum of the
170
+ # positive dignities it rules). Debilities are reported separately by .of
171
+ # and are not subtracted here.
172
+ def score(planet, longitude, sect: :day, terms: :egyptian)
173
+ total = 0
174
+ total += DIGNITY_SCORES[:domicile] if domicile_ruler(longitude) == planet
175
+ total += DIGNITY_SCORES[:exaltation] if exaltation_ruler(longitude) == planet
176
+ total += DIGNITY_SCORES[:triplicity] if triplicity_ruler(longitude, sect: sect) == planet
177
+ total += DIGNITY_SCORES[:term] if term_ruler(longitude, scheme: terms) == planet
178
+ total += DIGNITY_SCORES[:face] if face_ruler(longitude) == planet
179
+ total
180
+ end
181
+
182
+ # Full dignity/debility report for a planet at a longitude.
183
+ #
184
+ # { "dignities" => ["廟", "界"...], "debilities" => ["陷"...],
185
+ # "score" => 7 }
186
+ #
187
+ # Exaltation/fall are placement dignities of the sign (the exact degree is
188
+ # the peak but the whole sign confers the dignity), matching Lilly's table.
189
+ def of(planet, longitude, sect: :day, terms: :egyptian)
190
+ dignities = []
191
+ dignities << "廟" if domicile_ruler(longitude) == planet
192
+ dignities << "旺" if exaltation_ruler(longitude) == planet
193
+ dignities << "三分性" if triplicity_ruler(longitude, sect: sect) == planet
194
+ dignities << "界" if term_ruler(longitude, scheme: terms) == planet
195
+ dignities << "外觀" if face_ruler(longitude) == planet
196
+
197
+ debilities = []
198
+ debilities << "陷" if detriment_ruler(longitude) == planet
199
+ debilities << "弱" if fall_ruler(longitude) == planet
200
+
201
+ {
202
+ "planet" => planet,
203
+ "dignities" => dignities,
204
+ "debilities" => debilities,
205
+ "score" => score(planet, longitude, sect: sect, terms: terms),
206
+ }
207
+ end
208
+
209
+ # Almuten of a degree (界主星/度數勝利星): the traditional planet with the
210
+ # highest total dignity score at that longitude. Returns
211
+ # { "planet" => "火星", "score" => 7, "tied" => ["火星", ...] }
212
+ # `tied` lists every planet sharing the top score (usually one).
213
+ def almuten(longitude, sect: :day, terms: :egyptian)
214
+ scored = PLANETS.map { |p| [p, score(p, longitude, sect: sect, terms: terms)] }
215
+ top = scored.map { |_p, s| s }.max
216
+ winners = scored.select { |_p, s| s == top }.map { |p, _s| p }
217
+ { "planet" => winners.first, "score" => top, "tied" => winners }
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,64 @@
1
+ require_relative "zodiac"
2
+ require_relative "aspects"
3
+
4
+ module AstroChart
5
+ # Draconic chart (龍盤): the tropical zodiac rotated so the natal True North
6
+ # Node sits at 0° 牡羊. Every ecliptic longitude is shifted by the node's
7
+ # longitude, which re-signs the whole chart around the lunar nodal axis.
8
+ # Widely read as the "soul level" overlay of the ordinary tropical chart.
9
+ #
10
+ # draconic_longitude = (L − 北交點) mod 360
11
+ #
12
+ # Pure composition: this only transforms longitudes. Feed it the raw
13
+ # positions hash from `Planets.calculate_positions` (or any { name => L }
14
+ # hash) together with the natal True North Node longitude.
15
+ module Draconic
16
+ # positions: { "太陽" => 123.45, ... } tropical ecliptic longitudes
17
+ # north_node: True North Node longitude (degrees, 0-360)
18
+ #
19
+ # Returns { name => draconic_longitude } with 北交點 mapped to 0.0.
20
+ def self.positions(positions, north_node)
21
+ positions.transform_values { |lon| (lon - north_node) % 360.0 }
22
+ end
23
+
24
+ # Full draconic view: draconic longitude, zodiac sign and in-sign degree
25
+ # for every body, plus the aspects among them (major aspects only, via
26
+ # Aspects.calculate — draconic-to-draconic contacts are identical to the
27
+ # tropical ones, but the signs and houses they fall in differ).
28
+ #
29
+ # Returns:
30
+ # { "planets" => [ { "planet" =>, "zodiac" =>, "degree" =>,
31
+ # "total_degree" => }, ... ],
32
+ # "aspects" => [ { "a_planet" =>, "b_planet" =>,
33
+ # "aspect_type" =>, "orb" => }, ... ] } # sorted by orb
34
+ def self.chart(positions, north_node)
35
+ draconic = self.positions(positions, north_node)
36
+
37
+ planets = draconic.map do |name, lon|
38
+ {
39
+ "planet" => name,
40
+ "zodiac" => Zodiac.sign_name(lon),
41
+ "degree" => (lon % 30).round(4),
42
+ "total_degree" => lon.round(4),
43
+ }
44
+ end
45
+
46
+ names = draconic.keys
47
+ aspects = []
48
+ names.combination(2) do |a, b|
49
+ type, orb = Aspects.calculate(draconic[a], draconic[b])
50
+ next if type.nil?
51
+
52
+ aspects << {
53
+ "a_planet" => a,
54
+ "b_planet" => b,
55
+ "aspect_type" => type,
56
+ "orb" => orb,
57
+ }
58
+ end
59
+ aspects.sort_by! { |asp| asp["orb"] }
60
+
61
+ { "planets" => planets, "aspects" => aspects }
62
+ end
63
+ end
64
+ end