skymap 0.4.0 → 0.5.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +570 -31
  4. data/data/LICENSE-deep_sky_objects.txt +30 -0
  5. data/data/LICENSE-stars.txt +35 -0
  6. data/data/deep_sky_objects.csv +1742 -0
  7. data/data/stars.bin +0 -0
  8. data/lib/skymap/canvas.rb +12 -3
  9. data/lib/skymap/catalog/deep_sky_objects.rb +60 -0
  10. data/lib/skymap/catalog/star_records.rb +322 -0
  11. data/lib/skymap/catalog/stars.rb +75 -12
  12. data/lib/skymap/chart.rb +18 -4
  13. data/lib/skymap/cone.rb +21 -0
  14. data/lib/skymap/context.rb +326 -8
  15. data/lib/skymap/deep_sky_object.rb +140 -0
  16. data/lib/skymap/eyepiece.rb +22 -0
  17. data/lib/skymap/field.rb +20 -0
  18. data/lib/skymap/geometry.rb +47 -0
  19. data/lib/skymap/horizon.rb +33 -3
  20. data/lib/skymap/label_placement.rb +263 -0
  21. data/lib/skymap/layers/cardinal_directions.rb +2 -0
  22. data/lib/skymap/layers/constellation_lines.rb +6 -1
  23. data/lib/skymap/layers/constellation_names.rb +5 -2
  24. data/lib/skymap/layers/deep_sky_objects.rb +128 -0
  25. data/lib/skymap/layers/ecliptic.rb +1 -7
  26. data/lib/skymap/layers/field_circles.rb +169 -0
  27. data/lib/skymap/layers/grid.rb +153 -0
  28. data/lib/skymap/layers/moon.rb +22 -12
  29. data/lib/skymap/layers/planets.rb +4 -0
  30. data/lib/skymap/layers/scale.rb +75 -0
  31. data/lib/skymap/layers/star_names.rb +21 -4
  32. data/lib/skymap/layers/stars.rb +31 -8
  33. data/lib/skymap/layers/sun.rb +13 -9
  34. data/lib/skymap/magnitude_limit.rb +15 -0
  35. data/lib/skymap/print_size.rb +50 -0
  36. data/lib/skymap/projection/oblique_stereographic.rb +87 -0
  37. data/lib/skymap/projection/stereographic.rb +11 -0
  38. data/lib/skymap/renderer/caption.rb +35 -3
  39. data/lib/skymap/renderer/deep_sky_symbol.rb +67 -0
  40. data/lib/skymap/renderer/field_circle.rb +7 -0
  41. data/lib/skymap/renderer/grid.rb +7 -0
  42. data/lib/skymap/renderer/horizon_line.rb +7 -0
  43. data/lib/skymap/renderer/label.rb +3 -1
  44. data/lib/skymap/renderer/scale_bar.rb +7 -0
  45. data/lib/skymap/renderer/svg.rb +204 -15
  46. data/lib/skymap/setup.rb +48 -0
  47. data/lib/skymap/star.rb +127 -2
  48. data/lib/skymap/star_list.rb +25 -0
  49. data/lib/skymap/telescope.rb +17 -0
  50. data/lib/skymap/theme.rb +54 -20
  51. data/lib/skymap/version.rb +1 -1
  52. data/lib/skymap/view.rb +49 -0
  53. data/lib/skymap.rb +24 -0
  54. metadata +31 -2
  55. data/data/stars.csv +0 -9097
@@ -2,22 +2,55 @@
2
2
 
3
3
  module Skymap
4
4
  class Context
