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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -1
- data/README.md +570 -31
- data/data/LICENSE-deep_sky_objects.txt +30 -0
- data/data/LICENSE-stars.txt +35 -0
- data/data/deep_sky_objects.csv +1742 -0
- data/data/stars.bin +0 -0
- data/lib/skymap/canvas.rb +12 -3
- data/lib/skymap/catalog/deep_sky_objects.rb +60 -0
- data/lib/skymap/catalog/star_records.rb +322 -0
- data/lib/skymap/catalog/stars.rb +75 -12
- data/lib/skymap/chart.rb +18 -4
- data/lib/skymap/cone.rb +21 -0
- data/lib/skymap/context.rb +326 -8
- data/lib/skymap/deep_sky_object.rb +140 -0
- data/lib/skymap/eyepiece.rb +22 -0
- data/lib/skymap/field.rb +20 -0
- data/lib/skymap/geometry.rb +47 -0
- data/lib/skymap/horizon.rb +33 -3
- data/lib/skymap/label_placement.rb +263 -0
- data/lib/skymap/layers/cardinal_directions.rb +2 -0
- data/lib/skymap/layers/constellation_lines.rb +6 -1
- data/lib/skymap/layers/constellation_names.rb +5 -2
- data/lib/skymap/layers/deep_sky_objects.rb +128 -0
- data/lib/skymap/layers/ecliptic.rb +1 -7
- data/lib/skymap/layers/field_circles.rb +169 -0
- data/lib/skymap/layers/grid.rb +153 -0
- data/lib/skymap/layers/moon.rb +22 -12
- data/lib/skymap/layers/planets.rb +4 -0
- data/lib/skymap/layers/scale.rb +75 -0
- data/lib/skymap/layers/star_names.rb +21 -4
- data/lib/skymap/layers/stars.rb +31 -8
- data/lib/skymap/layers/sun.rb +13 -9
- data/lib/skymap/magnitude_limit.rb +15 -0
- data/lib/skymap/print_size.rb +50 -0
- data/lib/skymap/projection/oblique_stereographic.rb +87 -0
- data/lib/skymap/projection/stereographic.rb +11 -0
- data/lib/skymap/renderer/caption.rb +35 -3
- data/lib/skymap/renderer/deep_sky_symbol.rb +67 -0
- data/lib/skymap/renderer/field_circle.rb +7 -0
- data/lib/skymap/renderer/grid.rb +7 -0
- data/lib/skymap/renderer/horizon_line.rb +7 -0
- data/lib/skymap/renderer/label.rb +3 -1
- data/lib/skymap/renderer/scale_bar.rb +7 -0
- data/lib/skymap/renderer/svg.rb +204 -15
- data/lib/skymap/setup.rb +48 -0
- data/lib/skymap/star.rb +127 -2
- data/lib/skymap/star_list.rb +25 -0
- data/lib/skymap/telescope.rb +17 -0
- data/lib/skymap/theme.rb +54 -20
- data/lib/skymap/version.rb +1 -1
- data/lib/skymap/view.rb +49 -0
- data/lib/skymap.rb +24 -0
- metadata +31 -2
- data/data/stars.csv +0 -9097
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
class LabelPlacement
|
|
5
|
+
RANKS = %i[field planet star deep_sky].freeze
|
|
6
|
+
MARGIN_RATIO = 0.25
|
|
7
|
+
GAP_RATIO = 0.5
|
|
8
|
+
DOT_RATIO = 0.25
|
|
9
|
+
CELL_RATIO = 4
|
|
10
|
+
DIAGONAL = Math.sqrt(0.5)
|
|
11
|
+
DIRECTIONS = [
|
|
12
|
+
[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [-1, 1], [1, -1], [-1, -1]
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
Box = Data.define(:left, :top, :right, :bottom) do
|
|
16
|
+
def intersects?(other)
|
|
17
|
+
left < other.right && other.left < right &&
|
|
18
|
+
top < other.bottom && other.top < bottom
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def bounds
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def blocks?(other)
|
|
26
|
+
intersects?(other)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reaches?(x, y, radius)
|
|
30
|
+
dx = [left - x, 0, x - right].max
|
|
31
|
+
dy = [top - y, 0, y - bottom].max
|
|
32
|
+
|
|
33
|
+
dx * dx + dy * dy < radius * radius
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def crossed_by?(from, to)
|
|
37
|
+
dx = to.x - from.x
|
|
38
|
+
dy = to.y - from.y
|
|
39
|
+
entering = 0.0
|
|
40
|
+
leaving = 1.0
|
|
41
|
+
|
|
42
|
+
[
|
|
43
|
+
[-dx, from.x - left], [dx, right - from.x],
|
|
44
|
+
[-dy, from.y - top], [dy, bottom - from.y]
|
|
45
|
+
].each do |direction, distance|
|
|
46
|
+
if direction.zero?
|
|
47
|
+
return false if distance.negative?
|
|
48
|
+
else
|
|
49
|
+
ratio = distance / direction
|
|
50
|
+
if direction.negative?
|
|
51
|
+
entering = ratio if ratio > entering
|
|
52
|
+
elsif ratio < leaving
|
|
53
|
+
leaving = ratio
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
entering <= leaving
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Disc = Data.define(:center, :radius) do
|
|
63
|
+
def bounds
|
|
64
|
+
Box.new(
|
|
65
|
+
left: center.x - radius, top: center.y - radius,
|
|
66
|
+
right: center.x + radius, bottom: center.y + radius
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def blocks?(box)
|
|
71
|
+
box.reaches?(center.x, center.y, radius)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Segment = Data.define(:from, :to, :bounds) do
|
|
76
|
+
def self.between(from, to)
|
|
77
|
+
new(
|
|
78
|
+
from: from,
|
|
79
|
+
to: to,
|
|
80
|
+
bounds: Box.new(
|
|
81
|
+
left: [from.x, to.x].min, top: [from.y, to.y].min,
|
|
82
|
+
right: [from.x, to.x].max, bottom: [from.y, to.y].max
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def blocks?(box)
|
|
88
|
+
bounds.intersects?(box) && box.crossed_by?(from, to)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class Index
|
|
93
|
+
def initialize(cell, limit)
|
|
94
|
+
@cell = cell
|
|
95
|
+
@last = (limit / cell).floor
|
|
96
|
+
@cells = {}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def add(blocker)
|
|
100
|
+
cells_of(blocker.bounds) { |cell| (@cells[cell] ||= []) << blocker }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def blocked?(box)
|
|
104
|
+
cells_of(box) do |cell|
|
|
105
|
+
blockers = @cells[cell]
|
|
106
|
+
return true if blockers&.any? { |blocker| blocker.blocks?(box) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def cells_of(box)
|
|
115
|
+
rows = span(box.top, box.bottom)
|
|
116
|
+
|
|
117
|
+
span(box.left, box.right).each do |column|
|
|
118
|
+
rows.each { |row| yield [column, row] }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def span(from, to)
|
|
123
|
+
[(from / @cell).floor, 0].max..[(to / @cell).floor, @last].min
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
private_constant :Box, :Disc, :Segment, :Index
|
|
127
|
+
|
|
128
|
+
def initialize(canvas:)
|
|
129
|
+
@canvas = canvas
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def arrange(elements)
|
|
133
|
+
captions = elements.select { |element| anchored?(element) }
|
|
134
|
+
return elements if captions.empty?
|
|
135
|
+
|
|
136
|
+
text_size = captions.map(&:size).min
|
|
137
|
+
index = Index.new(CELL_RATIO * text_size, @canvas.size)
|
|
138
|
+
fixed_boxes(elements).each { |box| index.add(box) }
|
|
139
|
+
obstacles(elements, text_size).each { |obstacle| index.add(obstacle) }
|
|
140
|
+
placed = {}.compare_by_identity
|
|
141
|
+
|
|
142
|
+
by_priority(captions).each do |caption|
|
|
143
|
+
position = free_position(caption, index)
|
|
144
|
+
next unless position
|
|
145
|
+
|
|
146
|
+
index.add(box(caption, position))
|
|
147
|
+
placed[caption] = caption.with(position: position)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
elements.filter_map do |element|
|
|
151
|
+
anchored?(element) ? placed[element] : element
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def anchored?(element)
|
|
158
|
+
element.is_a?(Renderer::Caption) && !element.anchor.nil?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def by_priority(captions)
|
|
162
|
+
captions.each_with_index
|
|
163
|
+
.sort_by { |caption, index| [rank(caption.kind), index] }
|
|
164
|
+
.map(&:first)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def rank(kind)
|
|
168
|
+
RANKS.index(kind) || RANKS.size
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def fixed_boxes(elements)
|
|
172
|
+
elements.filter_map do |element|
|
|
173
|
+
next unless text?(element) && !anchored?(element)
|
|
174
|
+
|
|
175
|
+
box(element, element.position)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def text?(element)
|
|
180
|
+
element.is_a?(Renderer::Caption) || element.is_a?(Renderer::Label)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def obstacles(elements, text_size)
|
|
184
|
+
bright = DOT_RATIO * text_size
|
|
185
|
+
|
|
186
|
+
elements.flat_map do |element|
|
|
187
|
+
case element
|
|
188
|
+
when Renderer::DeepSkySymbol then symbol(element)
|
|
189
|
+
when Renderer::Planet, Renderer::Moon, Renderer::Sun
|
|
190
|
+
[Disc.new(center: element.center, radius: element.radius)]
|
|
191
|
+
when Renderer::Dot
|
|
192
|
+
next [] if element.radius < bright
|
|
193
|
+
|
|
194
|
+
[Disc.new(center: element.center, radius: element.radius)]
|
|
195
|
+
else
|
|
196
|
+
[]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def symbol(symbol)
|
|
202
|
+
unless symbol.hollow?
|
|
203
|
+
return [Disc.new(center: symbol.center, radius: symbol.semi_major)]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
points = symbol.outline
|
|
207
|
+
|
|
208
|
+
points.zip(points.rotate).map { |from, to| Segment.between(from, to) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def free_position(caption, index)
|
|
212
|
+
candidates(caption).find do |position, check_sky|
|
|
213
|
+
!index.blocked?(box(caption, position)) &&
|
|
214
|
+
(!check_sky || inside_sky?(caption, position))
|
|
215
|
+
end&.first
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def candidates(caption)
|
|
219
|
+
around = DIRECTIONS.map { |direction| [at(caption, direction), true] }
|
|
220
|
+
|
|
221
|
+
[[caption.position, false]] + around
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def at(caption, direction)
|
|
225
|
+
anchor = caption.anchor
|
|
226
|
+
size = caption.size
|
|
227
|
+
ux, uy = direction
|
|
228
|
+
reach_x = uy.zero? ? caption.clearance : caption.clearance * DIAGONAL
|
|
229
|
+
reach_y = ux.zero? ? caption.clearance : caption.clearance * DIAGONAL
|
|
230
|
+
gap = GAP_RATIO * size
|
|
231
|
+
|
|
232
|
+
Point.new(
|
|
233
|
+
x: anchor.x + ux * (reach_x + gap + caption.width / 2),
|
|
234
|
+
y: anchor.y + uy * (reach_y + size)
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def inside_sky?(caption, position)
|
|
239
|
+
half_width = caption.width / 2
|
|
240
|
+
half_height = caption.size / 2
|
|
241
|
+
|
|
242
|
+
[-1, 1].product([-1, 1]).all? do |sx, sy|
|
|
243
|
+
Math.hypot(
|
|
244
|
+
position.x + sx * half_width - @canvas.center.x,
|
|
245
|
+
position.y + sy * half_height - @canvas.center.y
|
|
246
|
+
) <= @canvas.radius
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def box(text, position)
|
|
251
|
+
margin = MARGIN_RATIO * text.size
|
|
252
|
+
half_width = text.width / 2 + margin
|
|
253
|
+
half_height = text.size / 2 + margin
|
|
254
|
+
|
|
255
|
+
Box.new(
|
|
256
|
+
left: position.x - half_width,
|
|
257
|
+
top: position.y - half_height,
|
|
258
|
+
right: position.x + half_width,
|
|
259
|
+
bottom: position.y + half_height
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -11,11 +11,14 @@ module Skymap
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def elements(context)
|
|
14
|
-
stars_by_hr =
|
|
14
|
+
stars_by_hr = StarList.wrap(@stars).by_hr
|
|
15
15
|
|
|
16
16
|
@lines.filter_map do |line|
|
|
17
17
|
ends = [line.from, line.to].map { |hrs| stars_by_hr.values_at(*hrs) }
|
|
18
18
|
next if ends.flatten.include?(nil)
|
|
19
|
+
next unless ends.flatten.all? { |star|
|
|
20
|
+
context.reachable?(context.horizontal(star))
|
|
21
|
+
}
|
|
19
22
|
|
|
20
23
|
directions = ends.map do |end_stars|
|
|
21
24
|
Horizon.direction(
|
|
@@ -34,6 +37,8 @@ module Skymap
|
|
|
34
37
|
Point.midpoint(end_stars.map { |star| context.position(star) })
|
|
35
38
|
end
|
|
36
39
|
end
|
|
40
|
+
next unless context.crosses_view?([from, to])
|
|
41
|
+
|
|
37
42
|
Renderer::Line.new(
|
|
38
43
|
from: from,
|
|
39
44
|
to: to,
|
|
@@ -10,7 +10,7 @@ module Skymap
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def elements(context)
|
|
13
|
-
stars_by_hr =
|
|
13
|
+
stars_by_hr = StarList.wrap(@stars).by_hr
|
|
14
14
|
|
|
15
15
|
@lines.group_by(&:constellation).filter_map do |abbreviation, lines|
|
|
16
16
|
name = @names[abbreviation]
|
|
@@ -23,8 +23,11 @@ module Skymap
|
|
|
23
23
|
next if risen.sum(&:size) * 2 <= parts.sum(&:size)
|
|
24
24
|
|
|
25
25
|
biggest = risen.max_by(&:size)
|
|
26
|
+
position = Point.midpoint(biggest.map { |s| context.position(s) })
|
|
27
|
+
next unless context.in_view?(position)
|
|
28
|
+
|
|
26
29
|
Renderer::Caption.new(
|
|
27
|
-
position:
|
|
30
|
+
position: position,
|
|
28
31
|
text: name,
|
|
29
32
|
size: Renderer::Caption.size_for(context.canvas),
|
|
30
33
|
kind: :constellation
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
module Layers
|
|
5
|
+
class DeepSkyObjects
|
|
6
|
+
DEFAULT_MAGNITUDE_LIMIT = 11
|
|
7
|
+
MIN_RADIUS_RATIO = 0.012
|
|
8
|
+
LINE_WIDTH_RATIO = 0.003
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
objects:,
|
|
12
|
+
magnitude_limit: DEFAULT_MAGNITUDE_LIMIT,
|
|
13
|
+
labels: false
|
|
14
|
+
)
|
|
15
|
+
MagnitudeLimit.check(magnitude_limit)
|
|
16
|
+
|
|
17
|
+
@objects = objects
|
|
18
|
+
@magnitude_limit = magnitude_limit
|
|
19
|
+
@labels = labels
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def elements(context)
|
|
23
|
+
radius = context.canvas.radius
|
|
24
|
+
minimum = MIN_RADIUS_RATIO * radius
|
|
25
|
+
width = LINE_WIDTH_RATIO * radius
|
|
26
|
+
|
|
27
|
+
brightest_first.flat_map do |object|
|
|
28
|
+
draw(context, object, minimum, width)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def brightest_first
|
|
35
|
+
@objects.each_with_index
|
|
36
|
+
.reject { |object, _| too_faint?(object) }
|
|
37
|
+
.sort_by { |object, index| [object.magnitude || Float::INFINITY, index] }
|
|
38
|
+
.map(&:first)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def too_faint?(object)
|
|
42
|
+
object.magnitude && object.magnitude > @magnitude_limit
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def draw(context, object, minimum, width)
|
|
46
|
+
coordinates = object.equatorial_coordinates
|
|
47
|
+
horizontal = context.horizontal_coordinates(coordinates)
|
|
48
|
+
return [] if horizontal.altitude.negative?
|
|
49
|
+
|
|
50
|
+
center = context.project(
|
|
51
|
+
altitude: horizontal.altitude,
|
|
52
|
+
azimuth: horizontal.azimuth
|
|
53
|
+
)
|
|
54
|
+
semi_major, semi_minor = semi_axes(context, object, horizontal, minimum)
|
|
55
|
+
return [] unless context.in_view?(center, semi_major)
|
|
56
|
+
|
|
57
|
+
drawn = Renderer::DeepSkySymbol.new(
|
|
58
|
+
center: center,
|
|
59
|
+
kind: object.type,
|
|
60
|
+
semi_major: semi_major,
|
|
61
|
+
semi_minor: semi_minor,
|
|
62
|
+
rotation: rotation(context, object),
|
|
63
|
+
width: width
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
[drawn, caption(context, object, center, semi_major)].compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def semi_axes(context, object, horizontal, minimum)
|
|
70
|
+
major, minor = axes(object)
|
|
71
|
+
return [minimum, minimum] unless major
|
|
72
|
+
|
|
73
|
+
pixels = ->(angle) {
|
|
74
|
+
context.span(
|
|
75
|
+
angle,
|
|
76
|
+
altitude: horizontal.altitude,
|
|
77
|
+
azimuth: horizontal.azimuth
|
|
78
|
+
) / 2
|
|
79
|
+
}
|
|
80
|
+
semi_major = pixels.call(major)
|
|
81
|
+
semi_minor = pixels.call(minor)
|
|
82
|
+
growth = [minimum / semi_major, 1].max
|
|
83
|
+
|
|
84
|
+
[semi_major * growth, semi_minor * growth]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def axes(object)
|
|
88
|
+
major = object.major_axis
|
|
89
|
+
return unless major
|
|
90
|
+
|
|
91
|
+
minor = object.minor_axis
|
|
92
|
+
return [major, minor] if round?(object) || object.position_angle
|
|
93
|
+
|
|
94
|
+
equal_area = Astronoby::Angle.from_radians(
|
|
95
|
+
Math.sqrt(major.radians * minor.radians)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
[equal_area, equal_area]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def round?(object)
|
|
102
|
+
object.major_axis == object.minor_axis
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def rotation(context, object)
|
|
106
|
+
angle = object.position_angle
|
|
107
|
+
return 0.0 if angle.nil? || round?(object)
|
|
108
|
+
|
|
109
|
+
context.rotation_at(
|
|
110
|
+
object.equatorial_coordinates,
|
|
111
|
+
position_angle: angle
|
|
112
|
+
).degrees
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def caption(context, object, center, radius)
|
|
116
|
+
return unless @labels
|
|
117
|
+
|
|
118
|
+
Renderer::Caption.beside(
|
|
119
|
+
center: center,
|
|
120
|
+
radius: radius,
|
|
121
|
+
canvas: context.canvas,
|
|
122
|
+
text: object.designation,
|
|
123
|
+
kind: :deep_sky
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -17,7 +17,7 @@ module Skymap
|
|
|
17
17
|
def elements(context)
|
|
18
18
|
width = LINE_WIDTH_RATIO * context.canvas.radius
|
|
19
19
|
|
|
20
|
-
joined(Horizon.visible_runs(context, @points)).map do |points|
|
|
20
|
+
Horizon.joined(Horizon.visible_runs(context, @points)).map do |points|
|
|
21
21
|
Renderer::Ecliptic.new(points: points, width: width)
|
|
22
22
|
end
|
|
23
23
|
end
|
|
@@ -33,12 +33,6 @@ module Skymap
|
|
|
33
33
|
epoch: Astronoby::JulianDate::J2000
|
|
34
34
|
)
|
|
35
35
|
end
|
|
36
|
-
|
|
37
|
-
def joined(runs)
|
|
38
|
-
return runs unless runs.size > 1 && runs.first.first == runs.last.last
|
|
39
|
-
|
|
40
|
-
[runs.last + runs.first.drop(1)] + runs[1...-1]
|
|
41
|
-
end
|
|
42
36
|
end
|
|
43
37
|
end
|
|
44
38
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
module Layers
|
|
5
|
+
class FieldCircles
|
|
6
|
+
LINE_WIDTH_RATIO = 0.004
|
|
7
|
+
MAX_DEVIATION = 0.1
|
|
8
|
+
MIN_SAMPLES = 16
|
|
9
|
+
MAX_SAMPLES = 360
|
|
10
|
+
|
|
11
|
+
def initialize(fields:, center: nil, labels: false)
|
|
12
|
+
check_fields(fields)
|
|
13
|
+
check_center(center)
|
|
14
|
+
|
|
15
|
+
@fields = fields
|
|
16
|
+
@center = center
|
|
17
|
+
@labels = labels
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def elements(context)
|
|
21
|
+
center = center_in(context)
|
|
22
|
+
return [] unless center
|
|
23
|
+
|
|
24
|
+
frame = frame_around(center)
|
|
25
|
+
width = LINE_WIDTH_RATIO * context.canvas.radius
|
|
26
|
+
|
|
27
|
+
@fields.flat_map do |field|
|
|
28
|
+
circle(context, center, frame, field, width)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def circle(context, center, frame, field, width)
|
|
35
|
+
radius = Astronoby::Angle.from_radians(field.true_field.radians / 2.0)
|
|
36
|
+
samples = sample_count(context, center, radius)
|
|
37
|
+
path = path_of(context, ring(context, frame, radius, samples))
|
|
38
|
+
runs = Horizon.joined(Horizon.runs_of(context, path))
|
|
39
|
+
return [] if runs.empty?
|
|
40
|
+
|
|
41
|
+
circles = runs.map do |points|
|
|
42
|
+
Renderer::FieldCircle.new(points: points, width: width)
|
|
43
|
+
end
|
|
44
|
+
circles + caption(context, field, path.compact)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def sample_count(context, center, radius)
|
|
48
|
+
pixels = context.span(
|
|
49
|
+
radius,
|
|
50
|
+
altitude: center.altitude,
|
|
51
|
+
azimuth: center.azimuth
|
|
52
|
+
)
|
|
53
|
+
step = Math.acos([1 - MAX_DEVIATION / pixels, -1].max)
|
|
54
|
+
return MAX_SAMPLES unless step.positive?
|
|
55
|
+
|
|
56
|
+
samples = (Math::PI / step).ceil.clamp(MIN_SAMPLES, MAX_SAMPLES)
|
|
57
|
+
|
|
58
|
+
samples + -samples % 4
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ring(context, frame, radius, samples)
|
|
62
|
+
forward, side, up = frame
|
|
63
|
+
points = (0...samples).map do |index|
|
|
64
|
+
angle = 2 * Math::PI * index / samples
|
|
65
|
+
direction = (0..2).map do |axis|
|
|
66
|
+
radius.cos * forward[axis] +
|
|
67
|
+
radius.sin * (Math.cos(angle) * side[axis] +
|
|
68
|
+
Math.sin(angle) * up[axis])
|
|
69
|
+
end
|
|
70
|
+
Astronoby::Coordinates::Horizontal.new(
|
|
71
|
+
altitude: Astronoby::Angle.from_radians(
|
|
72
|
+
Math.asin(direction[2].clamp(-1, 1))
|
|
73
|
+
),
|
|
74
|
+
azimuth: Astronoby::Angle.from_radians(
|
|
75
|
+
Math.atan2(direction[1], direction[0])
|
|
76
|
+
),
|
|
77
|
+
observer: context.observer
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
points + points.first(1)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def path_of(context, horizontals)
|
|
85
|
+
[position(context, horizontals.first)] +
|
|
86
|
+
horizontals.each_cons(2).flat_map do |from, to|
|
|
87
|
+
crossing(context, from, to) + [position(context, to)]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def crossing(context, from, to)
|
|
92
|
+
return [] unless context.full_sky?
|
|
93
|
+
|
|
94
|
+
Horizon.crossing_point(context, from, to)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def position(context, horizontal)
|
|
98
|
+
return if context.full_sky? && horizontal.altitude.negative?
|
|
99
|
+
return unless context.reachable?(horizontal)
|
|
100
|
+
|
|
101
|
+
context.project(
|
|
102
|
+
altitude: horizontal.altitude,
|
|
103
|
+
azimuth: horizontal.azimuth
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def caption(context, field, points)
|
|
108
|
+
return [] unless @labels
|
|
109
|
+
|
|
110
|
+
xs, ys = points.map(&:x), points.map(&:y)
|
|
111
|
+
caption = Renderer::Caption.beside(
|
|
112
|
+
center: Point.new(
|
|
113
|
+
x: (xs.min + xs.max) / 2.0,
|
|
114
|
+
y: (ys.min + ys.max) / 2.0
|
|
115
|
+
),
|
|
116
|
+
radius: (ys.max - ys.min) / 2.0,
|
|
117
|
+
canvas: context.canvas,
|
|
118
|
+
text: field.label,
|
|
119
|
+
kind: :field
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
context.in_view?(caption.position) ? [caption] : []
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def center_in(context)
|
|
126
|
+
center = @center || context.view&.center
|
|
127
|
+
return unless center
|
|
128
|
+
|
|
129
|
+
context.to_horizontal(center)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def frame_around(center)
|
|
133
|
+
Geometry.frame(
|
|
134
|
+
[
|
|
135
|
+
center.altitude.cos * center.azimuth.cos,
|
|
136
|
+
center.altitude.cos * center.azimuth.sin,
|
|
137
|
+
center.altitude.sin
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def check_fields(fields)
|
|
143
|
+
valid = fields.is_a?(Array) && !fields.empty? &&
|
|
144
|
+
fields.all? { |field| field?(field) }
|
|
145
|
+
return if valid
|
|
146
|
+
|
|
147
|
+
raise ArgumentError,
|
|
148
|
+
"fields must be a list of Skymap::Field or Skymap::Setup, " \
|
|
149
|
+
"or anything with a true_field angle and a label"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def field?(field)
|
|
153
|
+
field.respond_to?(:true_field) && field.respond_to?(:label) &&
|
|
154
|
+
field.true_field.is_a?(Astronoby::Angle) &&
|
|
155
|
+
field.true_field.positive? && field.true_field.radians <= Math::PI &&
|
|
156
|
+
field.label.is_a?(String)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def check_center(center)
|
|
160
|
+
return if center.nil? ||
|
|
161
|
+
center.is_a?(Astronoby::Coordinates::Equatorial) ||
|
|
162
|
+
center.is_a?(Astronoby::Coordinates::Horizontal)
|
|
163
|
+
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"center must be equatorial or horizontal coordinates"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|