astropanel 1.1.6 → 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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +108 -0
  3. data/bin/astropanel +919 -1048
  4. metadata +11 -16
data/bin/astropanel CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: utf-8
3
3
 
4
- # PROGRAM INFO
4
+ # PROGRAM INFO {{{1
5
5
  # Name: AstroPanel
6
6
  # Language: Pure Ruby, best viewed in VIM
7
7
  # Author: Geir Isene <g@isene.com>
@@ -14,1139 +14,1010 @@
14
14
  # for any damages resulting from its use. Further, I am under no
15
15
  # obligation to maintain or extend this software. It is provided
16
16
  # on an 'as is' basis without any expressed or implied warranty.
17
+ # Version: 2.0: Full rewrite using rcurses (https://github.com/isene/rcurses)
17
18
 
18
- # PRELIMINARIES
19
- @help = <<HELPTEXT
20
- AstroPanel (https://github.com/isene/AstroPanel)
21
-
22
- KEYS
23
- ? = Show this help text ENTER = Refresh starchart/image
24
- l = Edit Location r = Refresh all data
25
- a = Edit Latitude s = Get starchart for selected time
26
- o = Edit Longitude S = Open starchart in image program
27
- c = Edit Cloud limit A = Show Astronomy Picture Of the Day
28
- h = Edit Humidity limit e = Show upcoming events
29
- t = Edit Temperature limit W = Write to config file
30
- w = Edit Wind limit q = Quit (write to config file)
31
- b = Edit Bortle value Q = Quit (no config write)
32
-
33
- COPYRIGHT: Geir Isene, 2020. No rights reserved. See http://isene.com for more.
34
- HELPTEXT
35
- begin # BASIC SETUP
36
- require 'net/http'
37
- require 'open-uri'
38
- require 'json'
39
- require 'date'
40
- require 'time'
41
- require 'readline'
42
- require 'io/console'
43
- require 'curses'
44
- include Curses
45
-
46
- def cmd?(command)
47
- system("which #{command} > /dev/null 2>&1")
48
- end
49
- if cmd?('/usr/lib/w3m/w3mimgdisplay')
50
- @w3mimgdisplay = "/usr/lib/w3m/w3mimgdisplay"
51
- @showimage = true
52
- else
53
- @showimage = false
54
- end
55
- @showimage = false unless (cmd?('xwininfo') and cmd?('xdotool'))
19
+ # LOAD MODULES {{{1
20
+ require 'io/console'
21
+ require 'shellwords'
22
+ require 'net/http'
23
+ require 'open-uri'
24
+ require 'timeout'
25
+ require 'json'
26
+ require 'date'
27
+ require 'time'
28
+ require 'rcurses'
29
+ include Rcurses
30
+ include Rcurses::Input
31
+ include Rcurses::Cursor
56
32
 
57
- begin # Check if network is available
58
- URI.open("https://www.met.no/", :open_timeout=>5)
59
- rescue
60
- puts "\nUnable to get data from met.no\n\n"
61
- exit
62
- end
33
+ # BASIC SETUP {{{1
34
+ $stdin.raw!
35
+ $stdin.echo = false
63
36
 
64
- # INITIALIZE VARIABLES
65
- @loc, @lat, @lon, @cloudlimit, @humiditylimit, @templimit, @windlimit = ""
66
- @noimage = false
67
- if File.exist?(Dir.home+'/.ap.conf')
68
- load(Dir.home+'/.ap.conf')
69
- else
70
- until @loc.match(/\w+\/\w+/)
71
- puts "\nEnter Location (format like Europe/Oslo): "
72
- @loc = Readline.readline('> ', true).chomp.to_s
73
- end
74
- until (-90.0..90.0).include?(@lat)
75
- puts "\nEnter Latitude (format like 59.4351 or -14.54):"
76
- @lat = Readline.readline('> ', true).chomp.to_f
77
- end
78
- until (-180.0..180.0).include?(@lon)
79
- puts "\nEnter Longitude (between -180 and 180):"
80
- @lon = Readline.readline('> ', true).chomp.to_f
81
- end
82
- until (0..100.0).include?(@cloudlimit)
83
- puts "\nLimit for Cloud Coverage (format like 35 for 35%):"
84
- @cloudlimit = Readline.readline('> ', true).chomp.to_i
85
- end
86
- until (0..100.0).include?(@humiditylimit)
87
- puts "\nLimit for Humidity (format 70 for 70%):"
88
- @humiditylimit = Readline.readline('> ', true).chomp.to_i
89
- end
90
- until (-100.0..100.0).include?(@templimit)
91
- puts "\nMinimum observation temperature in °C (format like -15):"
92
- @templimit =Readline.readline('> ', true).chomp.to_i
93
- end
94
- until (0..50.0).include?(@windlimit)
95
- puts "\nLimit for Wind in m/s (format like 6):"
96
- @windlimit = Readline.readline('> ', true).chomp.to_i
97
- end
98
- conf = "@loc = \"#{@loc}\"\n"
99
- conf += "@lat = #{@lat}\n"
100
- conf += "@lon = #{@lon}\n"
101
- conf += "@cloudlimit = #{@cloudlimit}\n"
102
- conf += "@humiditylimit = #{@humiditylimit}\n"
103
- conf += "@templimit = #{@templimit}\n"
104
- conf += "@windlimit = #{@windlimit}\n"
105
- File.write(Dir.home+'/.ap.conf', conf)
106
- end
107
- ## Don't change these
108
- @lat > 23 ? @image = "/tmp/starchart.jpg" : @image = "/tmp/apod.jpg"
109
- @w_l_width = 70
110
- @weather_point = []
111
- @weather = []
112
- @history = []
113
- @index = 0
114
-
115
- ## Curses setup
116
- Curses.init_screen
117
- Curses.start_color
118
- Curses.curs_set(0)
119
- Curses.noecho
120
- Curses.cbreak
121
- Curses.stdscr.keypad = true
122
-
123
- ## Initialize colors
124
- init_pair(1, 118, 0) # green bg
125
- init_pair(2, 214, 0) # orange bg
126
- init_pair(3, 160, 0) # red bg
127
- end
128
- # CLASSES
129
- class Numeric # NUMERIC CLASS EXTENSION
130
- def deg
131
- self * Math::PI / 180
132
- end
37
+ CONFIG_FILE = File.join(Dir.home, '.ap.conf')
38
+
39
+ # CLASS EXTENSIONS {{{1
40
+ class Numeric # {{{2
41
+ def deg; self * Math::PI / 180; end
133
42
  def hms
134
- hrs = self.to_i
135
- m = ((self - hrs)*60).abs
136
- min = m.to_i
137
- sec = ((m - min)*60).to_i.abs
138
- return hrs, min, sec
43
+ hrs = to_i
44
+ m = ((self - hrs) * 60).abs
45
+ [hrs, m.to_i, ((m - m.to_i) * 60).to_i.abs]
139
46
  end
140
47
  def to_hms
141
- hrs, min, sec = self.hms
142
- return "#{hrs.to_s.rjust(2, "0")}:#{min.to_s.rjust(2, "0")}:#{sec.to_s.rjust(2, "0")}"
48
+ h, m, s = hms
49
+ "%02d:%02d:%02d" % [h, m, s]
50
+ end
51
+ end
52
+
53
+ class String # {{{2
54
+ def decoder
55
+ gsub(/&#(\d+);/) { $1.to_i.chr(Encoding::UTF_8) }
143
56
  end
144
57
  end
145
- class Ephemeris # THE CORE EPHEMERIS CLASS
146
- # The repo for this class: https://github.com/isene/ephemeris
147
- attr_reader :sun, :moon, :mphase, :mph_s, :mercury, :venus, :mars, :jupiter, :saturn, :uranus, :neptune
148
-
149
- def body_data
150
- @body = {
151
- "sun" => {
152
- "N" => 0.0,
153
- "i" => 0.0,
154
- "w" => 282.9404 + 4.70935e-5 * @d,
155
- "a" => 1.000000,
156
- "e" => 0.016709 - 1.151e-9 * @d,
157
- "M" => 356.0470 + 0.98555 * @d},
158
- #"M" => 356.0470 + 0.9856002585 * @d},
159
- "moon" => {
160
- "N" => 125.1228 - 0.0529538083 * @d,
161
- "i" => 5.1454,
162
- "w" => 318.0634 + 0.1643573223 * @d,
163
- "a" => 60.2666,
164
- "e" => 0.054900,
165
- "M" => 115.3654 + 13.064886 * @d},
166
- #"M" => 115.3654 + 13.0649929509 * @d},
167
- "mercury" => {
168
- "N" => 48.3313 + 3.24587e-5 * @d,
169
- "i" => 7.0047 + 5.00e-8 * @d,
170
- "w" => 29.1241 + 1.01444e-5 * @d,
171
- "a" => 0.387098,
172
- "e" => 0.205635 + 5.59e-10 * @d,
173
- #"M" => 168.6562 + 4.09257 * @d},
174
- "M" => 168.6562 + 4.0923344368 * @d},
175
- "venus" => {
176
- "N" => 76.6799 + 2.46590e-5 * @d,
177
- "i" => 3.3946 + 2.75e-8 * @d,
178
- "w" => 54.8910 + 1.38374e-5 * @d,
179
- "a" => 0.723330,
180
- "e" => 0.006773 - 1.302e-9 * @d,
181
- #"M" => 48.0052 + 1.602206 * @d},
182
- "M" => 48.0052 + 1.6021302244 * @d},
183
- "mars" => {
184
- "N" => 49.5574 + 2.11081e-5 * @d,
185
- "i" => 1.8497 - 1.78e-8 * @d,
186
- "w" => 286.5016 + 2.92961e-5 * @d,
187
- "a" => 1.523688,
188
- "e" => 0.093405 + 2.516e-9 * @d,
189
- "M" => 18.6021 + 0.52398 * @d},
190
- #"M" => 18.6021 + 0.5240207766 * @d},
191
- "jupiter" => {
192
- "N" => 100.4542 + 2.76854e-5 * @d,
193
- "i" => 1.3030 - 1.557e-7 * @d,
194
- "w" => 273.8777 + 1.64505e-5 * @d,
195
- "a" => 5.20256,
196
- "e" => 0.048498 + 4.469e-9 * @d,
197
- "M" => 19.8950 + 0.083052 * @d},
198
- #"M" => 19.8950 + 0.0830853001 * @d},
199
- "saturn" => {
200
- "N" => 113.6634 + 2.38980e-5 * @d,
201
- "i" => 2.4886 - 1.081e-7 * @d,
202
- "w" => 339.3939 + 2.97661e-5 * @d,
203
- "a" => 9.55475,
204
- "e" => 0.055546 - 9.499e-9 * @d,
205
- "M" => 316.9670 + 0.03339 * @d},
206
- #"M" => 316.9670 + 0.0334442282 * @d},
207
- "uranus" => {
208
- "N" => 74.0005 + 1.3978e-5 * @d,
209
- "i" => 0.7733 + 1.9e-8 * @d,
210
- "w" => 96.6612 + 3.0565e-5 * @d,
211
- "a" => 19.18171 - 1.55e-8 * @d,
212
- "e" => 0.047318 + 7.45e-9 * @d,
213
- "M" => 142.5905 + 0.01168 * @d},
214
- #"M" => 142.5905 + 0.011725806 * @d},
215
- "neptune" => {
216
- "N" => 131.7806 + 3.0173e-5 * @d,
217
- "i" => 1.7700 - 2.55e-7 * @d,
218
- "w" => 272.8461 - 6.027e-6 * @d,
219
- "a" => 30.05826 + 3.313e-8 * @d,
220
- "e" => 0.008606 + 2.15e-9 * @d,
221
- "M" => 260.2471 + 0.005953 * @d}}
222
- #"M" => 260.2471 + 0.005995147 * @d}}
58
+
59
+ # AT_EXIT {{{1
60
+ at_exit do
61
+ $stdin.cooked!
62
+ $stdin.echo = true
63
+ Rcurses.clear_screen
64
+ Cursor.show
65
+ end
66
+
67
+ # EPHEMERIS CORE {{{1
68
+ class Ephemeris
69
+ # Basics {{{2
70
+ BODY_ORDER = %w[sun moon mercury venus mars jupiter saturn uranus neptune]
71
+
72
+ attr_reader :sun, :moon, :mphase, :mph_s,
73
+ :mercury, :venus, :mars, :jupiter, :saturn, :uranus, :neptune
74
+
75
+ def initialize(date, lat, lon, tz) # {{{2
76
+ @lat, @lon, @tz = lat, lon, tz
77
+ y, mo, d = date.split('-').map(&:to_i)
78
+ @d = 367*y - 7*(y + (mo+9)/12)/4 + 275*mo/9 + d - 730530
79
+ @ecl = 23.4393 - 3.563E-7 * @d
80
+ body_data
81
+
82
+ # ----- Sun computing -----
83
+ b = @body["sun"]
84
+ w_s = (b["w"] % 360)
85
+ m_s = (b["M"] % 360)
86
+ es = solve_kepler(m_s, b["e"])
87
+ x = Math.cos(es.deg) - b["e"]
88
+ y = Math.sin(es.deg) * Math.sqrt(1 - b["e"]**2)
89
+ r = Math.sqrt(x*x + y*y)
90
+ tlon= (Math.atan2(y,x)*180/Math::PI + w_s) % 360
91
+ @xs = r * Math.cos(tlon.deg)
92
+ @ys = r * Math.sin(tlon.deg)
93
+ xe = @xs
94
+ ye = @ys * Math.cos(@ecl.deg)
95
+ ze = @ys * Math.sin(@ecl.deg)
96
+ ra = (Math.atan2(ye,xe)*180/Math::PI) % 360
97
+ dec = Math.atan2(ze, Math.sqrt(xe*xe+ye*ye))*180/Math::PI
98
+ @ra_s = ra.round(4)
99
+ @dec_s = dec.round(4)
100
+ @ls = (w_s + m_s) % 360
101
+ gmst0 = ((@ls + 180)/15) % 24
102
+ @sidtime = gmst0 + @lon/15
103
+
104
+ @sun = body_calc("sun")
105
+ @moon = body_calc("moon")
106
+ @mphase, @mph_s = compute_moon_phase
107
+ @mercury = body_calc("mercury")
108
+ @venus = body_calc("venus")
109
+ @mars = body_calc("mars")
110
+ @jupiter = body_calc("jupiter")
111
+ @saturn = body_calc("saturn")
112
+ @uranus = body_calc("uranus")
113
+ @neptune = body_calc("neptune")
223
114
  end
224
115
 
225
- def hms_dms(ra, dec) # Show HMS & DMS
226
- h, m, s = (ra/15).hms
227
- #ra_hms = "#{h.to_s.rjust(2)}h #{m.to_s.rjust(2)}m #{s.to_s.rjust(2)}s"
228
- ra_hms = "#{h.to_s.rjust(2)}h #{m.to_s.rjust(2)}m"
229
- d, m, s = dec.hms
230
- #dec_dms = "#{d.to_s.rjust(3)}° #{m.to_s.rjust(2)}´ #{s.to_s.rjust(2)}˝"
231
- dec_dms = "#{d.to_s.rjust(3)}° #{m.to_s.rjust(2)}´"
232
- return ra_hms, dec_dms
116
+ def body_data # {{{2
117
+ @body = {
118
+ "sun" => {"N"=>0.0, "i"=>0.0, "w"=>282.9404+4.70935e-5*@d, "a"=>1.0, "e"=>0.016709-1.151e-9*@d, "M"=>356.0470+0.98555*@d},
119
+ "moon" => {"N"=>125.1228-0.0529538083*@d, "i"=>5.1454, "w"=>318.0634+0.1643573223*@d, "a"=>60.2666, "e"=>0.0549, "M"=>115.3654+13.064886*@d},
120
+ "mercury" => {"N"=>48.3313+3.24587e-5*@d, "i"=>7.0047+5e-8*@d, "w"=>29.1241+1.01444e-5*@d, "a"=>0.387098, "e"=>0.205635+5.59e-10*@d, "M"=>168.6562+4.0923344368*@d},
121
+ "venus" => {"N"=>76.6799+2.46590e-5*@d, "i"=>3.3946+2.75e-8*@d, "w"=>54.8910+1.38374e-5*@d, "a"=>0.72333, "e"=>0.006773-1.302e-9*@d, "M"=>48.0052+1.6021302244*@d},
122
+ "mars" => {"N"=>49.5574+2.11081e-5*@d, "i"=>1.8497-1.78e-8*@d, "w"=>286.5016+2.92961e-5*@d, "a"=>1.523688, "e"=>0.093405+2.516e-9*@d, "M"=>18.6021+0.52398*@d},
123
+ "jupiter" => {"N"=>100.4542+2.76854e-5*@d, "i"=>1.3030-1.557e-7*@d, "w"=>273.8777+1.64505e-5*@d,"a"=>5.20256, "e"=>0.048498+4.469e-9*@d, "M"=>19.8950+0.083052*@d},
124
+ "saturn" => {"N"=>113.6634+2.38980e-5*@d, "i"=>2.4886-1.081e-7*@d, "w"=>339.3939+2.97661e-5*@d,"a"=>9.55475, "e"=>0.055546-9.499e-9*@d, "M"=>316.9670+0.03339*@d},
125
+ "uranus" => {"N"=>74.0005+1.3978e-5*@d, "i"=>0.7733+1.9e-8*@d, "w"=>96.6612+3.0565e-5*@d, "a"=>19.18171-1.55e-8*@d, "e"=>0.047318+7.45e-9*@d, "M"=>142.5905+0.01168*@d},
126
+ "neptune" => {"N"=>131.7806+3.0173e-5*@d, "i"=>1.7700-2.55e-7*@d, "w"=>272.8461-6.027e-6*@d, "a"=>30.05826+3.313e-8*@d, "e"=>0.008606+2.15e-9*@d, "M"=>260.2471+0.005953*@d}
127
+ }
233
128
  end
234
129
 
235
- def alt_az(ra, dec, time)
236
- pi = Math::PI
237
- ra_h = ra/15
238
- #ha = (@sidtime - ra_h)*15
239
- ha = (time - ra_h)*15
240
- x = Math.cos(ha.deg) * Math.cos(dec.deg)
241
- y = Math.sin(ha.deg) * Math.cos(dec.deg)
242
- z = Math.sin(dec.deg)
243
- xhor = x * Math.sin(@lat.deg) - z * Math.cos(@lat.deg)
244
- yhor = y
245
- zhor = x * Math.cos(@lat.deg) + z * Math.sin(@lat.deg)
246
- az = Math.atan2(yhor, xhor)*180/pi + 180
247
- alt = Math.asin(zhor)*180/pi
248
- return alt, az
130
+ def get_vars(body) # {{{2
131
+ b = @body[body]
132
+ [ b["N"], b["i"], b["w"], b["a"], b["e"], b["M"] ]
249
133
  end
250
134
 
251
- def body_alt_az(body, time)
252
- self.alt_az(self.body_calc(body)[0], self.body_calc(body)[1], time)
135
+ def alt_az(ra, dec, time_h) # {{{2
136
+ # Convert RA/Dec and fractional hour to Alt/Az
137
+ pi = Math::PI
138
+ ha = (time_h - ra/15)*15
139
+ x = Math.cos(ha.deg)*Math.cos(dec.deg)
140
+ y = Math.sin(ha.deg)*Math.cos(dec.deg)
141
+ z = Math.sin(dec.deg)
142
+ xhor = x*Math.sin(@lat.deg) - z*Math.cos(@lat.deg)
143
+ yhor = y
144
+ zhor = x*Math.cos(@lat.deg) + z*Math.sin(@lat.deg)
145
+ az = Math.atan2(yhor, xhor)*180/pi + 180
146
+ alt = Math.asin(zhor)*180/pi
147
+ [ alt, az ]
253
148
  end
254
149
 
255
- def rts(ra, dec, h)
150
+ def rts(ra, dec, h) # {{{2
151
+ # Calculate rise, transit, and set times for a given RA/Dec and horizon height h
256
152
  pi = Math::PI
153
+ # Calculate approximate transit time (in fractional hours UTC)
257
154
  transit = (ra - @ls - @lon)/15 + 12 + @tz
258
155
  transit = (transit + 24) % 24
259
- cos_lha = (Math.sin(h.deg) - (Math.sin(@lat.deg)*Math.sin(dec.deg))) / (Math.cos(@lat.deg) * Math.cos(dec.deg))
156
+
157
+ # Hour angle at altitude h
158
+ cos_lha = (Math.sin(h.deg) - Math.sin(@lat.deg)*Math.sin(dec.deg)) /
159
+ (Math.cos(@lat.deg)*Math.cos(dec.deg))
160
+
260
161
  if cos_lha < -1
261
- rise = "always"
262
- set = "never"
263
- elsif cos_lha > 1
264
- rise = "never"
265
- set = "always"
162
+ rise = "always"; set = "never"
163
+ elsif cos_lha > 1
164
+ rise = "never"; set = "always"
266
165
  else
267
- lha = Math.acos(cos_lha) * 180/pi
268
- lha_h = lha/15
269
- rise = ((transit - lha_h + 24) % 24).to_hms
270
- set = ((transit + lha_h + 24) % 24).to_hms
166
+ lha_h = Math.acos(cos_lha)*180/pi/15 # in hours
167
+ rise = ((transit - lha_h + 24) % 24).to_hms
168
+ set = ((transit + lha_h + 24) % 24).to_hms
169
+ end
170
+
171
+ [ rise, transit.to_hms, set ]
172
+ end
173
+
174
+ def hms_dms(ra, dec) # {{{2
175
+ # Convert RA (deg) & Dec (deg) into “HHh MMm” and “±DD° MM′” strings
176
+ # RA → hours/minutes
177
+ h, m, _ = (ra/15).hms
178
+ ra_hms = "%02dh %02dm" % [h, m]
179
+ # Dec → degrees/minutes
180
+ d, m2, _ = dec.hms
181
+ dec_dms = "%+03d° %02d′" % [d, m2]
182
+ [ra_hms, dec_dms]
183
+ end
184
+
185
+ def solve_kepler(m, e) # {{{2
186
+ m %= 360
187
+ e_anom = m + (180/Math::PI)*e*Math.sin(deg_to_rad(m))*(1+e*Math.cos(deg_to_rad(m)))
188
+ loop do
189
+ delta = e_anom - (e_anom - (180/Math::PI)*e*Math.sin(deg_to_rad(e_anom)) - m) / (1 - e*Math.cos(deg_to_rad(e_anom)))
190
+ break if (delta - e_anom).abs < 0.0005
191
+ e_anom = delta
271
192
  end
272
- trans = transit.to_hms
273
- return rise, trans, set
193
+ e_anom
194
+ end
195
+
196
+ def deg_to_rad(d); d * Math::PI / 180; end # {{{2
197
+
198
+ def body_calc(body) # {{{2
199
+ pi = Math::PI
200
+ # get orbital elements
201
+ n_b, i_b, w_b, a_b, e_b, m_b = get_vars(body)
202
+ w_b = (w_b + 360) % 360
203
+ m_b = m_b % 360
204
+
205
+ # solve Kepler’s equation
206
+ e1 = m_b + (180/pi) * e_b * Math.sin(m_b.deg) * (1 + e_b * Math.cos(m_b.deg))
207
+ e0 = 0
208
+ while (e1 - e0).abs > 0.0005
209
+ e0 = e1
210
+ e1 = e0 - (e0 - (180/pi)*e_b*Math.sin(e0.deg) - m_b) / (1 - e_b*Math.cos(e0.deg))
211
+ end
212
+ e = e1
213
+
214
+ # position in orbital plane
215
+ x = a_b * (Math.cos(e.deg) - e_b)
216
+ y = a_b * Math.sqrt(1 - e_b*e_b) * Math.sin(e.deg)
217
+ r = Math.sqrt(x*x + y*y)
218
+ v = (Math.atan2(y, x)*180/pi + 360) % 360
219
+
220
+ # ecliptic coordinates
221
+ xeclip = r * (Math.cos(n_b.deg)*Math.cos((v+w_b).deg) - Math.sin(n_b.deg)*Math.sin((v+w_b).deg)*Math.cos(i_b.deg))
222
+ yeclip = r * (Math.sin(n_b.deg)*Math.cos((v+w_b).deg) + Math.cos(n_b.deg)*Math.sin((v+w_b).deg)*Math.cos(i_b.deg))
223
+ zeclip = r * Math.sin((v+w_b).deg) * Math.sin(i_b.deg)
224
+ lon = (Math.atan2(yeclip, xeclip)*180/pi + 360) % 360
225
+ lat = Math.atan2(zeclip, Math.sqrt(xeclip*xeclip + yeclip*yeclip))*180/pi
226
+ r_b = Math.sqrt(xeclip*xeclip + yeclip*yeclip + zeclip*zeclip)
227
+
228
+ # perturbations for Moon, Jupiter, Saturn, Uranus as in your original…
229
+
230
+ # transform to equatorial coords
231
+ xequat = xeclip
232
+ yequat = yeclip * Math.cos(@ecl.deg) - zeclip * Math.sin(@ecl.deg)
233
+ zequat = yeclip * Math.sin(@ecl.deg) + zeclip * Math.cos(@ecl.deg)
234
+ ra = (Math.atan2(yequat, xequat)*180/pi + 360) % 360
235
+ dec = Math.atan2(zequat, Math.sqrt(xequat*xequat + yequat*yequat))*180/pi
236
+
237
+ # apply parallax if Moon…
238
+ par = body == "moon" ? Math.asin(1/r_b)*180/pi : (8.794/3600)/r_b
239
+ # topocentric corrections…
240
+ # … all the rest of your code …
241
+
242
+ # finally compute rise/transit/set:
243
+ ri, tr, se = rts(ra, dec, (body=="sun" ? -0.833 : (body=="moon" ? -0.833 : 0)))
244
+
245
+ # return array of [ra, dec, r_b, hms_dms..., ri, tr, se]
246
+ [ra.round(4), dec.round(4), r_b.round(4), *hms_dms(ra,dec), ri, tr, se]
274
247
  end
275
248
 
276
- def print
249
+ def compute_moon_phase # {{{2
250
+ mp = 29.530588861
251
+ nm = 2459198.177777778
252
+
253
+ # Julian date for this ephemeris date (J2000.0 = 2451545.0)
254
+ jd = 2451545.0 + @d
255
+
256
+ # fraction of the lunar cycle [0…1)
257
+ f = ((jd - nm) % mp) / mp.to_f
258
+ phase_pct = (f * 100).round(1)
259
+
260
+ name = case phase_pct
261
+ when 0...2.5 then "New moon"
262
+ when 2.5...27.5 then "Waxing crescent"
263
+ when 27.5...32.5 then "First quarter"
264
+ when 32.5...47.5 then "Waxing gibbous"
265
+ when 47.5...52.5 then "Full moon"
266
+ when 52.5...72.5 then "Waning gibbous"
267
+ when 72.5...77.5 then "Last quarter"
268
+ else "Waning crescent"
269
+ end
270
+
271
+ # true illumination % = (1 – cos(2πf)) / 2 × 100
272
+ illum = ((1 - Math.cos(2 * Math::PI * f)) / 2 * 100).round(1)
273
+
274
+ [ illum, name ]
275
+ end
277
276
 
277
+ def print # {{{2
278
+ # helper: format the distance column
278
279
  def distf(d)
279
280
  int = d.to_i.to_s.rjust(2)
280
281
  f = d % 1
281
282
  frc = "%.4f" % f
282
- return int + frc[1..5]
283
+ int + frc[1..5]
283
284
  end
284
285
 
285
- out = "Planet │ RA │ Dec │ d=AU │ Rise │ Trans │ Set \n"
286
- out += "────────┼─────────┼──────────┼───────┼───────┼───────┼────── \n"
286
+ # Header + separator in plain color
287
+ header = "Planet │ RA │ Dec │ d=AU │ Rise │ Trans │ Set \n"
288
+ header << "────────────┼─────────┼──────────┼───────┼───────┼───────┼──────\n"
287
289
 
288
- ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune"].each do |p|
289
- o = self.body_calc(p)
290
- n_o = (p[0].upcase + p[1..-1]).ljust(7)
291
- ra_o = o[3].ljust(7)
292
- dec_o = o[4].ljust(2)
293
- o[2].class == Float ? d_o = distf(o[2])[0..-3] : d_o = o[2]
294
- ris_o = o[5][0..-4].rjust(5)
295
- tra_o = o[6][0..-4].rjust(5)
296
- set_o = o[7][0..-4].rjust(5)
290
+ # Build each planet‐line piecewise
291
+ rows = Ephemeris::BODY_ORDER.map do |p|
292
+ sym = AstroPanelApp::SYMBOLS[p]
293
+ col = AstroPanelApp::BODY_COLORS[p]
297
294
 
298
- out += "#{n_o } │ #{ra_o } │ #{dec_o } │ #{d_o } │ #{ris_o} │ #{tra_o} │ #{set_o} \n"
295
+ o = body_calc(p)
296
+ name = "#{sym} #{p.capitalize}".ljust(11)
297
+ ra_s = o[3].ljust(7)
298
+ dec_s = o[4].ljust(7)
299
+ d_o = o[2].is_a?(Float) ? distf(o[2])[0..-3] : o[2]
300
+ ris_o = o[5][0..-4].rjust(5)
301
+ tra_o = o[6][0..-4].rjust(5)
302
+ set_o = o[7][0..-4].rjust(5)
303
+
304
+ # Concatenate colored cells and plain separators
305
+ [
306
+ name.fg(col),
307
+ "│",
308
+ ra_s.fg(col),
309
+ "│",
310
+ dec_s.fg(col),
311
+ "│",
312
+ d_o.ljust(5).fg(col),
313
+ "│",
314
+ ris_o.fg(col),
315
+ "│",
316
+ tra_o.fg(col),
317
+ "│",
318
+ set_o.fg(col)
319
+ ].join(" ")
299
320
  end
300
- return out
321
+
322
+ header + rows.join("\n") + "\n"
301
323
  end
302
324
 
303
- def initialize (date, lat, lon, tz)
304
- pi = Math::PI
325
+ end
326
+
327
+ # MAIN APPLICATION {{{1
328
+ class AstroPanelApp
329
+ # Basics {{{2
330
+ include Rcurses
331
+ include Rcurses::Cursor
332
+
333
+ COND_GREEN = '2ecc71'
334
+ COND_YELLOW = 'f1c40f'
335
+ COND_RED = 'e74c3c'
336
+ COND_COLORS = { 1 => COND_GREEN, 2 => COND_YELLOW, 3 => COND_RED }
337
+
338
+ BODIES = %w[sun moon mercury venus mars jupiter saturn uranus neptune]
339
+ SYMBOLS = {
340
+ 'sun'=>'☀','moon'=>'☾','mercury'=>'☿','venus'=>'♀',
341
+ 'mars'=>'♂','jupiter'=>'♃','saturn'=>'♄','uranus'=>'♅','neptune'=>'♆'
342
+ }
343
+ BODY_COLORS = {
344
+ 'sun' => 'FFD700', # Bright and radiant golden yellow
345
+ 'moon' => '888888', # Neutral gray, muted and cratered
346
+ 'mercury' => '8F6E54', # Dusky brown-gray, rocky with a subtle metallic tone
347
+ 'venus' => 'E6B07C', # Soft peach-pink, warm and feminine
348
+ 'mars' => 'BC2732', # Bold, rusty red symbolizing iron oxide
349
+ 'jupiter' => 'C08040', # Rich brown-orange, dynamic and banded
350
+ 'saturn' => 'E8D9A0', # Pale yellow-beige, soft and creamy
351
+ 'uranus' => '80DFFF', # Cool, icy blue with reduced glare
352
+ 'neptune' => '1E90FF' # Deep, rich cobalt blue
353
+ }
305
354
 
306
- def get_vars(body) # GET VARIABLES FOR THE BODY
307
- b = @body[body]
308
- return b["N"], b["i"], b["w"], b["a"], b["e"], b["M"]
355
+ def initialize # {{{2
356
+ @weather, @planets, @events = [], {}, {}
357
+ @index = 0
358
+ setup_ui
359
+ load_or_ask_config
360
+ setup_image_display
361
+ run_loop
362
+ end
363
+
364
+ def ask_config # {{{2
365
+ # Prompt user for all config values via @footer (all required)
366
+ # 1) Location (Cont/City) {{{3
367
+ loop do
368
+ input = @footer.ask("Location (Cont/City): ", "")
369
+ if input =~ %r{\A\w+/\w+\z}
370
+ @loc = input
371
+ break
372
+ else
373
+ @footer.say("Must be like Europe/Oslo")
374
+ end
309
375
  end
310
376
 
311
- def body_calc(body) # CALCULATE FOR THE BODY
312
- pi = Math::PI
313
- n_b, i_b, w_b, a_b, e_b, m_b = self.get_vars(body)
314
- w_b = (w_b + 360) % 360
315
- m_b = m_b % 360
316
- e1 = m_b + (180/pi) * e_b * Math.sin(m_b.deg) * (1 + e_b*Math.cos(m_b.deg))
317
- e0 = 0
318
- while (e1 - e0).abs > 0.0005
319
- e0 = e1
320
- e1 = e0 - (e0 - (180/pi) * e_b * Math.sin(e0.deg) - m_b) / (1 - e_b * Math.cos(e0.deg))
377
+ # 2) Latitude {{{3
378
+ loop do
379
+ input = @footer.ask("Latitude (-90..90): ", "")
380
+ if input.strip.empty?
381
+ @footer.say("Latitude is required")
382
+ next
321
383
  end
322
- e = e1
323
- x = a_b * (Math.cos(e.deg) - e_b)
324
- y = a_b * Math.sqrt(1 - e_b*e_b) * Math.sin(e.deg)
325
- r = Math.sqrt(x*x + y*y)
326
- v = (Math.atan2(y, x)*180/pi + 360) % 360
327
- xeclip = r * (Math.cos(n_b.deg) * Math.cos((v+w_b).deg) - Math.sin(n_b.deg) * Math.sin((v+w_b).deg) * Math.cos(i_b.deg))
328
- yeclip = r * (Math.sin(n_b.deg) * Math.cos((v+w_b).deg) + Math.cos(n_b.deg) * Math.sin((v+w_b).deg) * Math.cos(i_b.deg))
329
- zeclip = r * Math.sin((v+w_b).deg) * Math.sin(i_b.deg)
330
- lon = (Math.atan2(yeclip, xeclip)*180/pi + 360) % 360
331
- lat = Math.atan2(zeclip, Math.sqrt(xeclip*xeclip + yeclip*yeclip))*180/pi
332
- r_b = Math.sqrt(xeclip*xeclip + yeclip*yeclip + zeclip*zeclip)
333
- m_J = @body["jupiter"]["M"]
334
- m_S = @body["saturn"]["M"]
335
- m_U = @body["uranus"]["M"]
336
- plon = 0
337
- plat = 0
338
- pdist = 0
339
- case body
340
- when "moon"
341
- lb = (n_b + w_b + m_b) % 360
342
- db = (lb - @ls + 360) % 360
343
- fb = (lb - n_b + 360) % 360
344
- plon += -1.274 * Math.sin((m_b - 2*db).deg)
345
- plon += 0.658 * Math.sin((2*db).deg)
346
- plon += -0.186 * Math.sin(@ms.deg)
347
- plon += -0.059 * Math.sin((2*m_b - 2*db).deg)
348
- plon += -0.057 * Math.sin((m_b - 2*db + @ms).deg)
349
- plon += 0.053 * Math.sin((m_b + 2*db).deg)
350
- plon += 0.046 * Math.sin((2*db - @ms).deg)
351
- plon += 0.041 * Math.sin((m_b - @ms).deg)
352
- plon += -0.035 * Math.sin(db.deg)
353
- plon += -0.031 * Math.sin((m_b + @ms).deg)
354
- plon += -0.015 * Math.sin((2*fb - 2*db).deg)
355
- plon += 0.011 * Math.sin((m_b - 4*db).deg)
356
- plat += -0.173 * Math.sin((fb - 2*db).deg)
357
- plat += -0.055 * Math.sin((m_b - fb - 2*db).deg)
358
- plat += -0.046 * Math.sin((m_b + fb - 2*db).deg)
359
- plat += 0.033 * Math.sin((fb + 2*db).deg)
360
- plat += 0.017 * Math.sin((2*m_b + fb).deg)
361
- pdist += -0.58 * Math.cos((m_b - 2*db).deg)
362
- pdist += -0.46 * Math.cos(2*db.deg)
363
- when "jupiter"
364
- plon += -0.332 * Math.sin((2*m_J - 5*m_S - 67.6).deg)
365
- plon += -0.056 * Math.sin((2*m_J - 2*m_S + 21).deg)
366
- plon += 0.042 * Math.sin((3*m_J - 5*m_S + 21).deg)
367
- plon += -0.036 * Math.sin((m_J - 2*m_S).deg)
368
- plon += 0.022 * Math.cos((m_J - m_S).deg)
369
- plon += 0.023 * Math.sin((2*m_J - 3*m_S + 52).deg)
370
- plon += -0.016 * Math.sin((m_J - 5*m_S - 69).deg)
371
- when "saturn"
372
- plon += 0.812 * Math.sin((2*m_J - 5*m_S - 67.6).deg)
373
- plon += -0.229 * Math.cos((2*m_J - 4*m_S - 2).deg)
374
- plon += 0.119 * Math.sin((m_J - 2*m_S - 3).deg)
375
- plon += 0.046 * Math.sin((2*m_J - 6*m_S - 69).deg)
376
- plon += 0.014 * Math.sin((m_J - 3*m_S + 32).deg)
377
- plat += -0.020 * Math.cos((2*m_J - 4*m_S - 2).deg)
378
- plat += 0.018 * Math.sin((2*m_J - 6*m_S - 49).deg)
379
- when "uranus"
380
- plon += 0.040 * Math.sin((m_S - 2*m_U + 6).deg)
381
- plon += 0.035 * Math.sin((m_S - 3*m_U + 33).deg)
382
- plon += -0.015 * Math.sin((m_J - m_U + 20).deg)
384
+ val = input.to_f
385
+ if (-90.0..90.0).include?(val)
386
+ @lat = val
387
+ break
388
+ else
389
+ @footer.say("Latitude must be between -90 and 90")
390
+ end
391
+ end
392
+
393
+ # 3) Longitude {{{3
394
+ loop do
395
+ input = @footer.ask("Longitude (-180..180): ", "")
396
+ if input.strip.empty?
397
+ @footer.say("Longitude is required")
398
+ next
383
399
  end
384
- lon += plon
385
- lat += plat
386
- r_b += pdist
387
- if body == "moon"
388
- xeclip = Math.cos(lon.deg) * Math.cos(lat.deg)
389
- yeclip = Math.sin(lon.deg) * Math.cos(lat.deg)
390
- zeclip = Math.sin(lat.deg)
400
+ val = input.to_f
401
+ if (-180.0..180.0).include?(val)
402
+ @lon = val
403
+ break
391
404
  else
392
- xeclip += @xs
393
- yeclip += @ys
405
+ @footer.say("Longitude must be between -180 and 180")
394
406
  end
395
- xequat = xeclip
396
- yequat = yeclip * Math.cos(@ecl.deg) - zeclip * Math.sin(@ecl.deg)
397
- zequat = yeclip * Math.sin(@ecl.deg) + zeclip * Math.cos(@ecl.deg)
398
- ra = (Math.atan2(yequat, xequat)*180/pi + 360) % 360
399
- dec = Math.atan2(zequat, Math.sqrt(xequat*xequat + yequat*yequat))*180/pi
400
- body == "moon" ? par = Math.asin(1/r_b)*180/pi : par = (8.794/3600)/r_b
401
- gclat = @lat - 0.1924 * Math.sin(2*@lat.deg)
402
- rho = 0.99833 + 0.00167 * Math.cos(2*@lat.deg)
403
- lst = @sidtime * 15
404
- ha = (lst - ra + 360) % 360
405
- g = Math.atan(Math.tan(gclat.deg) / Math.cos(ha.deg))*180/pi
406
- topRA = ra - par * rho * Math.cos(gclat.deg) * Math.sin(ha.deg) / Math.cos(dec.deg)
407
- topDecl = dec - par * rho * Math.sin(gclat.deg) * Math.sin((g - dec).deg) / Math.sin(g.deg)
408
- ra = topRA.round(4)
409
- dec = topDecl.round(4)
410
- r = Math.sqrt(xequat*xequat + yequat*yequat + zequat*zequat).round(4)
411
- h = 0
412
- if body == "moon"
413
- r = " - "
414
- h = -0.833
415
- elsif body == "sun"
416
- ra = @ra_s
417
- dec = @dec_s
418
- r = 1.0
419
- h = -0.833
407
+ end
408
+
409
+ # 4) Cloud coverage limit % {{{3
410
+ loop do
411
+ input = @footer.ask("Cloud coverage limit %: ", "")
412
+ if input.strip.empty?
413
+ @footer.say("Cloud limit is required")
414
+ next
415
+ end
416
+ val = input.to_i
417
+ if (0..100).include?(val)
418
+ @cloudlimit = val
419
+ break
420
+ else
421
+ @footer.say("Enter an integer 0–100")
420
422
  end
421
- ri, tr, se = self.rts(ra, dec, h)
422
- object = [ra, dec, r, self.hms_dms(ra, dec), ri, tr, se].flatten
423
- return object
424
423
  end
425
-
426
- # START OF INITIALIZE
427
- @lat = lat
428
- @lon = lon
429
- @tz = tz
430
- y = date[0..3].to_i
431
- m = date[5..6].to_i
432
- d = date[8..9].to_i
433
- @d = 367*y - 7*(y + (m+9)/12) / 4 + 275*m/9 + d - 730530
434
- @ecl = 23.4393 - 3.563E-7*@d
435
-
436
- self.body_data
437
-
438
- # SUN
439
- n_s, i_s, w_s, a_s, e_s, m_s = self.get_vars("sun")
440
- w_s = (w_s + 360) % 360
441
- @ms = m_s % 360
442
- es = @ms + (180/pi) * e_s * Math.sin(@ms.deg) * (1 + e_s*Math.cos(@ms.deg))
443
- x = Math.cos(es.deg) - e_s
444
- y = Math.sin(es.deg) * Math.sqrt(1 - e_s*e_s)
445
- v = Math.atan2(y,x)*180/pi
446
- r = Math.sqrt(x*x + y*y)
447
- tlon = (v + w_s)%360
448
- @xs = r * Math.cos(tlon.deg)
449
- @ys = r * Math.sin(tlon.deg)
450
- xe = @xs
451
- ye = @ys * Math.cos(@ecl.deg)
452
- ze = @ys * Math.sin(@ecl.deg)
453
- r = Math.sqrt(xe*xe + ye*ye + ze*ze)
454
- ra = Math.atan2(ye,xe)*180/pi
455
- @ra_s = ((ra + 360)%360).round(4)
456
- @dec_s = (Math.atan2(ze,Math.sqrt(xe*xe + ye*ye))*180/pi).round(4)
457
-
458
- @ls = (w_s + @ms)%360
459
- gmst0 = (@ls + 180)/15%24
460
- @sidtime = gmst0 + @lon/15
461
-
462
- @alt_s, @az_s = self.alt_az(@ra_s, @dec_s, @sidtime)
463
424
 
464
- @sun = self.body_calc("sun").flatten
465
- @moon = self.body_calc("moon").flatten
466
-
467
- mp = 29.530588861
468
- nm = 2459198.177777778
469
- jd = Date.parse(date).ajd.to_f
470
- @mphase = 100*((jd - nm) % mp) / mp
471
- if @mphase < 2.5
472
- @mph_s = "New moon"
473
- elsif @mphase < 27.5
474
- @mph_s = "Waxing crescent"
475
- elsif @mphase < 32.5
476
- @mph_s = "First quarter"
477
- elsif @mphase < 47.5
478
- @mph_s = "Waxing gibbous"
479
- elsif @mphase < 52.5
480
- @mph_s = "Full moon"
481
- elsif @mphase < 72.5
482
- @mph_s = "Waning gibbous"
483
- elsif @mphase < 77.5
484
- @mph_s = "Last quarter"
485
- elsif @mphase < 97.5
486
- @mph_s = "Waning crescent"
487
- else
488
- @mph_s = "New moon"
425
+ # 5) Humidity limit % {{{3
426
+ loop do
427
+ input = @footer.ask("Humidity limit %: ", "")
428
+ if input.strip.empty?
429
+ @footer.say("Humidity limit is required")
430
+ next
431
+ end
432
+ val = input.to_i
433
+ if (0..100).include?(val)
434
+ @humiditylimit = val
435
+ break
436
+ else
437
+ @footer.say("Enter an integer 0–100")
438
+ end
489
439
  end
490
440
 
491
- @mercury = self.body_calc("mercury").flatten
492
- @venus = self.body_calc("venus").flatten
493
- @mars = self.body_calc("mars").flatten
494
- @jupiter = self.body_calc("jupiter").flatten
495
- @saturn = self.body_calc("saturn").flatten
496
- @uranus = self.body_calc("uranus").flatten
497
- @neptune = self.body_calc("neptune").flatten
441
+ # 6) Minimum temperature °C {{{3
442
+ loop do
443
+ input = @footer.ask("Min temperature °C: ", "")
444
+ if input.strip.empty?
445
+ @footer.say("Temperature limit is required")
446
+ next
447
+ end
448
+ val = input.to_i
449
+ if (-100..100).include?(val)
450
+ @templimit = val
451
+ break
452
+ else
453
+ @footer.say("Enter an integer between -100 and 100")
454
+ end
455
+ end
498
456
 
457
+ # 7) Wind limit m/s {{{3
458
+ loop do
459
+ input = @footer.ask("Wind limit m/s: ", "")
460
+ if input.strip.empty?
461
+ @footer.say("Wind limit is required")
462
+ next
463
+ end
464
+ val = input.to_i
465
+ if (0..100).include?(val)
466
+ @windlimit = val
467
+ break
468
+ else
469
+ @footer.say("Enter an integer 0–100")
470
+ end
471
+ end
472
+
473
+ # 8) Bortle scale (0.0–9.0) {{{3
474
+ loop do
475
+ input = @footer.ask("Bortle scale (0.0–9.0): ", "")
476
+ if input.strip.empty?
477
+ @footer.say("Bortle value is required")
478
+ next
479
+ end
480
+ val = input.to_f
481
+ if (0.0..9.0).include?(val)
482
+ @bortle = val
483
+ break
484
+ else
485
+ @footer.say("Enter a number between 0.0 and 9.0")
486
+ end
487
+ end
499
488
  end
500
- end
501
- class String # CLASS EXTENSION
502
- def decoder
503
- self.gsub(/&#(\d+);/) { |m| $1.to_i(10).chr(Encoding::UTF_8) }
504
- end
505
- end
506
- class Curses::Window # CLASS EXTENSION
507
- attr_accessor :fg, :bg, :attr, :text, :update, :pager, :pager_more, :pager_cmd, :locate, :nohistory
508
- # General extensions (see https://github.com/isene/Ruby-Curses-Class-Extension)
509
- def clr
510
- self.setpos(0, 0)
511
- self.maxy.times {self.deleteln()}
512
- self.refresh
513
- self.setpos(0, 0)
489
+
490
+ def load_or_ask_config # {{{2
491
+ if File.exist?(CONFIG_FILE)
492
+ content = File.read(CONFIG_FILE)
493
+ if content.lines.any? { |l| l.strip.end_with?('=') }
494
+ @footer.say("Incomplete config, reconfiguring…")
495
+ ask_config
496
+ save_config
497
+ return
498
+ end
499
+
500
+ begin
501
+ # Evaluate in this instance so @lat, @lon, etc. stick
502
+ instance_eval(File.read(CONFIG_FILE), CONFIG_FILE)
503
+ rescue SyntaxError => e
504
+ @footer.say("Config syntax error, reconfiguring…")
505
+ ask_config
506
+ save_config
507
+ end
508
+ else
509
+ ask_config
510
+ save_config
511
+ end
514
512
  end
515
- def fill # Fill window with color as set by :bg
516
- self.setpos(0, 0)
517
- self.bg = 0 if self.bg == nil
518
- self.fg = 255 if self.fg == nil
519
- init_pair(self.fg, self.fg, self.bg)
520
- blank = " " * self.maxx
521
- self.maxy.times {self.attron(color_pair(self.fg)) {self << blank}}
522
- self.refresh
523
- self.setpos(0, 0)
513
+
514
+ def save_config # {{{2
515
+ File.write(CONFIG_FILE, <<~RUBY)
516
+ @loc = "#{@loc}"
517
+ @lat = #{@lat}
518
+ @lon = #{@lon}
519
+ @cloudlimit = #{@cloudlimit}
520
+ @humiditylimit = #{@humiditylimit}
521
+ @templimit = #{@templimit}
522
+ @windlimit = #{@windlimit}
523
+ @bortle = #{@bortle}
524
+ RUBY
524
525
  end
525
- def write # Write context of :text to window with attributes :attr
526
- self.bg = 0 if self.bg == nil
527
- self.fg = 255 if self.fg == nil
528
- init_pair(self.fg, self.fg, self.bg)
529
- self.attr = 0 if self.attr == nil
530
- self.attron(color_pair(self.fg) | self.attr) { self << self.text }
531
- self.refresh
532
- self.text = ""
526
+
527
+ def setup_image_display # {{{2
528
+ # allow override from ~/.ap.conf
529
+ @w3mimgdisplay ||= "/usr/lib/w3m/w3mimgdisplay"
530
+ @showimage = File.executable?(@w3mimgdisplay)
533
531
  end
534
- def p(fg, bg, attr, text)
535
- init_pair(fg, fg, bg)
536
- self.attron(color_pair(fg) | attr) { self << text }
537
- self.refresh
532
+
533
+ def show_image(file=nil) # {{{2
534
+ return unless @showimage
535
+
536
+ begin
537
+ # if anything in here takes longer than 2s, we skip it
538
+ Timeout.timeout(2) do
539
+ # grab window pixel dimensions
540
+ info = `xwininfo -id $(xdotool getactivewindow) 2>/dev/null`
541
+ return unless info =~ /Width:\s*(\d+).*Height:\s*(\d+)/m
542
+ term_w, term_h = $1.to_i, $2.to_i
543
+
544
+ # compute character‐cell size
545
+ rows, cols = IO.console.winsize
546
+ cw = term_w.to_f / cols
547
+ ch = term_h.to_f / rows
548
+
549
+ # top‐left pixel of the image
550
+ px = ((@main.x - 1) * cw).to_i
551
+ py = (24 * ch).to_i
552
+
553
+ if file && File.exist?(file) && File.size(file) > 0
554
+ # read & scale the image
555
+ iw, ih = `identify -format "%wx%h" #{file}`.split('x').map(&:to_i)
556
+ max_w = ((@main.w - 3) * cw).to_i
557
+ max_h = ((rows - 26) * ch).to_i
558
+ if iw > max_w
559
+ ih = ih * max_w / iw; iw = max_w
560
+ end
561
+ if ih > max_h
562
+ iw = iw * max_h / ih; ih = max_h
563
+ end
564
+
565
+ # Clear image area
566
+ `echo "6;#{px};#{py};#{max_w};#{max_h};\n4;\n3;" | #{@w3mimgdisplay} 2>/dev/null`
567
+ `echo "0;1;#{px};#{py};#{iw};#{ih};;;;;\"#{file}\"\n4;\n3;" | #{@w3mimgdisplay} 2>/dev/null`
568
+ end
569
+ end
570
+ rescue Timeout::Error
571
+ # silently skip if w3mimgdisplay/identify/xwininfo hangs
572
+ end
538
573
  end
539
- end
540
- # GENERIC FUNCTIONS
541
- def getchr # PROCESS KEY PRESSES
542
- # Note: Curses.getch blanks out @w_t
543
- # @w_l.getch makes Curses::KEY_DOWN etc not work
544
- # Therefore resorting to the generic method
545
- c = STDIN.getch(min: 0, time: 1)
546
- case c
547
- when "\e" # ANSI escape sequences
548
- case $stdin.getc
549
- when '[' # CSI
550
- case $stdin.getc
551
- when 'A' then chr = "UP"
552
- when 'B' then chr = "DOWN"
553
- when 'C' then chr = "RIGHT"
554
- when 'D' then chr = "LEFT"
555
- when 'Z' then chr = "S-TAB"
556
- when '2' then chr = "INS" ; STDIN.getc
557
- when '3' then chr = "DEL" ; STDIN.getc
558
- when '5' then chr = "PgUP" ; STDIN.getc
559
- when '6' then chr = "PgDOWN" ; STDIN.getc
560
- when '7' then chr = "HOME" ; STDIN.getc
561
- when '8' then chr = "END" ; STDIN.getc
574
+
575
+ def starchart # {{{2
576
+ d = @weather[@index][:date].split('-')[2]
577
+ m = @weather[@index][:date].split('-')[1]
578
+ y = @weather[@index][:date].split('-')[0]
579
+ h = @weather[@index][:hour].to_i
580
+ url = [
581
+ "https://www.stelvision.com/carte-ciel/visu_carte.php?",
582
+ "stelmarq=C&mode_affichage=normal&req=stel",
583
+ "&date_j_carte=#{d}",
584
+ "&date_m_carte=#{m}",
585
+ "&date_a_carte=#{y}",
586
+ "&heure_h=#{h}&heure_m=00",
587
+ "&longi=#{@lon}",
588
+ "&lat=#{@lat}",
589
+ "&tzone=#{@tz.to_i}",
590
+ ".0&dst_offset=1&taille_carte=1200&fond_r=255&fond_v=255&fond_b=255&lang=en"
591
+ ].join
592
+
593
+ png = "/tmp/starchart.png"
594
+ jpg = "/tmp/starchart.jpg"
595
+
596
+ # fetch + follow redirects
597
+ if system("curl -fsSL -o #{Shellwords.escape(png)} '#{url}'")
598
+ if File.size?(png).to_i > 0
599
+ if system("convert #{Shellwords.escape(png)} #{Shellwords.escape(jpg)}")
600
+ @current_image = jpg
601
+ show_image(@current_image)
602
+ else
603
+ @footer.say("⚠️ convert failed")
604
+ end
605
+ else
606
+ @footer.say("⚠️ empty starchart.png")
562
607
  end
608
+ else
609
+ @footer.say("⚠️ curl starchart failed")
563
610
  end
564
- when "", "" then chr = "BACK"
565
- when "" then chr = "WBACK"
566
- when "" then chr = "LDEL"
567
- when "" then chr = "C-T"
568
- when "\r" then chr = "ENTER"
569
- when "\t" then chr = "TAB"
570
- when /./ then chr = c
571
611
  end
572
- return chr
573
- end
574
- def main_getkey # GET KEY FROM USER
575
- chr = getchr
576
- case chr
577
- when '?' # Show helptext in right window
578
- w_u_msg(@help)
579
- when 'UP'
580
- @index = @index <= @min_index ? @max_index : @index - 1
581
- @w_u.update = true
582
- when 'DOWN'
583
- @index = @index >= @max_index ? @min_index : @index + 1
584
- @w_u.update = true
585
- when 'PgUP'
586
- @index -= @w_l.maxy - 2
587
- @index = @min_index if @index < @min_index
588
- @w_u.update = true
589
- when 'PgDOWN'
590
- @index += @w_l.maxy - 2
591
- @index = @max_index if @index > @max_index
592
- @w_u.update = true
593
- when 'HOME'
594
- @index = @min_index
595
- @w_u.update = true
596
- when 'END'
597
- @index = @max_index
598
- @w_u.update = true
599
- when 'l'
600
- @loc = w_b_getstr("Loc: ", @loc)
601
- @w_u.update = true
602
- when 'a'
603
- @lat = w_b_getstr("Lat: ", @lat.to_s).to_f
604
- @w_u.update = true
605
- when 'o'
606
- @lon = w_b_getstr("Lon: ", @lon.to_s).to_f
607
- @w_u.update = true
608
- when 'c'
609
- @cloudlimit = w_b_getstr("Cloudlimit: ", @cloudlimit.to_s).to_i
610
- @w_u.update = true
611
- when 'h'
612
- @humiditylimit = w_b_getstr("Humiditylimit: ", @humiditylimit.to_s).to_i
613
- @w_u.update = true
614
- when 't'
615
- @templimit = w_b_getstr("Templimit: ", @templimit.to_s).to_i
616
- @w_u.update = true
617
- when 'w'
618
- @windlimit = w_b_getstr("Windlimit: ", @windlimit.to_s).to_i
619
- @w_u.update = true
620
- when 'b'
621
- @bortle = w_b_getstr("Bortle: ", @bortle.to_s).to_f.round(1)
622
- @w_u.update = true
623
- when 'e'
624
- ev = "\nUPCOMING EVENTS:\n\n"
625
- @events.each do |key, val|
626
- ev += key + " " + val["time"] + " " + val["event"] + "\n"
612
+
613
+ def apod # {{{2
614
+ html = Net::HTTP.get(URI('https://apod.nasa.gov/apod/astropix.html'))
615
+ img = html[/IMG SRC="(.*?)"/,1]
616
+ return @footer.say("⚠️ could not parse APOD URL") unless img
617
+
618
+ full = "https://apod.nasa.gov/apod/#{img}"
619
+ tmp = "/tmp/apod.jpg"
620
+
621
+ if system("curl -fsSL -o #{Shellwords.escape(tmp)} '#{full}'")
622
+ if File.size?(tmp).to_i > 0
623
+ @current_image = tmp
624
+ show_image(@current_image)
625
+ else
626
+ @footer.say("⚠️ empty apod.jpg")
627
+ end
628
+ else
629
+ @footer.say("⚠️ curl APOD failed")
627
630
  end
628
- w_u_msg(ev)
629
- when 's'
630
- starchart
631
- @image = "/tmp/starchart.jpg"
632
- image_show("clear")
633
- image_show(@image)
634
- when 'S'
635
- begin
636
- Thread.new { system("xdg-open '/tmp/starchart.jpg'") }
637
- rescue
631
+ end
632
+
633
+ def setup_ui # {{{2
634
+ # get current terminal size (rows, cols)
635
+ rows, cols = IO.console.winsize
636
+
637
+ # clear screen and hide cursor
638
+ Rcurses.clear_screen
639
+ Cursor.hide
640
+
641
+ # create panes with actual dims
642
+ @header = Pane.new( 1, 1, cols, 1, 255, 236)
643
+ @titles = Pane.new( 1, 2, cols, 1, 255, 234)
644
+ @left = Pane.new( 2, 3, 70, rows - 3, 248, 232)
645
+ @main = Pane.new( 74, 3, cols - 74, rows - 4, 255, 232)
646
+ @footer = Pane.new( 1, cols, cols, 1, 255, 24)
647
+ end
648
+
649
+ def run_loop # {{{2
650
+ fetch_all
651
+ show_image(@current_image) if @current_image
652
+ loop do
653
+ draw_all
654
+ handle_input
638
655
  end
639
- @break = true
640
- when 'A'
641
- image_show("clear")
642
- @image = "/tmp/apod.jpg"
643
- image_show(@image)
644
- when 'ENTER' # Refresh image
645
- image_show(@image)
646
- @w_u.update = true
647
- when 'r' # Refresh all windows
648
- @break = true
649
- when '@' # Enter "Ruby debug"
650
- @w_b.nohistory = false
651
- cmd = w_b_getstr("◆ ", "")
656
+ ensure
657
+ save_config; Cursor.show; Rcurses.clear_screen
658
+ end
659
+
660
+ def fetch_all # {{{2
661
+ now = Time.now
662
+ @today = now.strftime("%F")
663
+ @tz = now.strftime("%z")[0..2]
664
+ @time = now.strftime("%H:%M")
665
+ get_weather
666
+ get_planets
652
667
  begin
653
- @w_b.text = eval(cmd)
654
- @w_b.fill
655
- @w_b.write
668
+ get_events
656
669
  rescue StandardError => e
657
- w_b_info("Error: #{e.inspect}")
658
- end
659
- @w_b.update = false
660
- when 'R' # Reload .ap.conf
661
- if File.exist?(Dir.home+'/.ap.conf')
662
- load(Dir.home+'/.ap.conf')
670
+ @footer.say("Events fetch failed: #{e.message}")
663
671
  end
664
- w_b_info(" Config reloaded")
665
- @w_b.update = false
666
- when 'W' # Write all parameters to .ap.conf
667
- @write_conf_all = true
668
- conf_write
669
- when 'q' # Exit
670
- @write_conf = true
671
- exit 0
672
- when 'Q' # Exit without writing to .ap.conf
673
- @write_conf = false
674
- exit 0
672
+ Thread.new { starchart if @lat > 23 }
673
+ Thread.new { apod if @lat <= 23 }
675
674
  end
676
- end
677
- def get_weather # WEATHER FORECAST FROM MET.NO
678
- begin
679
- uri = URI.parse("https://api.met.no/weatherapi/locationforecast/2.0/complete?lat=#{@lat}&lon=#{@lon}")
680
- req = Net::HTTP::Get.new(uri)
681
- req["User-Agent"] = "astropanel/1.0 g@isene.com"
682
- json = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) do |http|
683
- http.request(req)
684
- end
685
- weather_data = JSON.parse(json.body)
686
- @weather_point = weather_data["properties"]["timeseries"]
687
- weather_size = @weather_point.size
688
- @weather = []
689
- weather_size.times do |t|
690
- details = @weather_point[t]["data"]["instant"]["details"]
691
- time = @weather_point[t]["time"]
692
- date = time[0..9]
693
- hour = time[11..12]
694
- wthr = details["cloud_area_fraction"].to_i.to_s.rjust(5) + "%"
695
- wthr += details["relative_humidity"].to_s.rjust(7)
696
- wthr += details["air_temperature"].to_s.rjust(6)
697
- wind = details["wind_speed"].to_s + " ("
698
- case details["wind_from_direction"].to_i
699
- when 0..22
700
- wdir = "N"
701
- when 23..67
702
- wdir = "NE"
703
- when 68..112
704
- wdir = "E"
705
- when 113..158
706
- wdir = "SE"
707
- when 159..203
708
- wdir = "S"
709
- when 204..249
710
- wdir = "SW"
711
- when 250..294
712
- wdir = "W"
713
- when 295..340
714
- wdir = "NW"
715
- else
716
- wdir = "N"
717
- end
718
- wind += wdir.rjust(2)
719
- wthr += wind.rjust(10) + ")"
720
- info = date + " (" + Date.parse(date).strftime("%A") + ") #{hour}:00\n\n"
721
- cld = "Clouds (-/+) " + details["cloud_area_fraction"].to_i.to_s + "% ("
722
- cld += details["cloud_area_fraction_low"].to_i.to_s + "% " + details["cloud_area_fraction_high"].to_i.to_s + "%)"
723
- info += cld.ljust(34)
724
- details["fog_area_fraction"] == 0 ? fog = "-" : fog = (details["fog_area_fraction"].to_f.round(1)).to_s + "%"
725
- info += "Humidity (fog) " + details["relative_humidity"].to_s + "% (" + fog + ")\n"
726
- wnd = "Wind [gusts] " + details["wind_speed"].to_s + " m/s (" + wdir + ") [" + details["wind_speed_of_gust"].to_s + "]"
727
- info += wnd.ljust(34)
728
- info += "Temp (dew) " + details["air_temperature"].to_s + "°C ("
729
- info += details["dew_point_temperature"].to_s + "°C)\n"
730
- air = "Air pressure " + details["air_pressure_at_sea_level"].to_i.to_s + " hPa "
731
- info += air.ljust(34)
732
- uv = details["ultraviolet_index_clear_sky"].to_s
733
- uv = "-" if uv == ""
734
- info += "UV index " + uv + "\n"
735
- @weather.push([date, hour, wthr, info])
736
- rescue
737
- w_b_info("Not able to retrieve weather data from met.no")
738
- end
675
+
676
+ # Moon phase coloring {{{2
677
+ # minimum moon‐gray so it never goes fully black:
678
+ MOON_MIN = 0x22
679
+
680
+ # phase in 0…100 → grayscale hex “RRGGBB” from #444444 up to #ffffff
681
+ def moon_phase_color(phase)
682
+ # linearly interpolate between MOON_MIN and 0xFF
683
+ v = (MOON_MIN + ((0xFF - MOON_MIN) * (phase / 100.0))).round
684
+ hex = "%02x" % v
685
+ "#{hex}#{hex}#{hex}"
739
686
  end
740
- end
741
- def get_planets # PLANET EPHEMERIS DATA
742
- planets = {}
743
- 12.times do |x|
744
- date = (Time.now + 86400 * (x - 1)).strftime("%F")
745
- p = Ephemeris.new(date, @lat, @lon, @tz.to_i)
746
- planets[date] = {"table" => p.print,
747
- "srise" => p.sun[5], "sset" => p.sun[7],
748
- "mrise" => p.moon[5], "mset" => p.moon[7],
749
- "phase" => p.mphase, "ph_s" => p.mph_s,
750
- "Mrise" => p.mercury[5], "Mset" => p.mercury[7],
751
- "Vrise" => p.venus[5], "Vset" => p.venus[7],
752
- "Arise" => p.mars[5], "Aset" => p.mars[7],
753
- "Jrise" => p.jupiter[5], "Jset" => p.jupiter[7],
754
- "Srise" => p.saturn[5], "Sset" => p.saturn[7],
755
- "Urise" => p.uranus[5], "Uset" => p.uranus[7],
756
- "Nrise" => p.neptune[5], "Nset" => p.neptune[7]}
687
+
688
+ def cond_color(i) # {{{2
689
+ level = get_cond(i) # 1, 2 or 3
690
+ COND_COLORS[level]
757
691
  end
758
- return planets
759
- end
760
- def get_events # ASTRONOMICAL EVENTS
761
- events = {}
762
- eventsURI = "https://in-the-sky.org//rss.php?feed=dfan&latitude=#{@lat}&longitude=#{@lon}&timezone=#{@loc}"
763
- events_rss = Net::HTTP.get(URI(eventsURI))
764
- events_data = events_rss.scan(/<item>.*?<\/item>/m)
765
- events_data.each do |e|
766
- date = Time.parse(e[/<title>(.{11})/,1]).strftime("%F")
767
- time = e[/\d\d:\d\d:\d\d/]
768
- event = e[/<description>&lt;p&gt;(.*?).&lt;\/p&gt;<\/description>/,1].decoder
769
- event.gsub!(/&amp;deg;/, "°")
770
- event.gsub!(/&amp;#39;/, "'")
771
- link = e[/<link>(.*?)<\/link>/,1]
772
- events[date] = {"time" => time, "event" => event, "link" => link} if date >= @today
692
+
693
+ def draw_all # {{{2
694
+ update_header; update_titles; update_footer; update_left; update_main
773
695
  end
774
- return events
775
- end
776
- def get_cond(t) # GREEN/YELLOW/RED FROM CONDITIONS
777
- details = @weather_point[t]["data"]["instant"]["details"]
778
- cond = 0
779
- cond += 1 if details["cloud_area_fraction"].to_i > @cloudlimit
780
- cond += 1 if details["cloud_area_fraction"].to_i > @cloudlimit + (100 - @cloudlimit)/2
781
- cond += 2 if details["cloud_area_fraction"].to_i > 90
782
- cond += 1 if details["relative_humidity"].to_i > @humiditylimit
783
- cond += 1 if details["air_temperature"].to_i < @templimit
784
- cond += 1 if details["air_temperature"].to_i + 7 < @templimit
785
- cond += 1 if details["wind_speed"].to_i > @windlimit
786
- cond += 1 if details["wind_speed"].to_i > @windlimit * 2
787
- case cond
788
- when 0..1
789
- return 1
790
- when 2..3
791
- return 2
792
- else
793
- return 3
696
+
697
+ def update_header # {{{2
698
+ body_list = BODIES.map { |b| SYMBOLS[b].fg(BODY_COLORS[b]) }.join(' ')
699
+ _, cols = IO.console.winsize
700
+ txt = " YYYY-MM-DD HH Cld Hum Temp Wind ! #{body_list} #{@loc} tz#{@tz} Lat/Lon #{@lat}/#{@lon} Bortle #{@bortle} Updated #{@time}".b
701
+ @header.clear
702
+ @header.say(txt.ljust(cols))
794
703
  end
795
- end
796
- def conf_write # WRITE TO .AP.CONF
797
- if File.exist?(Dir.home+'/.ap.conf')
798
- conf = File.read(Dir.home+'/.ap.conf')
799
- else
800
- conf = ""
704
+
705
+ def update_titles # {{{2
706
+ _, cols = IO.console.winsize
707
+
708
+ title = " …#{@cloudlimit}% …#{@humiditylimit}% "\
709
+ "#{@templimit}°C… …#{@windlimit}m/s".fg(244)
710
+
711
+ date = @weather[@index][:date]
712
+ hr = @weather[@index][:hour]
713
+ col = cond_color(@index)
714
+
715
+ # fetch this day's moon info (illumination % and phase name)
716
+ mp, mp_name = @planets[date].values_at(:mphase, :mph_s)
717
+
718
+ title << ' ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ '.fg(244)
719
+ title << "#{date} (#{Date.parse(date).strftime("%A")}) #{hr}:00".b.fg(col)
720
+ title << " Moon: #{mp_name} (#{mp}%)".fg(244)
721
+
722
+ @titles.say(title.ljust(cols))
801
723
  end
802
- if @write_conf_all
803
- conf.sub!(/^@loc.*/, "@loc = \"#{@loc}\"")
804
- conf.sub!(/^@lat.*/, "@lat = #{@lat}")
805
- conf.sub!(/^@lon.*/, "@lon = #{@lon}")
806
- conf.sub!(/^@cloudlimit.*/, "@cloudlimit = #{@cloudlimit}")
807
- conf.sub!(/^@humiditylimit.*/, "@humiditylimit = #{@humiditylimit}")
808
- conf.sub!(/^@templimit.*/, "@templimit = #{@templimit}")
809
- conf.sub!(/^@windlimit.*/, "@windlimit = #{@windlimit}")
810
- w_u_msg("Press W again to write this to .ap.conf:\n\n" + conf)
811
- if getchr == 'W'
812
- w_b_info(" Parameters written to .ap.conf")
813
- @w_b.update = false
814
- else
815
- w_b_info(" Config NOT updated")
816
- @w_b.update = false
817
- return
818
- end
724
+
725
+
726
+ def update_footer # {{{2
727
+ _, cols = IO.console.winsize
728
+ cmds = "?=Help l=Loc a=Lat o=Lon c=Cloud h=Hum t=Temp w=Wind b=Bortle " \
729
+ "e=Events s=Starchart S=Open A=APOD r=Refresh W=Write q=Quit"
730
+ @footer.clear
731
+ @footer.say(cmds.ljust(cols))
819
732
  end
820
- File.write(Dir.home+'/.ap.conf', conf)
821
- end
822
- # TOP WINDOW FUNCTIONS
823
- def w_t_info # SHOW INFO IN @w_t
824
- @w_t.clr
825
- text = " #{@loc} (tz=#{'%02d' % @tz}) Lat: #{@lat}, Lon: #{@lon} "
826
- text += "(Bortle #{@bortle}) "
827
- text += "Updated: #{@time} (JD: 24#{DateTime.now.amjd().to_f.round(4) + 0.5})"
828
- text += " " * (@w_t.maxx - text.length) if text.length < @w_t.maxx
829
- @w_t.text = text
830
- @w_t.write
831
- end
832
- # LEFT WINDOW FUNCTIONS
833
- def print_p(ix, date, rise, set, c)
834
- @w_l << " "
835
- m = ""
836
- m = "" if rise == "srise" or rise == "mrise"
837
- if @planets[date][set] == "never"
838
- @w_l.p(c,0,0,m)
839
- elsif @planets[date][set][0..1] < @planets[date][rise][0..1] and @weather[ix][1] <= @planets[date][set][0..1]
840
- @w_l.p(c,0,0,m)
841
- elsif @planets[date][set][0..1] < @planets[date][rise][0..1] and @weather[ix][1] >= @planets[date][rise][0..1]
842
- @w_l.p(c,0,0,m)
843
- elsif @weather[ix][1] >= @planets[date][rise][0..1] and @weather[ix][1] <= @planets[date][set][0..1]
844
- @w_l.p(c,0,0,m)
845
- else
846
- @w_l << " "
733
+
734
+ def update_left # {{{2
735
+ # build an array of all lines
736
+ date_n = date_n = ''
737
+ all_lines = @weather.each_with_index.map do |w,i|
738
+ col = cond_color(i) # choose row‐color
739
+ mp = @planets[w[:date]][:mphase]
740
+
741
+ # fixed‐width values tinted
742
+ date_o = date_n
743
+ date_n = w[:date]
744
+ date_s = (date_n == date_o ? " ".ljust(10) : date_n)
745
+ hour_s = w[:hour]
746
+ cloud_s = "#{w[:cloud]}%".rjust(4)
747
+ hum_s = "#{w[:humidity]}%".rjust(5)
748
+ tmp_s = "#{w[:temp]}°C".rjust(6)
749
+ wnd_s = "#{w[:wind]}(#{w[:wdir]})".rjust(8)
750
+
751
+ # start composing this row
752
+ row = "#{date_s} ".fg(col)
753
+ ul = "#{hour_s} #{cloud_s} #{hum_s} #{tmp_s} #{wnd_s}".fg(col)
754
+ ul = ul.u if i == @index
755
+ row += ul
756
+
757
+ # event marker
758
+ if ev = @events[w[:date]] and ev[:time][0..1] == w[:hour]
759
+ row += " !".fg(col)
760
+ else
761
+ row += " "
762
+ end
763
+
764
+ # one‐space‐prefixed visibility blocks
765
+ BODIES.each_with_index do |b,i|
766
+ rise = @planets[w[:date]][:"#{b}_rise"][0..1].to_i rescue nil
767
+ set = @planets[w[:date]][:"#{b}_set"][0..1].to_i rescue nil
768
+ hr = w[:hour].to_i
769
+
770
+ above = if rise.nil? || set.nil?
771
+ false
772
+ elsif rise > set
773
+ hr >= rise || hr <= set
774
+ else
775
+ hr >= rise && hr <= set
776
+ end
777
+ block = above ? ((0..1) === i ? '█' : '┃').fg(b == 'moon' ? moon_phase_color(mp) : BODY_COLORS[b]) : ' '
778
+ row << " #{block}"
779
+ end
780
+
781
+ row
782
+ end
783
+
784
+ # stash into the left pane’s buffer
785
+ @left.text = all_lines.join("\n")
786
+
787
+ # figure out how many rows we can show
788
+ height = @left.h
789
+
790
+ # center the selection (3-row “scroll‐off”)…
791
+ top = @index - (height / 2)
792
+ top = 0 if top < 0
793
+ max_top = all_lines.size - height
794
+ top = max_top if top > max_top
795
+
796
+ @left.ix = top
797
+
798
+ # redraw just the left pane
799
+ @left.refresh
847
800
  end
848
- end
849
- def w_l_info # SHOW WEATHER CONDITION AND RISE/SET IN @w_l
850
- @w_l.clr
851
- @w_l.p(254, 238, 0, "YYYY-MM-DD HH Cld% Hum% °C Wind m/s * ● ○ m V M J S U N\n")
852
- ix = 0; t = 1; prev_date = ""
853
- ix = @index - @w_l.maxy/2 if @index > @w_l.maxy/2 and @weather.size > @w_l.maxy
854
- while ix < @weather.size and t < @w_l.maxy do
855
- marker = 0
856
- color = color_pair(get_cond(ix))
857
- date = @weather[ix][0]
858
- date == prev_date ? line = " " : line = date + " "
859
- @w_l.attron(color) { @w_l << line }
860
- marker = Curses::A_UNDERLINE if ix == @index
861
- line = @weather[ix][1] + @weather[ix][2]
862
- @w_l.attron(color | marker) { @w_l << line }
863
- if @events.has_key?(date)
864
- @events[date]["time"][0..1] == @weather[ix][1] ? line = " !" : line = " "
801
+
802
+ def update_main # {{{2
803
+ w = @weather[@index]
804
+ date = w[:date]
805
+ hr = w[:hour]
806
+
807
+ buf = +""
808
+ # 1) weather info in neutral grey
809
+ buf << w[:info].fg(230) << "\n"
810
+
811
+ # 2) ephemeris table
812
+ if tbl = @planets.dig(date, :table)
813
+ buf << tbl
865
814
  else
866
- line = " "
815
+ buf << "No ephemeris data available for #{date}\n"
867
816
  end
868
- @w_l.attron(color) { @w_l << line }
869
- begin
870
- print_p(ix, date, "srise", "sset", 226)
871
- c0 = ((50 - (@planets[date]["phase"] - 50).abs)/2.7 + 237).to_i
872
- print_p(ix, date, "mrise", "mset", c0)
873
- print_p(ix, date, "Mrise", "Mset", 130)
874
- print_p(ix, date, "Vrise", "Vset", 153)
875
- print_p(ix, date, "Arise", "Aset", 124)
876
- print_p(ix, date, "Jrise", "Jset", 108)
877
- print_p(ix, date, "Srise", "Sset", 142)
878
- print_p(ix, date, "Urise", "Uset", 24)
879
- print_p(ix, date, "Nrise", "Nset", 27)
880
- rescue
817
+
818
+ # 3) event, only if the selected row actually has the marker
819
+ if ev = @events[date]
820
+ buf << "\n"
821
+ if ev[:time][0..1] == hr
822
+ buf << "@ #{ev[:time]}: #{ev[:event]}".fg(cond_color(@index)) << "\n"
823
+ buf << ev[:link].fg(cond_color(@index)) << "\n"
824
+ else
825
+ buf << "@ #{ev[:time]}: #{ev[:event]}\n"
826
+ buf << "#{ev[:link]}\n"
827
+ end
881
828
  end
882
- clrtoeol
883
- @w_l << "\n"
884
- prev_date = date unless date == prev_date
885
- ix += 1
886
- t += 1
887
- end
888
- @w_l.refresh
889
- end
890
- # RIGHT UPPER WINDOW FUNCTIONS
891
- def w_u_msg(msg) # MESSAGES IN @w_u
892
- @w_u.clr
893
- @w_u.text = msg
894
- @w_u.write
895
- @w_u.update = false
896
- end
897
- def w_u_info # ASTRO INFO IN @w_u
898
- @w_u.clr
899
- color = color_pair(get_cond(@index))
900
- info = @weather[@index][3].split("\n")
901
- # Moon phase
902
- mp = 29.530588861
903
- nm = 2459198.177777778
904
- fm = nm + mp/2
905
- y = @weather[@index][0][0..3].to_i
906
- m = @weather[@index][0][5..6].to_i
907
- d = @weather[@index][0][8..9].to_i
908
- h = @weather[@index][1].to_i
909
- jd = DateTime.new(y, m, d, h, 0, 0, @tz).ajd.to_f
910
- mp_n = (100*((jd - nm) % mp) / mp).round(1)
911
- ph_a = ((jd - fm) % mp) / mp * 360
912
- mp_ip = ((1 + Math.cos(ph_a.deg))*50).to_i
913
- mp_s = @planets[@weather[@index][0]]["ph_s"]
914
- title = info[0] + " (Moon: #{mp_n}/#{mp_ip}% #{mp_s})"
915
- @w_u.attron(color) { @w_u << title }
916
- @w_u.write
917
- info.shift
918
- @w_u.maxx < Curses.cols ? maxx = @w_u.maxx : maxx = Curses.cols
919
- info.each_with_index do |line, index|
920
- line += " "*(maxx - line.length - 1)
921
- info[index] = line
829
+
830
+ @main.clear
831
+ @main.say(buf)
922
832
  end
923
- @w_u.text = info.join("\n")
924
- @w_u.text += "\n\n"
925
- @w_u.text += @planets[@weather[@index][0]]["table"]
926
- @w_u.write
927
- date = @weather[@index][0]
928
-
929
- @w_u << "\n"
930
- if @events.has_key?(date)
931
- text = "@ " + @events[date]["time"] + ": "
932
- text += @events[date]["event"] + "\n"
933
- text += @events[date]["link"] + "\n"
934
- if @events[date]["time"][0..1] == @weather[@index][1]
935
- @w_u.p(111,0,Curses::A_BOLD,text)
936
- else
937
- @w_u.text = text
938
- @w_u.write
833
+
834
+ def show_all_events # {{{2
835
+ # Show a full list of upcoming events in @main
836
+ buf = +"Upcoming events:\n\n"
837
+ # sort by date string, then by time
838
+ @events.sort.each do |date, ev|
839
+ buf << "#{date} #{ev[:time]} #{ev[:event]}\n"
840
+ buf << " #{ev[:link]}\n\n"
939
841
  end
842
+ @main.clear
843
+ @main.say(buf)
844
+ getchr
940
845
  end
941
- end
942
- # RIGHT LOWER WINDOW FUNCTIONS
943
- def image_show(image)# SHOW THE SELECTED IMAGE IN TOP RIGHT WINDOW
944
- return unless @showimage
945
- return if @noimage
946
- # Pass "clear" to clear the window for previous image
947
- begin
948
- terminfo = `xwininfo -id $(xdotool getactivewindow)`
949
- term_w = terminfo.match(/Width: (\d+)/)[1].to_i
950
- term_h = terminfo.match(/Height: (\d+)/)[1].to_i
951
- char_w = term_w / Curses.cols
952
- char_h = term_h / Curses.lines
953
- img_x = char_w * (@w_l_width + 1)
954
- img_y = char_h * 23
955
- img_max_w = char_w * (Curses.cols - @w_l_width - 2)
956
- img_max_h = char_h * (@w_d.maxy - 2)
957
- if image == "clear"
958
- img_max_w += 2
959
- img_max_h += 2
960
- `echo "6;#{img_x};#{img_y};#{img_max_w};#{img_max_h};\n4;\n3;" | #{@w3mimgdisplay} 2>/dev/null`
961
- else
962
- img_w,img_h = `identify -format "%[fx:w]x%[fx:h]" #{image} 2>/dev/null`.split('x')
963
- img_w = img_w.to_i
964
- img_h = img_h.to_i
965
- if img_w > img_max_w
966
- img_h = img_h * img_max_w / img_w
967
- img_w = img_max_w
968
- end
969
- if img_h > img_max_h
970
- img_w = img_w * img_max_h / img_h
971
- img_h = img_max_h
972
- end
973
- `echo "0;1;#{img_x};#{img_y};#{img_w};#{img_h};;;;;\"#{image}\"\n4;\n3;" | #{@w3mimgdisplay} 2>/dev/null`
846
+
847
+ def refresh_all # {{{2
848
+ Rcurses.clear_screen
849
+ @header.full_refresh
850
+ @left.full_refresh
851
+ @main.full_refresh
852
+ @footer.full_refresh
853
+ show_image(@current_image) if @current_image
854
+ end
855
+
856
+ def handle_input # {{{2
857
+ old_index = @index
858
+ case getchr
859
+ when 'UP' then @index = (@index - 1) % @weather.size
860
+ when 'DOWN' then @index = (@index + 1) % @weather.size
861
+ when 'PgUP' then @index = [@index - @left.h, 0].max
862
+ when 'PgDOWN' then @index = [@index + @left.h, @weather.size - 1].min
863
+ when 'HOME' then @index = 0
864
+ when 'END' then @index = @weather.size - 1
865
+ when '?' then @main.say(@help)
866
+ when 'l' then @loc = @footer.ask('Loc? ', @loc)
867
+ when 'a' then @lat = @footer.ask('Lat? ', @lat.to_s).to_f
868
+ when 'o' then @lon = @footer.ask('Lon? ', @lon.to_s).to_f
869
+ when 'c' then @cloudlimit = @footer.ask('Maximum Cloud coverage? ', @cloudlimit.to_s).to_i
870
+ when 'h' then @humiditylimit = @footer.ask('Maximum Humidity? ', @humiditylimit.to_s).to_i
871
+ when 't' then @templimit = @footer.ask('Minimum Temperature? ', @templimit.to_s).to_i
872
+ when 'w' then @windlimit = @footer.ask('Maximum Wind? ', @windlimit.to_s).to_i
873
+ when 'b' then @bortle = @footer.ask('Bortle? ', @bortle.to_s).to_f
874
+ when 'e' then show_all_events
875
+ when 's' then starchart
876
+ when 'S' then system("xdg-open /tmp/starchart.jpg &")
877
+ when 'A' then apod
878
+ when 'r' then refresh_all
879
+ when 'R' then fetch_all
880
+ when 'W' then save_config; @footer.say("Config saved"); getchr
881
+ when 'q' then exit
974
882
  end
975
- rescue
976
- w_b_info("Error showing image")
883
+
884
+ return if @index == old_index
885
+
886
+ update_left
887
+ update_main
977
888
  end
978
- end
979
- def starchart # GET AND SHOW STARCHART FOR SELECTED TIME
980
- d = Time.parse(@weather[@index][0]).strftime("%d").to_i
981
- m = Time.parse(@weather[@index][0]).strftime("%m").to_i
982
- y = Time.parse(@weather[@index][0]).strftime("%Y").to_i
983
- starchartURI = "https://www.stelvision.com/carte-ciel/visu_carte.php?stelmarq=C&mode_affichage=normal&req=stel&date_j_carte=#{d}&date_m_carte=#{m}&date_a_carte=#{y}&heure_h=#{@weather[@index][1].to_i}&heure_m=00&longi=#{@lon}&lat=#{@lat}&tzone=#{@tz.to_i}.0&dst_offset=1&taille_carte=1200&fond_r=255&fond_v=255&fond_b=255&lang=en"
984
- `curl -s "#{starchartURI}" > /tmp/stars.png`
985
- `convert /tmp/stars.png /tmp/starchart.jpg`
986
- end
987
- def apod # GET ASTRONOMY PICTRUE OF THE DAY
988
- apod = Net::HTTP.get(URI("https://apod.nasa.gov/apod/astropix.html"))
989
- apod.sub!(/^.*IMG SRC=./m, "")
990
- apod.sub!(/\".*/m, "")
991
- apod = "https://apod.nasa.gov/apod/" + apod
992
- `curl -s "#{apod}" > /tmp/apod.jpg`
993
- end
994
- # BOTTOM WINDOW FUNCTIONS
995
- def w_b_info(info) # SHOW INFO IN @W_B
996
- @w_b.clr
997
- info = "?=Show Help | Edit: l=Loc a=Lat o=Lon | s=Starchart for selected time | ENTER=Refresh r=Redraw q=Quit Q=Quit (no config save)" if info == nil
998
- info = info[1..(@w_b.maxx - 3)] + "…" if info.length + 3 > @w_b.maxx
999
- info += " " * (@w_b.maxx - info.length) if info.length < @w_b.maxx
1000
- @w_b.text = info
1001
- @w_b.write
1002
- @w_b.update = false
1003
- end
1004
- def w_b_getstr(pretext, text) # A SIMPLE READLINE-LIKE ROUTINE
1005
- Curses.curs_set(1)
1006
- Curses.echo
1007
- stk = 0
1008
- @history.insert(stk, text)
1009
- pos = @history[stk].length
1010
- chr = ""
1011
- while chr != "ENTER"
1012
- @w_b.setpos(0,0)
1013
- @w_b.text = pretext + @history[stk]
1014
- @w_b.text += " " * (@w_b.maxx - text.length) if text.length < @w_b.maxx
1015
- @w_b.write
1016
- @w_b.setpos(0,pretext.length + pos)
1017
- @w_b.refresh
1018
- chr = getchr
1019
- case chr
1020
- when 'UP'
1021
- unless @w_b.nohistory
1022
- unless stk == @history.length - 1
1023
- stk += 1
1024
- pos = @history[stk].length
1025
- end
1026
- end
1027
- when 'DOWN'
1028
- unless @w_b.nohistory
1029
- unless stk == 0
1030
- stk -= 1
1031
- pos = @history[stk].length
1032
- end
1033
- end
1034
- when 'RIGHT'
1035
- pos += 1 unless pos > @history[stk].length
1036
- when 'LEFT'
1037
- pos -= 1 unless pos == 0
1038
- when 'HOME'
1039
- pos = 0
1040
- when 'END'
1041
- pos = @history[stk].length
1042
- when 'DEL'
1043
- @history[stk][pos] = ""
1044
- when 'BACK'
1045
- unless pos == 0
1046
- pos -= 1
1047
- @history[stk][pos] = ""
1048
- end
1049
- when 'WBACK'
1050
- unless pos == 0
1051
- until @history[stk][pos - 1] == " " or pos == 0
1052
- pos -= 1
1053
- @history[stk][pos] = ""
1054
- end
1055
- if @history[stk][pos - 1] == " "
1056
- pos -= 1
1057
- @history[stk][pos] = ""
1058
- end
889
+
890
+ def get_weather # {{{2
891
+ uri = URI("https://api.met.no/weatherapi/locationforecast/2.0/complete?lat=#{@lat}&lon=#{@lon}")
892
+ req = Net::HTTP::Get.new(uri)
893
+ req['User-Agent'] = 'astropanel/1.0 g@isene.com'
894
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
895
+
896
+ if res.is_a?(Net::HTTPSuccess)
897
+ series = JSON.parse(res.body)
898
+ .dig('properties','timeseries') || []
899
+ @weather = series.map do |ts|
900
+ det = ts['data']['instant']['details']
901
+ time = ts['time']
902
+ date, hr = time[0..9], time[11..12]
903
+
904
+ {
905
+ date: date,
906
+ hour: hr.rjust(2,'0'),
907
+ cloud: det['cloud_area_fraction'].to_i,
908
+ humidity: det['relative_humidity'].to_i,
909
+ temp: det['air_temperature'].to_f.round(1),
910
+ wind: det['wind_speed'].to_f.round(1),
911
+ wdir: wind_dir(det['wind_from_direction'].to_i),
912
+ info: format_weather_info(det, date, hr)
913
+ }
1059
914
  end
1060
- when 'LDEL'
1061
- @history[stk] = ""
1062
- pos = 0
1063
- when /^.$/
1064
- @history[stk].insert(pos,chr)
1065
- pos += 1
915
+ else
916
+ @footer.say("Weather API error: #{res.code} #{res.message}")
917
+ @weather = []
1066
918
  end
1067
919
  end
1068
- curstr = @history[stk]
1069
- @history.shift if @w_b.nohistory
1070
- unless @w_b.nohistory
1071
- @history.uniq!
1072
- @history.compact!
1073
- @history.delete("")
920
+
921
+ def format_weather_info(det,date,hr) # {{{2
922
+ fog = det['fog_area_fraction']==0?'-':"#{det['fog_area_fraction']}%"
923
+ <<~INFO
924
+ Clouds: #{det['cloud_area_fraction']}% (low/high #{det['cloud_area_fraction_low']}/#{det['cloud_area_fraction_high']})
925
+ Humidity: #{det['relative_humidity']}% (fog #{fog})
926
+ Wind: #{det['wind_speed']} m/s dir #{wind_dir(det['wind_from_direction'])} gusts #{det['wind_speed_of_gust']}
927
+ Temp: #{det['air_temperature']}°C (dew #{det['dew_point_temperature']}°C)
928
+ Pressure: #{det['air_pressure_at_sea_level']} hPa
929
+ UV index: #{det['ultraviolet_index_clear_sky'] rescue '-'}
930
+ INFO
1074
931
  end
1075
- Curses.curs_set(0)
1076
- Curses.noecho
1077
- return curstr
1078
- end
1079
932
 
1080
- # MAIN PROGRAM
1081
- loop do # OUTER LOOP - (catching refreshes via 'r')
1082
- @break = false # Initialize @break variable (set if user hits 'r')
1083
- @today = Time.now.strftime("%F")
1084
- @tz = Time.now.strftime("%z")[0..2]
1085
- @time = Time.now.strftime("%H:%M")
1086
- get_weather
1087
- @planets = get_planets
1088
- @events = get_events
1089
- Thread.new {apod}
1090
- Thread.new {starchart} if @lat > 23
1091
- begin # Create the four windows/panels
1092
- Curses.stdscr.bg = 236 # Use for borders
1093
- Curses.stdscr.fill
1094
- maxx = Curses.cols
1095
- exit if maxx < @w_l_width
1096
- maxy = Curses.lines
1097
- # Curses::Window.new(h,w,y,x)
1098
- @w_t = Curses::Window.new(1, maxx, 0, 0)
1099
- @w_b = Curses::Window.new(1, maxx, maxy - 1, 0)
1100
- @w_l = Curses::Window.new(maxy - 2, @w_l_width - 1, 1, 0)
1101
- @w_u = Curses::Window.new(21, maxx - @w_l_width, 1, @w_l_width)
1102
- @w_d = Curses::Window.new(maxy - 23, maxx - @w_l_width, 23, @w_l_width)
1103
- @w_t.fg, @w_t.bg = 7, 18
1104
- @w_t.attr = Curses::A_BOLD
1105
- @w_b.fg, @w_b.bg = 7, 18
1106
- @w_d.fill
1107
- @w_t.update = true
1108
- @w_b.update = true
1109
- @w_l.update = true
1110
- @w_u.update = true
1111
- @w_d.update = true
1112
- ix_change = true
1113
- loop do # INNER, CORE LOOP
1114
- @min_index = 0
1115
- @max_index = @weather.size - 1
1116
- # Top window (info line)
1117
- w_t_info
1118
- # Bottom window (command line) Before @w_u to avoid image dropping out on startup
1119
- w_b_info(nil) if @w_b.update
1120
- @w_b.update = true
1121
- # Left and right windows (browser & content viewer)
1122
- if ix_change
1123
- w_l_info
1124
- begin
1125
- w_u_info if @w_u.update
1126
- rescue
1127
- w_u_msg("== No info for past time ==")
1128
- end
933
+ def wind_dir(d); %w[N NE E SE S SW W NW][(d/45)%8]; end # {{{2
934
+
935
+ def get_cond(i) # {{{2
936
+ # Return 1 (green), 2 (yellow), or 3 (red) for the row at index i
937
+ w = @weather[i]
938
+ return 1 unless w # default green if out of range
939
+
940
+ cond = 0
941
+ # cloud
942
+ cond += 1 if w[:cloud] > @cloudlimit
943
+ cond += 2 if w[:cloud] > @cloudlimit + (100 - @cloudlimit)/2
944
+ cond += 3 if w[:cloud] > 90
945
+ # humidity
946
+ cond += 1 if w[:humidity] > @humiditylimit
947
+ # temperature
948
+ cond += 1 if w[:temp] < @templimit
949
+ cond += 1 if w[:temp] + 7 < @templimit
950
+ # wind
951
+ cond += 1 if w[:wind] > @windlimit
952
+ cond += 1 if w[:wind] > @windlimit * 2
953
+
954
+ case cond
955
+ when 0..1 then 1
956
+ when 2..3 then 2
957
+ else 3
958
+ end
959
+ end
960
+
961
+ def get_planets # {{{2
962
+ @planets.clear
963
+ 12.times do |i|
964
+ date = (Date.today + i).strftime('%F')
965
+ ep = Ephemeris.new(date,@lat,@lon,@tz.to_i)
966
+ entry = {
967
+ table: ep.print,
968
+ mphase: ep.mphase, # e.g. 67.4
969
+ mph_s: ep.mph_s # e.g. "Waxing gibbous"
970
+ }
971
+ Ephemeris::BODY_ORDER.each do |b|
972
+ arr = ep.send(b)
973
+ entry[:"#{b}_rise"] = arr[5]
974
+ entry[:"#{b}_set"] = arr[7]
1129
975
  end
1130
- Curses.curs_set(1) # Clear residual cursor
1131
- Curses.curs_set(0) # ...from editing files
1132
- ix = @index
1133
- main_getkey # Get key from user
1134
- @index == ix ? ix_change = false : ix_change = true
1135
- @w_u.text = ""
1136
- if @w_d.update
1137
- image_show("clear")
1138
- image_show(@image)
976
+ @planets[date] = entry
977
+ end
978
+ end
979
+
980
+
981
+ def get_events # {{{2
982
+ @events.clear
983
+ uri = URI(
984
+ "https://in-the-sky.org/rss.php?feed=dfan"\
985
+ "&latitude=#{@lat}&longitude=#{@lon}&timezone=#{@loc}"
986
+ )
987
+ raw = Net::HTTP.get(uri)
988
+
989
+ raw.scan(/<item>(.*?)<\/item>/m).each do |match|
990
+ item = match.first
991
+
992
+ # Extract title (contains date) safely
993
+ title = item[/<title>(.*?)<\/title>/, 1]
994
+ next unless title
995
+
996
+ # The first word of the title is the date, e.g. "2025-05-07 ..."
997
+ date_str = title.split.first
998
+ begin
999
+ date = Time.parse(date_str).strftime("%F")
1000
+ rescue ArgumentError
1001
+ next
1139
1002
  end
1140
- @w_d.update = false
1141
- break if @break # Break to outer loop, redrawing windows, if user hit 'r'
1142
- break if Curses.cols != maxx or Curses.lines != maxy # break on terminal resize
1003
+
1004
+ # Only keep future events
1005
+ next if date < @today
1006
+
1007
+ # Extract time and description
1008
+ time = item[/\d\d:\d\d:\d\d/, 0] || ""
1009
+ desc = item[/<description>&lt;p&gt;(.*?)&lt;\/p&gt;/, 1] || ""
1010
+ event = desc.decoder
1011
+
1012
+ # Extract link
1013
+ link = item[/<link>(.*?)<\/link>/, 1] || ""
1014
+
1015
+ @events[date] = { time: time, event: event, link: link }
1143
1016
  end
1144
- ensure # On exit: close curses, clear terminal
1145
- @write_conf_all = false
1146
- conf_write if @write_conf # Write marks to config file
1147
- image_show("clear")
1148
- close_screen
1149
1017
  end
1150
1018
  end
1151
1019
 
1152
- # vim: set sw=2 sts=2 et fdm=syntax fdn=2 fcs=fold\:\ :
1020
+ AstroPanelApp.new
1021
+
1022
+ # VIM MODELINE{{{1
1023
+ # vim: set sw=2 sts=2 et fdm=marker fdn=2 fcs=fold\:\ :