5
- attr_reader :observer, :instant, :canvas
5
+ MAX_ANGLE_FROM_VIEW_COSINE = Math.cos(150 * Math::PI / 180)
6
+ STAR_POSITION_TOLERANCE = 0.005
7
+ SHIFT_SLACK = 1e-6
8
+ SECONDS_PER_JULIAN_YEAR = Astronoby::Constants::SECONDS_PER_JULIAN_YEAR
9
+ ASTRONOMICAL_UNIT = 149_597_870_700.0
10
+ SPEED_OF_LIGHT = 299_792_458.0
11
+ PROPER_MOTION_ROUNDING = 1e-15
12
+ POSITION_ANGLE_STEP = 1e-4
13
+ PRECESSION_ARCSECONDS_PER_YEAR = 50.3
14
+ FASTEST_PROPER_MOTION_ARCSECONDS_PER_YEAR = 10.4
15
+ NUTATION_AND_ABERRATION_ARCSECONDS = 60.0
16
+ private_constant :PRECESSION_ARCSECONDS_PER_YEAR,
17
+ :FASTEST_PROPER_MOTION_ARCSECONDS_PER_YEAR,
18
+ :NUTATION_AND_ABERRATION_ARCSECONDS, :STAR_POSITION_TOLERANCE,
19
+ :SHIFT_SLACK, :SECONDS_PER_JULIAN_YEAR, :ASTRONOMICAL_UNIT,
20
+ :SPEED_OF_LIGHT, :PROPER_MOTION_ROUNDING, :POSITION_ANGLE_STEP
6
21
 
7
- def initialize(observer:, instant:, canvas:)
22
+ attr_reader :observer, :instant, :canvas, :view
23
+
24
+ def initialize(observer:, instant:, canvas:, view: nil)
8
25
  @observer = observer
9
26
  @instant = instant
10
27
  @canvas = canvas
28
+ @view = view
11
29
  @transformation = Astronoby::HorizontalTransformation.new(
12
30
  observer: observer,
13
31
  instant: instant
14
32
  )
15
33
  @horizontal = {}
34
+ @star_horizontals = {}.compare_by_identity
35
+ @catalog_horizontals = {}.compare_by_identity
36
+ @star_shifts = {}.compare_by_identity
16
37
  @projections = {}
17
38
  end
18
39
 
19
40
  def horizontal(star)
20
- horizontal_coordinates(star.equatorial_coordinates)
41
+ @star_horizontals[star] ||= star_horizontal(star)
42
+ end
43
+
44
+ def possibly_visible?(star, margin = 0)
45
+ catalog = catalog_horizontal(star)
46
+ shift = star_shift(star) + SHIFT_SLACK
47
+ return false if catalog.altitude.radians < -shift
48
+ return true if full_sky?
49
+
50
+ reach = view_reach(margin) + shift
51
+ return true if reach >= Math::PI
52
+
53
+ cosine_from_view(catalog) >= Math.cos(reach)
21
54
  end
22
55
 
23
56
  def horizontal_coordinates(coordinates)
@@ -27,7 +60,13 @@ module Skymap
27
60
  coordinates.epoch
28
61
  ]
29
62
 
30
- @horizontal[key] ||= @transformation.apply(coordinates)
63
+ @horizontal[key] ||= convert(coordinates)
64
+ end
65
+
66
+ def to_horizontal(position)
67
+ return position unless position.is_a?(Astronoby::Coordinates::Equatorial)
68
+
69
+ horizontal_coordinates(position)
31
70
  end
32
71
 
33
72
  def position(star)
@@ -39,13 +78,292 @@ module Skymap
39
78
  projection(radius).project(altitude: altitude, azimuth: azimuth)
40
79
  end
41
80
 
