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
@@ -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
@@ -11,29 +11,39 @@ module Skymap
11
11
 
12
12
  def elements(context)
13
13
  moon = Astronoby::Moon.at(context.instant, ephem: @ephem)
14
- horizontal = moon.observed_by(context.observer).horizontal
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: context.project(
20
- altitude: horizontal.altitude,
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.observer)
35
+ rotation: rotation(moon, equatorial, context)
26
36
  )
27
37
  ]
28
38
  end
29
39
 
30
40
  private
31
41
 
32
- def rotation(moon, observer)
33
- from_zenith = moon.bright_limb_position_angle.degrees -
34
- moon.parallactic_angle(observer: observer).degrees
35
-
36
- -90 - from_zenith
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.filter_map do |star|
27
- next if star.magnitude > @magnitude_limit
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: context.position(star),
36
- radius: star_size.radius(star.magnitude),
52
+ center: center,
53
+ radius: radius,
37
54
  canvas: context.canvas,
38
55
  text: name,
39
56
  kind: :star
@@ -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.filter_map do |star|
20
- next if star.magnitude > @magnitude_limit
21
-
22
- next if context.horizontal(star).altitude.negative?
21
+ candidates = StarList.wrap(@stars).search(
22
+ magnitude_limit: @magnitude_limit,
23
+ cone: context.cone
24
+ )
23
25
 
24
- Renderer::Dot.new(
25
- center: context.position(star),
26
- radius: star_size.radius(star.magnitude)
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
@@ -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
- Renderer::Sun.new(
19
- center: context.project(
20
- altitude: horizontal.altitude,
21
- azimuth: horizontal.azimuth
22
- ),
23
- radius: SUN_RADIUS_RATIO * context.canvas.radius
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
- Caption = Data.define(:position, :text, :size, :kind) do
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.length * size * CAPTION_CHARACTER_WIDTH_RATIO / 2
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)