skymap 0.0.1 → 0.2.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.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ Canvas = Data.define(:size, :padding) do
5
+ def center
6
+ Point.new(x: size / 2.0, y: size / 2.0)
7
+ end
8
+
9
+ def radius
10
+ size / 2.0 - padding
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Skymap
6
+ module Catalog
7
+ class ConstellationBoundaries
8
+ include Enumerable
9
+
10
+ PATH = File.expand_path(
11
+ "../../../data/constellations/iau/boundaries.csv",
12
+ __dir__
13
+ )
14
+
15
+ def each
16
+ CSV.foreach(PATH, headers: true).chunk_while do |previous, row|
17
+ previous["border"] == row["border"]
18
+ end.each do |rows|
19
+ yield boundary_from(rows)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def boundary_from(rows)
26
+ ConstellationBoundary.new(
27
+ constellations: rows.first["constellations"].split,
28
+ points: rows.map { |row| coordinates_from(row) }
29
+ )
30
+ end
31
+
32
+ def coordinates_from(row)
33
+ Astronoby::Coordinates::Equatorial.new(
34
+ right_ascension: Astronoby::Angle.from_hours(
35
+ row["right_ascension"].to_f
36
+ ),
37
+ declination: Astronoby::Angle.from_degrees(row["declination"].to_f),
38
+ epoch: Astronoby::JulianDate::J2000
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Skymap
6
+ module Catalog
7
+ class ConstellationLines
8
+ include Enumerable
9
+
10
+ PATH = File.expand_path(
11
+ "../../../data/constellations/iau/lines.csv",
12
+ __dir__
13
+ )
14
+
15
+ def each
16
+ CSV.foreach(PATH, headers: true) do |row|
17
+ yield line_from(row)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def line_from(row)
24
+ ConstellationLine.new(
25
+ constellation: row["constellation"],
26
+ from: hr_numbers(row["from_hr"]),
27
+ to: hr_numbers(row["to_hr"]),
28
+ weight: row["weight"].to_sym
29
+ )
30
+ end
31
+
32
+ def hr_numbers(value)
33
+ value.split.map(&:to_i)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Skymap
6
+ module Catalog
7
+ class Stars
8
+ include Enumerable
9
+
10
+ PATH = File.expand_path("../../../data/stars.csv", __dir__)
11
+
12
+ def each
13
+ CSV.foreach(PATH, headers: true) do |row|
14
+ yield star_from(row)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def star_from(row)
21
+ Star.new(
22
+ hr: row["hr"].to_i,
23
+ equatorial_coordinates: Astronoby::Coordinates::Equatorial.new(
24
+ right_ascension: Astronoby::Angle.from_hours(
25
+ row["right_ascension"].to_f
26
+ ),
27
+ declination: Astronoby::Angle.from_degrees(row["declination"].to_f),
28
+ epoch: Astronoby::JulianDate::J2000
29
+ ),
30
+ magnitude: row["magnitude"].to_f
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ class Chart
5
+ def initialize(observer:, instant:, canvas:)
6
+ @observer = observer
7
+ @instant = instant
8
+ @canvas = canvas
9
+ end
10
+
11
+ def render(*layers)
12
+ context = Context.new(
13
+ observer: @observer,
14
+ instant: @instant,
15
+ canvas: @canvas
16
+ )
17
+ elements = layers.flat_map { |layer| layer.elements(context) }
18
+
19
+ Renderer::SVG.new(canvas: @canvas).render(elements)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ ConstellationBoundary = Data.define(:constellations, :points)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ ConstellationLine = Data.define(:constellation, :from, :to, :weight)
5
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ class Context
5
+ attr_reader :observer, :instant, :canvas
6
+
7
+ def initialize(observer:, instant:, canvas:)
8
+ @observer = observer
9
+ @instant = instant
10
+ @canvas = canvas
11
+ @horizontal = {}
12
+ @projections = {}
13
+ end
14
+
15
+ def horizontal(star)
16
+ horizontal_coordinates(star.equatorial_coordinates)
17
+ end
18
+
19
+ def horizontal_coordinates(coordinates)
20
+ key = [
21
+ coordinates.right_ascension,
22
+ coordinates.declination,
23
+ coordinates.epoch
24
+ ]
25
+
26
+ @horizontal[key] ||= Astronoby::DeepSkyObject
27
+ .new(equatorial_coordinates: coordinates)
28
+ .at(@instant)
29
+ .observed_by(@observer)
30
+ .horizontal
31
+ end
32
+
33
+ def position(star)
34
+ horizontal = horizontal(star)
35
+ project(altitude: horizontal.altitude, azimuth: horizontal.azimuth)
36
+ end
37
+
38
+ def project(altitude:, azimuth:, radius: @canvas.radius)
39
+ projection(radius).project(altitude: altitude, azimuth: azimuth)
40
+ end
41
+
42
+ private
43
+
44
+ def projection(radius)
45
+ @projections[radius] ||= Projection::Stereographic.new(
46
+ center: @canvas.center,
47
+ radius: radius
48
+ )
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Horizon
5
+ module_function
6
+
7
+ def direction(horizontals)
8
+ vectors = horizontals.map do |horizontal|
9
+ [
10
+ horizontal.altitude.cos * horizontal.azimuth.cos,
11
+ horizontal.altitude.cos * horizontal.azimuth.sin,
12
+ horizontal.altitude.sin
13
+ ]
14
+ end
15
+ vectors.transpose.map(&:sum)
16
+ end
17
+
18
+ def crossing(above, below)
19
+ above.zip(below).map do |from, to|
20
+ above.last * to - below.last * from
21
+ end
22
+ end
23
+
24
+ def project(context, direction)
25
+ x, y, = direction
26
+ context.project(
27
+ altitude: Astronoby::Angle.zero,
28
+ azimuth: Astronoby::Angle.from_radians(Math.atan2(y, x))
29
+ )
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Layers
5
+ class CardinalDirections
6
+ CARDINAL_DIRECTIONS = {"N" => 0, "E" => 90, "S" => 180, "W" => 270}
7
+ CARDINAL_SIZE_RATIO = 0.6
8
+
9
+ def elements(context)
10
+ canvas = context.canvas
11
+
12
+ CARDINAL_DIRECTIONS.map do |text, azimuth|
13
+ position = context.project(
14
+ altitude: Astronoby::Angle.zero,
15
+ azimuth: Astronoby::Angle.from_degrees(azimuth),
16
+ radius: canvas.radius + canvas.padding / 2.0
17
+ )
18
+ Renderer::Label.new(
19
+ position: position,
20
+ text: text,
21
+ size: canvas.padding * CARDINAL_SIZE_RATIO
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Layers
5
+ class ConstellationBoundaries
6
+ LINE_WIDTH_RATIO = 0.0017
7
+
8
+ def initialize(boundaries:)
9
+ @boundaries = boundaries
10
+ end
11
+
12
+ def elements(context)
13
+ width = LINE_WIDTH_RATIO * context.canvas.radius
14
+
15
+ @boundaries.flat_map do |boundary|
16
+ visible_runs(context, boundary.points).map do |points|
17
+ Renderer::Boundary.new(points: points, width: width)
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def visible_runs(context, coordinates)
25
+ horizontals = coordinates.map do |point|
26
+ context.horizontal_coordinates(point)
27
+ end
28
+ path = [position(context, horizontals.first)] +
29
+ horizontals.each_cons(2).flat_map do |from, to|
30
+ crossing(context, from, to) + [position(context, to)]
31
+ end
32
+
33
+ path.chunk { |point| !point.nil? }.filter_map do |visible, points|
34
+ points if visible && points.size > 1
35
+ end
36
+ end
37
+
38
+ def position(context, horizontal)
39
+ return if below?(horizontal)
40
+
41
+ context.project(
42
+ altitude: horizontal.altitude,
43
+ azimuth: horizontal.azimuth
44
+ )
45
+ end
46
+
47
+ def crossing(context, from, to)
48
+ return [] if below?(from) == below?(to)
49
+
50
+ above, below = below?(from) ? [to, from] : [from, to]
51
+ [
52
+ Horizon.project(
53
+ context,
54
+ Horizon.crossing(
55
+ Horizon.direction([above]),
56
+ Horizon.direction([below])
57
+ )
58
+ )
59
+ ]
60
+ end
61
+
62
+ def below?(horizontal)
63
+ horizontal.altitude.negative?
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Layers
5
+ class ConstellationLines
6
+ LINE_WIDTH_RATIOS = {bold: 0.0044, normal: 0.003, thin: 0.0017}
7
+
8
+ def initialize(lines:, stars:)
9
+ @lines = lines
10
+ @stars = stars
11
+ end
12
+
13
+ def elements(context)
14
+ stars_by_hr = @stars.to_h { |star| [star.hr, star] }
15
+
16
+ @lines.filter_map do |line|
17
+ ends = [line.from, line.to].map { |hrs| stars_by_hr.values_at(*hrs) }
18
+ next if ends.flatten.include?(nil)
19
+
20
+ directions = ends.map do |end_stars|
21
+ Horizon.direction(
22
+ end_stars.map { |star| context.horizontal(star) }
23
+ )
24
+ end
25
+ next if directions.all? { |direction| direction.last.negative? }
26
+
27
+ from, to = ends.each_with_index.map do |end_stars, index|
28
+ if directions[index].last.negative?
29
+ Horizon.project(
30
+ context,
31
+ Horizon.crossing(directions[1 - index], directions[index])
32
+ )
33
+ else
34
+ Point.midpoint(end_stars.map { |star| context.position(star) })
35
+ end
36
+ end
37
+ Renderer::Line.new(
38
+ from: from,
39
+ to: to,
40
+ width: LINE_WIDTH_RATIOS.fetch(line.weight) * context.canvas.radius
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Layers
5
+ class Moon
6
+ MOON_RADIUS_RATIO = 0.03
7
+
8
+ def initialize(ephem:)
9
+ @ephem = ephem
10
+ end
11
+
12
+ def elements(context)
13
+ moon = Astronoby::Moon.at(context.instant, ephem: @ephem)
14
+ horizontal = moon.observed_by(context.observer).horizontal
15
+ return [] if horizontal.altitude.negative?
16
+
17
+ [
18
+ Renderer::Moon.new(
19
+ center: context.project(
20
+ altitude: horizontal.altitude,
21
+ azimuth: horizontal.azimuth
22
+ ),
23
+ radius: MOON_RADIUS_RATIO * context.canvas.radius,
24
+ illuminated_fraction: moon.illuminated_fraction,
25
+ rotation: rotation(moon, context.observer)
26
+ )
27
+ ]
28
+ end
29
+
30
+ private
31
+
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
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Layers
5
+ class Stars
6
+ DEFAULT_MAGNITUDE_LIMIT = 5
7
+ MAX_RADIUS_MAGNITUDE = -1.5
8
+ MIN_STAR_RADIUS_RATIO = 0.0025
9
+ MAX_STAR_RADIUS_RATIO = 0.02
10
+ STAR_SIZE_EXPONENT = 1.5
11
+
12
+ def initialize(stars:, magnitude_limit: DEFAULT_MAGNITUDE_LIMIT)
13
+ @stars = stars
14
+ @magnitude_limit = magnitude_limit
15
+ end
16
+
17
+ def elements(context)
18
+ star_size = StarSize.new(
19
+ brightest_magnitude: MAX_RADIUS_MAGNITUDE,
20
+ faintest_magnitude: @magnitude_limit,
21
+ min_radius: MIN_STAR_RADIUS_RATIO * context.canvas.radius,
22
+ max_radius: MAX_STAR_RADIUS_RATIO * context.canvas.radius,
23
+ exponent: STAR_SIZE_EXPONENT
24
+ )
25
+
26
+ @stars.filter_map do |star|
27
+ next if star.magnitude > @magnitude_limit
28
+
29
+ next if context.horizontal(star).altitude.negative?
30
+
31
+ Renderer::Dot.new(
32
+ center: context.position(star),
33
+ radius: star_size.radius(star.magnitude)
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ Point = Data.define(:x, :y) do
5
+ def self.midpoint(points)
6
+ new(
7
+ x: points.sum(&:x).fdiv(points.size),
8
+ y: points.sum(&:y).fdiv(points.size)
9
+ )
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Projection
5
+ class Stereographic
6
+ def initialize(center:, radius:)
7
+ @center = center
8
+ @radius = radius
9
+ end
10
+
11
+ def project(altitude:, azimuth:)
12
+ distance = @radius * altitude.cos / (1 + altitude.sin)
13
+
14
+ Point.new(
15
+ x: @center.x - distance * azimuth.sin,
16
+ y: @center.y - distance * azimuth.cos
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Renderer
5
+ Boundary = Data.define(:points, :width)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Renderer
5
+ Dot = Data.define(:center, :radius)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Renderer
5
+ Label = Data.define(:position, :text, :size)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Renderer
5
+ Line = Data.define(:from, :to, :width)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ module Renderer
5
+ Moon = Data.define(:center, :radius, :illuminated_fraction, :rotation)
6
+ end
7
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "victor"
4
+
5
+ module Skymap
6
+ module Renderer
7
+ class SVG
8
+ SKY_COLOR = "#000000"
9
+ HORIZON_COLOR = "#3262a8"
10
+ HORIZON_WIDTH = 3
11
+ STAR_COLOR = "#ffffff"
12
+ LINE_COLOR = "#46689c"
13
+ BOUNDARY_COLOR = "#2b3f5e"
14
+ BOUNDARY_DASH_RATIO = 4
15
+ MOON_COLOR = "#f1ecd6"
16
+ MOON_SHADOW_COLOR = "#232a35"
17
+ MOON_SHADOW_OPACITY = 0.6
18
+ LABEL_COLOR = HORIZON_COLOR
19
+ LABEL_FONT = "sans-serif"
20
+
21
+ def initialize(canvas:)
22
+ @canvas = canvas
23
+ end
24
+
25
+ def render(elements)
26
+ svg = Victor::SVG.new(
27
+ viewBox: "0 0 #{@canvas.size} #{@canvas.size}",
28
+ width: @canvas.size,
29
+ height: @canvas.size
30
+ )
31
+
32
+ svg.circle(
33
+ cx: @canvas.center.x.round(2),
34
+ cy: @canvas.center.y.round(2),
35
+ r: @canvas.radius.round(2),
36
+ fill: SKY_COLOR,
37
+ stroke: HORIZON_COLOR,
38
+ stroke_width: HORIZON_WIDTH
39
+ )
40
+
41
+ elements.each do |element|
42
+ case element
43
+ when Boundary then draw_boundary(svg, element)
44
+ when Line then draw_line(svg, element)
45
+ when Dot then draw_dot(svg, element)
46
+ when Label then draw_label(svg, element)
47
+ when Moon then draw_moon(svg, element)
48
+ else raise ArgumentError, "unknown element: #{element.class}"
49
+ end
50
+ end
51
+
52
+ svg.render
53
+ end
54
+
55
+ private
56
+
57
+ def draw_line(svg, line)
58
+ svg.line(
59
+ x1: line.from.x.round(2),
60
+ y1: line.from.y.round(2),
61
+ x2: line.to.x.round(2),
62
+ y2: line.to.y.round(2),
63
+ stroke: LINE_COLOR,
64
+ stroke_width: line.width.round(2),
65
+ stroke_linecap: "round"
66
+ )
67
+ end
68
+
69
+ def draw_boundary(svg, boundary)
70
+ dash = (boundary.width * BOUNDARY_DASH_RATIO).round(2)
71
+
72
+ svg.polyline(
73
+ points: boundary.points.map { |point|
74
+ "#{point.x.round(2)},#{point.y.round(2)}"
75
+ }.join(" "),
76
+ fill: "none",
77
+ stroke: BOUNDARY_COLOR,
78
+ stroke_width: boundary.width.round(2),
79
+ stroke_dasharray: "#{dash} #{dash}"
80
+ )
81
+ end
82
+
83
+ def draw_dot(svg, dot)
84
+ svg.circle(
85
+ cx: dot.center.x.round(2),
86
+ cy: dot.center.y.round(2),
87
+ r: dot.radius.round(2),
88
+ fill: STAR_COLOR
89
+ )
90
+ end
91
+
92
+ def draw_moon(svg, moon)
93
+ x = moon.center.x.round(2)
94
+ y = moon.center.y.round(2)
95
+ radius = moon.radius.round(2)
96
+ fraction = moon.illuminated_fraction
97
+ terminator = (radius * (1 - 2 * fraction).abs).round(2)
98
+ bulge = (fraction > 0.5) ? 1 : 0
99
+ top = "#{x},#{(y - radius).round(2)}"
100
+ bottom = "#{x},#{(y + radius).round(2)}"
101
+ lit = "M #{top} A #{radius},#{radius} 0 0 1 #{bottom} " \
102
+ "A #{terminator},#{radius} 0 0 #{bulge} #{top} Z"
103
+
104
+ svg.build do
105
+ g(transform: "rotate(#{moon.rotation.round(2)} #{x} #{y})") do
106
+ circle(
107
+ cx: x,
108
+ cy: y,
109
+ r: radius,
110
+ fill: MOON_SHADOW_COLOR,
111
+ fill_opacity: MOON_SHADOW_OPACITY
112
+ )
113
+ path(d: lit, fill: MOON_COLOR)
114
+ end
115
+ end
116
+ end
117
+
118
+ def draw_label(svg, label)
119
+ svg.text(
120
+ label.text,
121
+ x: label.position.x.round(2),
122
+ y: label.position.y.round(2),
123
+ font_size: label.size.round(2),
124
+ font_family: LABEL_FONT,
125
+ text_anchor: "middle",
126
+ dominant_baseline: "central",
127
+ fill: LABEL_COLOR
128
+ )
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skymap
4
+ Star = Data.define(:hr, :equatorial_coordinates, :magnitude)
5
+ end