81
+ def span(angle, altitude:, azimuth:, radius: @canvas.radius)
82
+ projection(radius).scale_at(altitude: altitude, azimuth: azimuth) *
83
+ angle.radians
84
+ end
85
+
86
+ def rotation_at(coordinates, position_angle:)
87
+ center = project_horizontal(horizontal_coordinates(coordinates))
88
+ toward = project_horizontal(
89
+ convert(step_toward(coordinates, position_angle))
90
+ )
91
+
92
+ Astronoby::Angle.from_radians(
93
+ Math.atan2(toward.y - center.y, toward.x - center.x)
94
+ )
95
+ end
96
+
97
+ def scale_at_point(point, radius: @canvas.radius)
98
+ projection(radius).scale_at_point(point)
99
+ end
100
+
101
+ def disk_radius(default, angular_diameter:, altitude:, azimuth:)
102
+ return default if full_sky?
103
+
104
+ [
105
+ default,
106
+ span(angular_diameter, altitude: altitude, azimuth: azimuth) / 2
107
+ ].max
108
+ end
109
+
110
+ def full_sky?
111
+ @view.nil?
112
+ end
113
+
114
+ def cone
115
+ return if full_sky?
116
+
117
+ @cone ||= Cone.new(
118
+ center: view_equatorial,
119
+ radius: Astronoby::Angle.from_radians(
120
+ [view_reach(StarSize::MAX_RADIUS_RATIO * @canvas.radius) +
121
+ cone_drift, Math::PI].min
122
+ )
123
+ )
124
+ end
125
+
126
+ def in_view?(point, margin = 0)
127
+ return true if full_sky?
128
+
129
+ distance = Math.hypot(point.x - @canvas.center.x,
130
+ point.y - @canvas.center.y)
131
+ distance <= @canvas.radius + margin
132
+ end
133
+
134
+ def crosses_view?(points)
135
+ return true if full_sky?
136
+
137
+ return in_view?(points.first) if points.size == 1
138
+
139
+ points.each_cons(2).any? do |from, to|
140
+ distance_to_segment(@canvas.center, from, to) <= @canvas.radius
141
+ end
142
+ end
143
+
144
+ def reachable?(horizontal)
145
+ return true if full_sky?
146
+
147
+ cosine_from_view(horizontal) > MAX_ANGLE_FROM_VIEW_COSINE
148
+ end
149
+
42
150
  private
43
151
 
