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,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
module Layers
|
|
5
|
+
class Grid
|
|
6
|
+
DEFAULT_STEP = Astronoby::Angle.from_degrees(30)
|
|
7
|
+
LINE_WIDTH_RATIO = 0.0025
|
|
8
|
+
SAMPLING_STEP_IN_DEGREES = 1
|
|
9
|
+
TOLERANCE_IN_DEGREES = 1e-9
|
|
10
|
+
EPOCHS = %i[j2000 of_date].freeze
|
|
11
|
+
CULL_MARGIN = Astronoby::Angle.from_degrees(
|
|
12
|
+
2 * SAMPLING_STEP_IN_DEGREES
|
|
13
|
+
).radians
|
|
14
|
+
AXES = [[0, 0], [90, 0], [0, 90]].freeze
|
|
15
|
+
|
|
16
|
+
Line = Data.define(:kind, :degrees) do
|
|
17
|
+
def parallel?
|
|
18
|
+
kind == :parallel
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def radians
|
|
22
|
+
Astronoby::Angle.from_degrees(degrees).radians
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(step: DEFAULT_STEP, epoch: :j2000)
|
|
27
|
+
divisions = divisions_of(step)
|
|
28
|
+
unless divisions
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
"step must be an Astronoby::Angle between 1 and 90 degrees " \
|
|
31
|
+
"that divides 360 degrees"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
unless EPOCHS.include?(epoch)
|
|
35
|
+
raise ArgumentError, "epoch must be :j2000 or :of_date"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@divisions = divisions
|
|
39
|
+
@step = 360.0 / divisions
|
|
40
|
+
@epoch = epoch
|
|
41
|
+
@specs = (parallels + meridians).freeze
|
|
42
|
+
if j2000?
|
|
43
|
+
@lines = @specs.to_h { |spec|
|
|
44
|
+
[spec, points_of(spec, Astronoby::JulianDate::J2000)]
|
|
45
|
+
}.freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def elements(context)
|
|
50
|
+
width = LINE_WIDTH_RATIO * context.canvas.radius
|
|
51
|
+
|
|
52
|
+
lines_for(context).flat_map { |line|
|
|
53
|
+
Horizon.joined(Horizon.visible_runs(context, line))
|
|
54
|
+
}.map do |points|
|
|
55
|
+
Renderer::Grid.new(points: points, width: width)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def j2000?
|
|
62
|
+
@epoch == :j2000
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def lines_for(context)
|
|
66
|
+
epoch = j2000? ? Astronoby::JulianDate::J2000 : context.instant.tt
|
|
67
|
+
|
|
68
|
+
specs_near(context, epoch).map do |spec|
|
|
69
|
+
j2000? ? @lines.fetch(spec) : points_of(spec, epoch)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def specs_near(context, epoch)
|
|
74
|
+
return @specs if context.full_sky?
|
|
75
|
+
|
|
76
|
+
center = view_center(context, epoch)
|
|
77
|
+
limit = context.view.field_of_view.radians / 2 + CULL_MARGIN
|
|
78
|
+
|
|
79
|
+
@specs.select { |spec| distance_to(spec, center) <= limit }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def view_center(context, epoch)
|
|
83
|
+
direction = Horizon.direction(
|
|
84
|
+
[context.to_horizontal(context.view.center)]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
AXES.map do |right_ascension, declination|
|
|
88
|
+
axis = point_at(right_ascension, declination, epoch)
|
|
89
|
+
Geometry.dot(
|
|
90
|
+
Horizon.direction([context.horizontal_coordinates(axis)]),
|
|
91
|
+
direction
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def distance_to(spec, center)
|
|
97
|
+
x, y, z = center
|
|
98
|
+
if spec.parallel?
|
|
99
|
+
return (Math.asin(z.clamp(-1, 1)) - spec.radians).abs
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
along = x * Math.cos(spec.radians) + y * Math.sin(spec.radians)
|
|
103
|
+
reach = along.negative? ? z.abs : Math.hypot(along, z)
|
|
104
|
+
|
|
105
|
+
Math.acos(reach.clamp(-1, 1))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def divisions_of(step)
|
|
109
|
+
return unless step.is_a?(Astronoby::Angle) && step.positive?
|
|
110
|
+
|
|
111
|
+
divisions = (360 / step.degrees).round
|
|
112
|
+
return unless divisions.between?(4, 360)
|
|
113
|
+
|
|
114
|
+
divisions if (360.0 / divisions - step.degrees).abs <
|
|
115
|
+
TOLERANCE_IN_DEGREES
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parallels
|
|
119
|
+
circles = ((90 - TOLERANCE_IN_DEGREES) / @step).floor
|
|
120
|
+
declinations = (1..circles).flat_map { |index|
|
|
121
|
+
[index * @step, -index * @step]
|
|
122
|
+
} + [0]
|
|
123
|
+
|
|
124
|
+
declinations.map { |declination| Line.new(:parallel, declination) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def meridians
|
|
128
|
+
@divisions.times.map { |index| Line.new(:meridian, index * @step) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def points_of(spec, epoch)
|
|
132
|
+
if spec.parallel?
|
|
133
|
+
points = (0...360).step(SAMPLING_STEP_IN_DEGREES).map do |degrees|
|
|
134
|
+
point_at(degrees, spec.degrees, epoch)
|
|
135
|
+
end
|
|
136
|
+
points + points.first(1)
|
|
137
|
+
else
|
|
138
|
+
(-90..90).step(SAMPLING_STEP_IN_DEGREES).map do |declination|
|
|
139
|
+
point_at(spec.degrees, declination, epoch)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def point_at(right_ascension, declination, epoch)
|
|
145
|
+
Astronoby::Coordinates::Equatorial.new(
|
|
146
|
+
right_ascension: Astronoby::Angle.from_degrees(right_ascension),
|
|
147
|
+
declination: Astronoby::Angle.from_degrees(declination),
|
|
148
|
+
epoch: epoch
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/skymap/layers/moon.rb
CHANGED
|
@@ -11,29 +11,39 @@ module Skymap
|
|
|
11
11
|
|
|
12
12
|
def elements(context)
|
|
13
13
|
moon = Astronoby::Moon.at(context.instant, ephem: @ephem)
|
|
14
|
-
|
|
14
|
+
equatorial = moon.observed_by(context.observer).equatorial
|
|
15
|
+
horizontal = context.horizontal_coordinates(equatorial)
|
|
15
16
|
return [] if horizontal.altitude.negative?
|
|
16
17
|
|
|
18
|
+
center = context.project(
|
|
19
|
+
altitude: horizontal.altitude,
|
|
20
|
+
azimuth: horizontal.azimuth
|
|
21
|
+
)
|
|
22
|
+
radius = context.disk_radius(
|
|
23
|
+
MOON_RADIUS_RATIO * context.canvas.radius,
|
|
24
|
+
angular_diameter: moon.angular_diameter,
|
|
25
|
+
altitude: horizontal.altitude,
|
|
26
|
+
azimuth: horizontal.azimuth
|
|
27
|
+
)
|
|
28
|
+
return [] unless context.in_view?(center, radius)
|
|
29
|
+
|
|
17
30
|
[
|
|
18
31
|
Renderer::Moon.new(
|
|
19
|
-
center:
|
|
20
|
-
|
|
21
|
-
azimuth: horizontal.azimuth
|
|
22
|
-
),
|
|
23
|
-
radius: MOON_RADIUS_RATIO * context.canvas.radius,
|
|
32
|
+
center: center,
|
|
33
|
+
radius: radius,
|
|
24
34
|
illuminated_fraction: moon.illuminated_fraction,
|
|
25
|
-
rotation: rotation(moon, context
|
|
35
|
+
rotation: rotation(moon, equatorial, context)
|
|
26
36
|
)
|
|
27
37
|
]
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
private
|
|
31
41
|
|
|
32
|
-
def rotation(moon,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
def rotation(moon, equatorial, context)
|
|
43
|
+
context.rotation_at(
|
|
44
|
+
equatorial,
|
|
45
|
+
position_angle: moon.bright_limb_position_angle
|
|
46
|
+
).degrees
|
|
37
47
|
end
|
|
38
48
|
end
|
|
39
49
|
end
|
|
@@ -22,6 +22,8 @@ module Skymap
|
|
|
22
22
|
labels: false,
|
|
23
23
|
names: NAMES
|
|
24
24
|
)
|
|
25
|
+
MagnitudeLimit.check(magnitude_limit)
|
|
26
|
+
|
|
25
27
|
@ephem = ephem
|
|
26
28
|
@magnitude_limit = magnitude_limit
|
|
27
29
|
@labels = labels
|
|
@@ -47,6 +49,8 @@ module Skymap
|
|
|
47
49
|
azimuth: horizontal.azimuth
|
|
48
50
|
)
|
|
49
51
|
radius = star_size.radius(magnitude)
|
|
52
|
+
next [] unless context.in_view?(center, radius)
|
|
53
|
+
|
|
50
54
|
caption = caption(planet, center, radius, context) if @labels
|
|
51
55
|
[Renderer::Planet.new(center: center, radius: radius), caption]
|
|
52
56
|
.compact
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
module Layers
|
|
5
|
+
class Scale
|
|
6
|
+
UNITS = [["″", 1 / 3600.0], ["′", 1 / 60.0], ["°", 1.0]].freeze
|
|
7
|
+
STEPS = [1, 2, 5, 10, 20, 30].freeze
|
|
8
|
+
MAX_LENGTH_RATIO = 0.2
|
|
9
|
+
LINE_WIDTH_RATIO = 0.005
|
|
10
|
+
TICK_SIZE_RATIO = 0.5
|
|
11
|
+
LEFT_RATIO = -0.5
|
|
12
|
+
BOTTOM_RATIO = 0.76
|
|
13
|
+
LABEL_GAP_RATIO = 0.7
|
|
14
|
+
LENGTH_ITERATIONS = 8
|
|
15
|
+
|
|
16
|
+
def elements(context)
|
|
17
|
+
return [] if context.full_sky?
|
|
18
|
+
|
|
19
|
+
canvas = context.canvas
|
|
20
|
+
left = canvas.center.x + LEFT_RATIO * canvas.radius
|
|
21
|
+
y = canvas.center.y + BOTTOM_RATIO * canvas.radius
|
|
22
|
+
max_length = MAX_LENGTH_RATIO * 2 * canvas.radius
|
|
23
|
+
length, text = longest_fitting(context, left, y, max_length)
|
|
24
|
+
return [] unless length
|
|
25
|
+
|
|
26
|
+
size = Renderer::Caption.size_for(canvas)
|
|
27
|
+
tick = TICK_SIZE_RATIO * size
|
|
28
|
+
|
|
29
|
+
[
|
|
30
|
+
Renderer::ScaleBar.new(
|
|
31
|
+
from: Point.new(x: left, y: y),
|
|
32
|
+
to: Point.new(x: left + length, y: y),
|
|
33
|
+
tick: tick,
|
|
34
|
+
width: LINE_WIDTH_RATIO * canvas.radius
|
|
35
|
+
),
|
|
36
|
+
Renderer::Caption.new(
|
|
37
|
+
position: Point.new(
|
|
38
|
+
x: left + length / 2,
|
|
39
|
+
y: y - tick - LABEL_GAP_RATIO * size
|
|
40
|
+
),
|
|
41
|
+
text: text,
|
|
42
|
+
size: size,
|
|
43
|
+
kind: :scale
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def longest_fitting(context, left, y, max_length)
|
|
51
|
+
UNITS.product(STEPS).reverse_each do |(symbol, degrees), step|
|
|
52
|
+
radians = Astronoby::Angle.from_degrees(step * degrees).radians
|
|
53
|
+
length = pixels_for(context, radians, left, y)
|
|
54
|
+
return [length, "#{step}#{symbol}"] if length <= max_length
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pixels_for(context, radians, left, y)
|
|
61
|
+
length = radians * context.scale_at_point(Point.new(x: left, y: y))
|
|
62
|
+
|
|
63
|
+
LENGTH_ITERATIONS.times do
|
|
64
|
+
inverses = [0, 0.5, 1].map do |along|
|
|
65
|
+
point = Point.new(x: left + along * length, y: y)
|
|
66
|
+
1 / context.scale_at_point(point)
|
|
67
|
+
end
|
|
68
|
+
length = radians * 6 / (inverses[0] + 4 * inverses[1] + inverses[2])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
length
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -11,6 +11,12 @@ module Skymap
|
|
|
11
11
|
stars_magnitude_limit: Stars::DEFAULT_MAGNITUDE_LIMIT,
|
|
12
12
|
names: Catalog::StarNames.new
|
|
13
13
|
)
|
|
14
|
+
MagnitudeLimit.check(magnitude_limit, allow_nil: true)
|
|
15
|
+
MagnitudeLimit.check(
|
|
16
|
+
stars_magnitude_limit,
|
|
17
|
+
name: :stars_magnitude_limit
|
|
18
|
+
)
|
|
19
|
+
|
|
14
20
|
@stars = stars
|
|
15
21
|
@magnitude_limit = magnitude_limit
|
|
16
22
|
@stars_magnitude_limit = stars_magnitude_limit
|
|
@@ -23,17 +29,28 @@ module Skymap
|
|
|
23
29
|
magnitude_limit: @stars_magnitude_limit
|
|
24
30
|
)
|
|
25
31
|
|
|
26
|
-
@stars.
|
|
27
|
-
|
|
32
|
+
candidates = StarList.wrap(@stars).search(
|
|
33
|
+
magnitude_limit: @magnitude_limit,
|
|
34
|
+
cone: context.cone
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
candidates.filter_map do |star|
|
|
38
|
+
next unless star.hr
|
|
28
39
|
|
|
29
40
|
name = @names[star.hr]
|
|
30
41
|
next unless name
|
|
31
42
|
|
|
43
|
+
radius = star_size.radius(star.magnitude)
|
|
44
|
+
next unless context.possibly_visible?(star, radius)
|
|
45
|
+
|
|
32
46
|
next if context.horizontal(star).altitude.negative?
|
|
33
47
|
|
|
48
|
+
center = context.position(star)
|
|
49
|
+
next unless context.in_view?(center, radius)
|
|
50
|
+
|
|
34
51
|
Renderer::Caption.beside(
|
|
35
|
-
center:
|
|
36
|
-
radius:
|
|
52
|
+
center: center,
|
|
53
|
+
radius: radius,
|
|
37
54
|
canvas: context.canvas,
|
|
38
55
|
text: name,
|
|
39
56
|
kind: :star
|
data/lib/skymap/layers/stars.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Skymap
|
|
|
6
6
|
DEFAULT_MAGNITUDE_LIMIT = 5
|
|
7
7
|
|
|
8
8
|
def initialize(stars:, magnitude_limit: DEFAULT_MAGNITUDE_LIMIT)
|
|
9
|
+
MagnitudeLimit.check(magnitude_limit)
|
|
10
|
+
|
|
9
11
|
@stars = stars
|
|
10
12
|
@magnitude_limit = magnitude_limit
|
|
11
13
|
end
|
|
@@ -16,16 +18,37 @@ module Skymap
|
|
|
16
18
|
magnitude_limit: @magnitude_limit
|
|
17
19
|
)
|
|
18
20
|
|
|
19
|
-
@stars.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
candidates = StarList.wrap(@stars).search(
|
|
22
|
+
magnitude_limit: @magnitude_limit,
|
|
23
|
+
cone: context.cone
|
|
24
|
+
)
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
26
|
+
dots = candidates.filter_map do |star|
|
|
27
|
+
dot = dot_for(star, context, star_size)
|
|
28
|
+
[star.magnitude, dot] if dot
|
|
28
29
|
end
|
|
30
|
+
|
|
31
|
+
faintest_first(dots)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def dot_for(star, context, star_size)
|
|
37
|
+
radius = star_size.radius(star.magnitude)
|
|
38
|
+
return unless context.possibly_visible?(star, radius)
|
|
39
|
+
|
|
40
|
+
return if context.horizontal(star).altitude.negative?
|
|
41
|
+
|
|
42
|
+
center = context.position(star)
|
|
43
|
+
return unless context.in_view?(center, radius)
|
|
44
|
+
|
|
45
|
+
Renderer::Dot.new(center: center, radius: radius)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def faintest_first(dots)
|
|
49
|
+
dots.each_with_index
|
|
50
|
+
.sort_by { |(magnitude, _), index| [-magnitude, index] }
|
|
51
|
+
.map { |(_, dot), _| dot }
|
|
29
52
|
end
|
|
30
53
|
end
|
|
31
54
|
end
|
data/lib/skymap/layers/sun.rb
CHANGED
|
@@ -14,15 +14,19 @@ module Skymap
|
|
|
14
14
|
horizontal = sun.observed_by(context.observer).horizontal
|
|
15
15
|
return [] if horizontal.altitude.negative?
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
center = context.project(
|
|
18
|
+
altitude: horizontal.altitude,
|
|
19
|
+
azimuth: horizontal.azimuth
|
|
20
|
+
)
|
|
21
|
+
radius = context.disk_radius(
|
|
22
|
+
SUN_RADIUS_RATIO * context.canvas.radius,
|
|
23
|
+
angular_diameter: sun.angular_diameter,
|
|
24
|
+
altitude: horizontal.altitude,
|
|
25
|
+
azimuth: horizontal.azimuth
|
|
26
|
+
)
|
|
27
|
+
return [] unless context.in_view?(center, radius)
|
|
28
|
+
|
|
29
|
+
[Renderer::Sun.new(center: center, radius: radius)]
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
module MagnitudeLimit
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def check(value, name: :magnitude_limit, allow_nil: false)
|
|
8
|
+
return if allow_nil && value.nil?
|
|
9
|
+
return if value.is_a?(Numeric) && value.real? && value.finite?
|
|
10
|
+
|
|
11
|
+
raise ArgumentError,
|
|
12
|
+
"#{name} must be a finite Numeric#{" or nil" if allow_nil}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
PrintSize = Data.define(:length, :unit) do
|
|
5
|
+
def self.paper(name, margin: PrintSize::DEFAULT_MARGIN)
|
|
6
|
+
short_side = PrintSize::PAPERS.fetch(name) do
|
|
7
|
+
papers = PrintSize::PAPERS.keys.join(", ")
|
|
8
|
+
raise ArgumentError, "paper must be one of #{papers}"
|
|
9
|
+
end
|
|
10
|
+
unless margin.is_a?(Numeric) && margin.real? && margin >= 0 &&
|
|
11
|
+
margin < short_side / 2.0
|
|
12
|
+
raise ArgumentError,
|
|
13
|
+
"margin must not be negative and must be less than half the paper"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
new(length: (short_side - 2 * margin).round(2), unit: :mm)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(length:, unit:)
|
|
20
|
+
unless length.is_a?(Numeric) && length.real? && length.finite? &&
|
|
21
|
+
length > 0
|
|
22
|
+
raise ArgumentError, "length must be a number above 0"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
unless PrintSize::UNITS.include?(unit)
|
|
26
|
+
units = PrintSize::UNITS.join(", ")
|
|
27
|
+
raise ArgumentError, "unit must be one of #{units}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s
|
|
34
|
+
number = length.to_f
|
|
35
|
+
number = number.to_i if number == number.to_i
|
|
36
|
+
|
|
37
|
+
"#{number}#{unit}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
PrintSize::UNITS = %i[mm cm in].freeze
|
|
42
|
+
PrintSize::PAPERS = {
|
|
43
|
+
a5: 148,
|
|
44
|
+
a4: 210,
|
|
45
|
+
a3: 297,
|
|
46
|
+
letter: 215.9,
|
|
47
|
+
tabloid: 279.4
|
|
48
|
+
}.freeze
|
|
49
|
+
PrintSize::DEFAULT_MARGIN = 10
|
|
50
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Skymap
|
|
4
|
+
module Projection
|
|
5
|
+
class ObliqueStereographic
|
|
6
|
+
MIN_DENOMINATOR = 1e-9
|
|
7
|
+
NORTH = [1.0, 0.0, 0.0].freeze
|
|
8
|
+
ZENITH = [0.0, 0.0, 1.0].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
center:,
|
|
12
|
+
radius:,
|
|
13
|
+
altitude:,
|
|
14
|
+
azimuth:,
|
|
15
|
+
field_of_view:,
|
|
16
|
+
orientation: :zenith_up,
|
|
17
|
+
pole: NORTH,
|
|
18
|
+
rotation: Astronoby::Angle.zero,
|
|
19
|
+
mirrored: false
|
|
20
|
+
)
|
|
21
|
+
@center = center
|
|
22
|
+
@forward = vector(altitude, azimuth)
|
|
23
|
+
up = up_from(@forward, field_of_view, orientation, pole)
|
|
24
|
+
right = Geometry.cross(up, @forward)
|
|
25
|
+
right = right.map(&:-@) if mirrored
|
|
26
|
+
@right, @up = turned(right, up, rotation)
|
|
27
|
+
quarter = Astronoby::Angle.from_radians(field_of_view.radians / 4.0)
|
|
28
|
+
@scale = radius / (2 * quarter.tan)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def project(altitude:, azimuth:)
|
|
32
|
+
point = vector(altitude, azimuth)
|
|
33
|
+
factor = 2 / [1 + Geometry.dot(point, @forward), MIN_DENOMINATOR].max
|
|
34
|
+
|
|
35
|
+
Point.new(
|
|
36
|
+
x: @center.x + @scale * factor * Geometry.dot(point, @right),
|
|
37
|
+
y: @center.y - @scale * factor * Geometry.dot(point, @up)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scale_at(altitude:, azimuth:)
|
|
42
|
+
point = vector(altitude, azimuth)
|
|
43
|
+
|
|
44
|
+
@scale * 2 / [1 + Geometry.dot(point, @forward), MIN_DENOMINATOR].max
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def scale_at_point(point)
|
|
48
|
+
distance = Math.hypot(point.x - @center.x, point.y - @center.y)
|
|
49
|
+
|
|
50
|
+
@scale * (1 + (distance / (2 * @scale))**2)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def vector(altitude, azimuth)
|
|
56
|
+
[
|
|
57
|
+
altitude.cos * azimuth.cos,
|
|
58
|
+
altitude.cos * azimuth.sin,
|
|
59
|
+
altitude.sin
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def up_from(forward, field_of_view, orientation, pole)
|
|
64
|
+
half_angle = Astronoby::Angle.from_radians(field_of_view.radians / 2.0)
|
|
65
|
+
references = if orientation == :equatorial
|
|
66
|
+
[pole, ZENITH, NORTH]
|
|
67
|
+
elsif forward[2] >= half_angle.cos
|
|
68
|
+
[NORTH, ZENITH]
|
|
69
|
+
else
|
|
70
|
+
[ZENITH, NORTH]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
references.each do |reference|
|
|
74
|
+
up = Geometry.away_from(reference, forward)
|
|
75
|
+
return Geometry.normalize(up) if Geometry.dot(up, up) > 1e-18
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def turned(right, up, rotation)
|
|
80
|
+
[
|
|
81
|
+
right.zip(up).map { |r, u| rotation.cos * r + rotation.sin * u },
|
|
82
|
+
up.zip(right).map { |u, r| rotation.cos * u - rotation.sin * r }
|
|
83
|
+
]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -16,6 +16,17 @@ module Skymap
|
|
|
16
16
|
y: @center.y - distance * azimuth.cos
|
|
17
17
|
)
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
def scale_at(altitude:, azimuth:)
|
|
21
|
+
@radius / (1 + altitude.sin)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scale_at_point(point)
|
|
25
|
+
scale = @radius / 2.0
|
|
26
|
+
distance = Math.hypot(point.x - @center.x, point.y - @center.y)
|
|
27
|
+
|
|
28
|
+
scale * (1 + (distance / (2 * scale))**2)
|
|
29
|
+
end
|
|
19
30
|
end
|
|
20
31
|
end
|
|
21
32
|
end
|
|
@@ -5,7 +5,37 @@ module Skymap
|
|
|
5
5
|
CAPTION_SIZE_RATIO = 0.028
|
|
6
6
|
CAPTION_CHARACTER_WIDTH_RATIO = 0.55
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
module TextWidth
|
|
9
|
+
def self.of(text, size)
|
|
10
|
+
text.length * size * CAPTION_CHARACTER_WIDTH_RATIO
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def width
|
|
14
|
+
TextWidth.of(text, size)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Caption = Data.define(
|
|
19
|
+
:position,
|
|
20
|
+
:text,
|
|
21
|
+
:size,
|
|
22
|
+
:kind,
|
|
23
|
+
:anchor,
|
|
24
|
+
:clearance
|
|
25
|
+
) do
|
|
26
|
+
include TextWidth
|
|
27
|
+
|
|
28
|
+
def initialize(
|
|
29
|
+
position:,
|
|
30
|
+
text:,
|
|
31
|
+
size:,
|
|
32
|
+
kind:,
|
|
33
|
+
anchor: nil,
|
|
34
|
+
clearance: 0.0
|
|
35
|
+
)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
9
39
|
def self.size_for(canvas)
|
|
10
40
|
CAPTION_SIZE_RATIO * canvas.radius
|
|
11
41
|
end
|
|
@@ -22,14 +52,16 @@ module Skymap
|
|
|
22
52
|
),
|
|
23
53
|
text: text,
|
|
24
54
|
size: size,
|
|
25
|
-
kind: kind
|
|
55
|
+
kind: kind,
|
|
56
|
+
anchor: center,
|
|
57
|
+
clearance: radius
|
|
26
58
|
)
|
|
27
59
|
end
|
|
28
60
|
|
|
29
61
|
def self.within_sky(x, y, text, size, canvas)
|
|
30
62
|
dy = (y - canvas.center.y).abs + size / 2
|
|
31
63
|
room = Math.sqrt([canvas.radius**2 - dy**2, 0].max)
|
|
32
|
-
half_width = text
|
|
64
|
+
half_width = TextWidth.of(text, size) / 2
|
|
33
65
|
reach = [room - half_width, 0].max
|
|
34
66
|
|
|
35
67
|
canvas.center.x + (x - canvas.center.x).clamp(-reach, reach)
|