44
- def projection(radius)
45
- @projections[radius] ||= Projection::Stereographic.new(
46
- center: @canvas.center,
47
- radius: radius
152
+ def view_equatorial
153
+ center = @view.center
154
+ unless center.is_a?(Astronoby::Coordinates::Horizontal)
155
+ return Astronoby::Coordinates::Equatorial.new(
156
+ right_ascension: center.right_ascension,
157
+ declination: center.declination,
158
+ epoch: Astronoby::JulianDate::J2000
159
+ )
160
+ end
161
+
162
+ latitude = @observer.latitude
163
+ altitude = center.altitude
164
+ azimuth = center.azimuth
165
+ hour_angle = Math.atan2(
166
+ -azimuth.sin * altitude.cos,
167
+ altitude.sin * latitude.cos - altitude.cos * latitude.sin * azimuth.cos
168
+ )
169
+
170
+ Astronoby::Coordinates::Equatorial.new(
171
+ right_ascension: Astronoby::Angle.from_radians(
172
+ (Astronoby::Angle.from_hours(sidereal_time).radians - hour_angle) %
173
+ (2 * Math::PI)
174
+ ),
175
+ declination: Astronoby::Angle.from_radians(
176
+ Math.asin(
177
+ (altitude.sin * latitude.sin +
178
+ altitude.cos * latitude.cos * azimuth.cos).clamp(-1, 1)
179
+ )
180
+ ),
181
+ epoch: Astronoby::JulianDate::J2000
182
+ )
183
+ end
184
+
185
+ def cone_drift
186
+ arcseconds = elapsed_years * (
187
+ PRECESSION_ARCSECONDS_PER_YEAR +
188
+ FASTEST_PROPER_MOTION_ARCSECONDS_PER_YEAR
189
+ ) + NUTATION_AND_ABERRATION_ARCSECONDS
190
+
191
+ Astronoby::Angle.from_degree_arcseconds(arcseconds).radians
192
+ end
193
+
194
+ def project_horizontal(horizontal)
195
+ project(altitude: horizontal.altitude, azimuth: horizontal.azimuth)
196
+ end
197
+
198
+ def step_toward(coordinates, position_angle)
199
+ right_ascension = coordinates.right_ascension
200
+ declination = coordinates.declination
201
+ east = [-right_ascension.sin, right_ascension.cos, 0.0]
202
+ north = [
203
+ -declination.sin * right_ascension.cos,
204
+ -declination.sin * right_ascension.sin,
205
+ declination.cos
206
+ ]
207
+ point = [
208
+ declination.cos * right_ascension.cos,
209
+ declination.cos * right_ascension.sin,
210
+ declination.sin
211
+ ]
212
+ nearby = Geometry.normalize(
213
+ point.each_index.map do |axis|
214
+ point[axis] + POSITION_ANGLE_STEP * (
215
+ position_angle.sin * east[axis] + position_angle.cos * north[axis]
216
+ )
217
+ end
48
218
  )
219
+
220
+ Astronoby::Coordinates::Equatorial.new(
221
+ right_ascension: Astronoby::Angle.from_radians(
222
+ Math.atan2(nearby[1], nearby[0]) % (2 * Math::PI)
223
+ ),
224
+ declination: Astronoby::Angle.from_radians(
225
+ Math.asin(nearby[2].clamp(-1, 1))
226
+ ),
227
+ epoch: coordinates.epoch
228
+ )
229
+ end
230
+
231
+ def star_horizontal(star)
232
+ catalog = catalog_horizontal(star)
233
+ shift = star_shift(star)
234
+ return catalog if shift.zero?
235
+
236
+ scale = projection(@canvas.radius)
237
+ .scale_at(altitude: catalog.altitude, azimuth: catalog.azimuth)
238
+ return catalog if shift * scale < STAR_POSITION_TOLERANCE
239
+
240
+ convert(star.coordinates_at(@instant))
241
+ end
242
+
243
+ def catalog_horizontal(star)
244
+ @catalog_horizontals[star] ||= convert(star.equatorial_coordinates)
245
+ end
246
+
247
+ def star_shift(star)
248
+ @star_shifts[star] ||= compute_star_shift(star)
249
+ end
250
+
251
+ def compute_star_shift(star)
252
+ return 0.0 if star.proper_motion_ra.nil? || elapsed_years.zero?
253
+
254
+ rate = Math.hypot(
255
+ proper_motion_bound(star.proper_motion_ra),
256
+ proper_motion_bound(star.proper_motion_dec)
257
+ ) * SECONDS_PER_JULIAN_YEAR
258
+ velocity = (star.radial_velocity&.meters_per_second || 0).abs
259
+ seconds = elapsed_years * SECONDS_PER_JULIAN_YEAR
260
+ parallax = star.parallax&.radians || 0
261
+ slack = velocity / SPEED_OF_LIGHT +
262
+ velocity * seconds * parallax / ASTRONOMICAL_UNIT
263
+ return Float::INFINITY if slack >= 0.5
264
+
265
+ rate * elapsed_years / (1 - slack)
266
+ end
267
+
268
+ def proper_motion_bound(proper_motion)
269
+ rate = proper_motion.radians_per_second
270
+
271
+ rate.negative? ? -rate + PROPER_MOTION_ROUNDING : rate
272
+ end
273
+
274
+ def elapsed_years
275
+ @elapsed_years ||= (@instant.tt - Astronoby::JulianDate::J2000).abs /
276
+ Astronoby::Constants::DAYS_PER_JULIAN_YEAR
277
+ end
278
+
279
+ def view_reach(margin)
280
+ quarter = @view.field_of_view.radians / 4.0
281
+
282
+ 2 * Math.atan(Math.tan(quarter) * (1 + margin / @canvas.radius))
283
+ end
284
+
285
+ def cosine_from_view(horizontal)
286
+ east, north, up = view_direction
287
+
288
+ horizontal.altitude.cos *
289
+ (horizontal.azimuth.cos * east + horizontal.azimuth.sin * north) +
290
+ horizontal.altitude.sin * up
291
+ end
292
+
293
+ def convert(coordinates)
294
+ case coordinates.epoch
295
+ when Astronoby::JulianDate::J2000
296
+ @transformation.apply(coordinates)
297
+ when @instant.tt
298
+ horizontal_of_date(coordinates)
299
+ else
300
+ raise ArgumentError,
301
+ "coordinates must be at J2000 or at the date of the chart"
302
+ end
303
+ end
304
+
305
+ def horizontal_of_date(coordinates)
306
+ Astronoby::Coordinates::Equatorial.new(
307
+ right_ascension: coordinates.right_ascension,
308
+ declination: coordinates.declination,
309
+ hour_angle: Astronoby::Angle.from_hours(
310
+ (sidereal_time - coordinates.right_ascension.hours) %
311
+ Astronoby::Constants::HOURS_PER_DAY
312
+ ),
313
+ epoch: coordinates.epoch
314
+ ).to_horizontal(time: @instant.to_time, observer: @observer)
315
+ end
316
+
317
+ def sidereal_time
318
+ @sidereal_time ||= Astronoby::LocalApparentSiderealTime.from_utc(
319
+ @instant.to_time,
320
+ longitude: @observer.longitude
321
+ ).time
322
+ end
323
+
324
+ def projection(radius)
325
+ @projections[radius] ||= if full_sky?
326
+ Projection::Stereographic.new(center: @canvas.center, radius: radius)
327
+ else
328
+ center = view_center
329
+ Projection::ObliqueStereographic.new(
330
+ center: @canvas.center,
331
+ radius: radius,
332
+ altitude: center.altitude,
333
+ azimuth: center.azimuth,
334
+ field_of_view: @view.field_of_view,
335
+ orientation: @view.orientation,
336
+ pole: celestial_pole,
337
+ rotation: @view.rotation,
338
+ mirrored: @view.mirrored
339
+ )
340
+ end
341
+ end
342
+
343
+ def view_center
344
+ @view_center ||= to_horizontal(@view.center)
345
+ end
346
+
347
+ def celestial_pole
348
+ latitude = @observer.latitude
349
+
350
+ [latitude.cos, 0.0, latitude.sin]
351
+ end
352
+
353
+ def view_direction
354
+ @view_direction ||= Horizon.direction([view_center])
355
+ end
356
+
357
+ def distance_to_segment(point, from, to)
358
+ dx = to.x - from.x
359
+ dy = to.y - from.y
360
+ length = dx * dx + dy * dy
361
+ return Math.hypot(from.x - point.x, from.y - point.y) if length.zero?
362
+
363
+ along = (((point.x - from.x) * dx + (point.y - from.y) * dy) / length)
364
+ .clamp(0, 1)
365
+
366
+ Math.hypot(from.x + along * dx - point.x, from.y + along * dy - point.y)
49
367
  end
50
368
  end
51
369
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ DeepSkyObject = Data.define(
5
+ :designation,
6
+ :name,
7
+ :type,
8
+ :equatorial_coordinates,
9
+ :magnitude,
10
+ :magnitude_band,
11
+ :major_axis,
12
+ :minor_axis,
13
+ :position_angle
14
+ ) do
15
+ def initialize(
16
+ designation:,
17
+ type:,
18
+ equatorial_coordinates:,
19
+ name: nil,
20
+ magnitude: nil,
21
+ magnitude_band: nil,
22
+ major_axis: nil,
23
+ minor_axis: nil,
24
+ position_angle: nil
25
+ )
26
+ unless non_blank_string?(designation)
27
+ raise ArgumentError, "designation must be a non-blank String"
28
+ end
29
+
30
+ unless name.nil? || non_blank_string?(name)
31
+ raise ArgumentError, "name must be a non-blank String or nil"
32
+ end
33
+
34
+ unless DeepSkyObject::TYPES.include?(type)
35
+ raise ArgumentError,
36
+ "type must be one of #{DeepSkyObject::TYPES.join(", ")}"
37
+ end
38
+
39
+ unless equatorial_coordinates.is_a?(Astronoby::Coordinates::Equatorial) &&
40
+ equatorial_coordinates.epoch == Astronoby::JulianDate::J2000
41
+ raise ArgumentError,
42
+ "equatorial_coordinates must be Astronoby::Coordinates::Equatorial " \
43
+ "at J2000"
44
+ end
45
+
46
+ unless magnitude?(magnitude)
47
+ raise ArgumentError, "magnitude must be a finite Numeric or nil"
48
+ end
49
+
50
+ unless DeepSkyObject::MAGNITUDE_BANDS.include?(magnitude_band) ||
51
+ magnitude_band.nil?
52
+ raise ArgumentError,
53
+ "magnitude_band must be one of " \
54
+ "#{DeepSkyObject::MAGNITUDE_BANDS.join(", ")} or nil"
55
+ end
56
+
57
+ if magnitude_band && magnitude.nil?
58
+ raise ArgumentError, "magnitude_band must come with a magnitude"
59
+ end
60
+
61
+ unless axis?(major_axis)
62
+ raise ArgumentError,
63
+ "major_axis must be an Astronoby::Angle above 0 or nil"
64
+ end
65
+
66
+ unless axis?(minor_axis)
67
+ raise ArgumentError,
68
+ "minor_axis must be an Astronoby::Angle above 0 or nil"
69
+ end
70
+
71
+ if minor_axis && major_axis.nil?
72
+ raise ArgumentError, "minor_axis must come with a major_axis"
73
+ end
74
+
75
+ if minor_axis && minor_axis > major_axis
76
+ raise ArgumentError, "minor_axis must not be larger than major_axis"
77
+ end
78
+
79
+ unless position_angle?(position_angle)
80
+ raise ArgumentError, "position_angle must be an Astronoby::Angle or nil"
81
+ end
82
+
83
+ super
84
+ end
85
+
86
+ def messier?
87
+ designation.match?(/\AM\d+\z/)
88
+ end
89
+
90
+ alias_method :given_minor_axis, :minor_axis
91
+ private :given_minor_axis
92
+
93
+ def minor_axis
94
+ given_minor_axis || major_axis
95
+ end
96
+
97
+ alias_method :given_magnitude_band, :magnitude_band
98
+ private :given_magnitude_band
99
+
100
+ def magnitude_band
101
+ given_magnitude_band || (:visual unless magnitude.nil?)
102
+ end
103
+
104
+ private
105
+
106
+ def non_blank_string?(value)
107
+ value.is_a?(String) && !value.strip.empty?
108
+ end
109
+
110
+ def magnitude?(value)
111
+ value.nil? ||
112
+ (value.is_a?(Numeric) && value.real? && value.finite?)
113
+ end
114
+
115
+ def position_angle?(value)
116
+ value.nil? ||
117
+ (value.is_a?(Astronoby::Angle) && value.radians.finite?)
118
+ end
119
+
120
+ def axis?(value)
121
+ value.nil? ||
122
+ (value.is_a?(Astronoby::Angle) &&
123
+ value.radians.finite? && value.positive?)
124
+ end
125
+ end
126
+
127
+ DeepSkyObject::MAGNITUDE_BANDS = %i[visual blue].freeze
128
+
129
+ DeepSkyObject::TYPES = %i[
130
+ galaxy
131
+ open_cluster
132
+ globular_cluster
133
+ planetary_nebula
134
+ nebula
135
+ supernova_remnant
136
+ asterism
137
+ double_star
138
+ other
139
+ ].freeze
140
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ Eyepiece = Data.define(:focal_length_mm, :apparent_field_of_view) do
5
+ def initialize(focal_length_mm:, apparent_field_of_view:)
6
+ unless focal_length_mm.is_a?(Numeric) && focal_length_mm.real? &&
7
+ focal_length_mm.finite? && focal_length_mm > 0
8
+ raise ArgumentError, "focal_length_mm must be a number above 0"
9
+ end
10
+
11
+ unless apparent_field_of_view.is_a?(Astronoby::Angle) &&
12
+ apparent_field_of_view.positive? &&
13
+ apparent_field_of_view.radians <= Math::PI
14
+ raise ArgumentError,
15
+ "apparent_field_of_view must be an Astronoby::Angle " \
16
+ "above 0 and up to 180 degrees"
17
+ end
18
+
19
+ super
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ Field = Data.define(:true_field, :label) do
5
+ def initialize(true_field:, label:)
6
+ unless true_field.is_a?(Astronoby::Angle) && true_field.positive? &&
7
+ true_field.radians <= Math::PI
8
+ raise ArgumentError,
9
+ "true_field must be an Astronoby::Angle " \
10
+ "above 0 and up to 180 degrees"
11
+ end
12
+
13
+ unless label.is_a?(String) && !label.strip.empty?
14
+ raise ArgumentError, "label must be a string that is not blank"
15
+ end
16
+
17
+ super
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Geometry
5
+ module_function
6
+
7
+ def dot(a, b)
8
+ a.zip(b).sum { |x, y| x * y }
9
+ end
10
+
11
+ def direction(right_ascension, declination)
12
+ [
13
+ Math.cos(declination) * Math.cos(right_ascension),
14
+ Math.cos(declination) * Math.sin(right_ascension),
15
+ Math.sin(declination)
16
+ ]
17
+ end
18
+
19
+ def cross(a, b)
20
+ [
21
+ a[1] * b[2] - a[2] * b[1],
22
+ a[2] * b[0] - a[0] * b[2],
23
+ a[0] * b[1] - a[1] * b[0]
24
+ ]
25
+ end
26
+
27
+ def normalize(vector)
28
+ length = Math.sqrt(dot(vector, vector))
29
+
30
+ vector.map { |component| component / length }
31
+ end
32
+
33
+ def away_from(reference, forward)
34
+ along = dot(reference, forward)
35
+
36
+ reference.zip(forward).map { |r, f| r - along * f }
37
+ end
38
+
39
+ def frame(forward)
40
+ axis = (0..2).min_by { |index| forward[index].abs }
41
+ reference = Array.new(3) { |index| (index == axis) ? 1.0 : 0.0 }
42
+ up = normalize(away_from(reference, forward))
43
+
44
+ [forward, cross(up, forward), up]
45
+ end
46
+ end
47
+ end
@@ -38,13 +38,43 @@ module Skymap
38
38
  crossing_point(context, from, to) + [position(context, to)]
39
39
  end
40
40
 
41
+ runs_of(context, path)
42
+ end
43
+
44
+ def line(context)
45
+ azimuths = (0...360).map do |degrees|
46
+ Astronoby::Coordinates::Horizontal.new(
47
+ altitude: Astronoby::Angle.zero,
48
+ azimuth: Astronoby::Angle.from_degrees(degrees),
49
+ observer: context.observer
50
+ )
51
+ end
52
+ path = (azimuths + azimuths.first(1)).map do |horizontal|
53
+ next unless context.reachable?(horizontal)
54
+
55
+ context.project(
56
+ altitude: horizontal.altitude,
57
+ azimuth: horizontal.azimuth
58
+ )
59
+ end
60
+
61
+ joined(runs_of(context, path))
62
+ end
63
+
64
+ def joined(runs)
65
+ return runs unless runs.size > 1 && runs.first.first == runs.last.last
66
+
67
+ [runs.last + runs.first.drop(1)] + runs[1...-1]
68
+ end
69
+
70
+ def runs_of(context, path)
41
71
  path.chunk { |point| !point.nil? }.filter_map do |visible, points|
42
- points if visible && points.size > 1
72
+ points if visible && points.size > 1 && context.crosses_view?(points)
43
73
  end
44
74
  end
45
75
 
46
76
  def position(context, horizontal)
47
- return if below?(horizontal)
77
+ return if below?(horizontal) || !context.reachable?(horizontal)
48
78
 
49
79
  context.project(
50
80
  altitude: horizontal.altitude,
@@ -68,6 +98,6 @@ module Skymap
68
98
  horizontal.altitude.negative?
69
99
  end
70
100
 
71
- private_class_method :position, :crossing_point, :below?
101
+ private_class_method :position, :below?
72
102
  end
73
103
  end