astropanel 1.1.5 → 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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +108 -0
  3. data/bin/astropanel +1023 -0
  4. metadata +13 -18
  5. data/bin/astropanel.rb +0 -1152
data/bin/astropanel ADDED
@@ -0,0 +1,1023 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ # PROGRAM INFO {{{1
5
+ # Name: AstroPanel
6
+ # Language: Pure Ruby, best viewed in VIM
7
+ # Author: Geir Isene <g@isene.com>
8
+ # Web_site: http://isene.com/
9
+ # Github: https://github.com/isene/AstroPanel
10
+ # License: I release all copyright claims. This code is in the public domain.
11
+ # Permission is granted to use, copy modify, distribute, and sell
12
+ # this software for any purpose. I make no guarantee about the
13
+ # suitability of this software for any purpose and I am not liable
14
+ # for any damages resulting from its use. Further, I am under no
15
+ # obligation to maintain or extend this software. It is provided
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)
18
+
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
32
+
33
+ # BASIC SETUP {{{1
34
+ $stdin.raw!
35
+ $stdin.echo = false
36
+
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
42
+ def hms
43
+ hrs = to_i
44
+ m = ((self - hrs) * 60).abs
45
+ [hrs, m.to_i, ((m - m.to_i) * 60).to_i.abs]
46
+ end
47
+ def to_hms
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) }
56
+ end
57
+ end
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")
114
+ end
115
+
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
+ }
128
+ end
129
+
130
+ def get_vars(body) # {{{2
131
+ b = @body[body]
132
+ [ b["N"], b["i"], b["w"], b["a"], b["e"], b["M"] ]
133
+ end
134
+
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 ]
148
+ end
149
+
150
+ def rts(ra, dec, h) # {{{2
151
+ # Calculate rise, transit, and set times for a given RA/Dec and horizon height h
152
+ pi = Math::PI
153
+ # Calculate approximate transit time (in fractional hours UTC)
154
+ transit = (ra - @ls - @lon)/15 + 12 + @tz
155
+ transit = (transit + 24) % 24
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
+
161
+ if cos_lha < -1
162
+ rise = "always"; set = "never"
163
+ elsif cos_lha > 1
164
+ rise = "never"; set = "always"
165
+ else
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
192
+ end
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]
247
+ end
248
+
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
276
+
277
+ def print # {{{2
278
+ # helper: format the distance column
279
+ def distf(d)
280
+ int = d.to_i.to_s.rjust(2)
281
+ f = d % 1
282
+ frc = "%.4f" % f
283
+ int + frc[1..5]
284
+ end
285
+
286
+ # Header + separator in plain color
287
+ header = "Planet │ RA │ Dec │ d=AU │ Rise │ Trans │ Set \n"
288
+ header << "────────────┼─────────┼──────────┼───────┼───────┼───────┼──────\n"
289
+
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]
294
+
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(" ")
320
+ end
321
+
322
+ header + rows.join("\n") + "\n"
323
+ end
324
+
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
+ }
354
+
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
375
+ end
376
+
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
383
+ end
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
399
+ end
400
+ val = input.to_f
401
+ if (-180.0..180.0).include?(val)
402
+ @lon = val
403
+ break
404
+ else
405
+ @footer.say("Longitude must be between -180 and 180")
406
+ end
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")
422
+ end
423
+ end
424
+
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
439
+ end
440
+
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
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
488
+ end
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
512
+ end
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
525
+ end
526
+
527
+ def setup_image_display # {{{2
528
+ # allow override from ~/.ap.conf
529
+ @w3mimgdisplay ||= "/usr/lib/w3m/w3mimgdisplay"
530
+ @showimage = File.executable?(@w3mimgdisplay)
531
+ end
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
573
+ end
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")
607
+ end
608
+ else
609
+ @footer.say("⚠️ curl starchart failed")
610
+ end
611
+ end
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")
630
+ end
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
655
+ end
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
667
+ begin
668
+ get_events
669
+ rescue StandardError => e
670
+ @footer.say("Events fetch failed: #{e.message}")
671
+ end
672
+ Thread.new { starchart if @lat > 23 }
673
+ Thread.new { apod if @lat <= 23 }
674
+ 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}"
686
+ end
687
+
688
+ def cond_color(i) # {{{2
689
+ level = get_cond(i) # 1, 2 or 3
690
+ COND_COLORS[level]
691
+ end
692
+
693
+ def draw_all # {{{2
694
+ update_header; update_titles; update_footer; update_left; update_main
695
+ end
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))
703
+ end
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))
723
+ 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))
732
+ end
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
800
+ end
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
814
+ else
815
+ buf << "No ephemeris data available for #{date}\n"
816
+ end
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
828
+ end
829
+
830
+ @main.clear
831
+ @main.say(buf)
832
+ end
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"
841
+ end
842
+ @main.clear
843
+ @main.say(buf)
844
+ getchr
845
+ end
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
882
+ end
883
+
884
+ return if @index == old_index
885
+
886
+ update_left
887
+ update_main
888
+ 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
+ }
914
+ end
915
+ else
916
+ @footer.say("Weather API error: #{res.code} #{res.message}")
917
+ @weather = []
918
+ end
919
+ end
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
931
+ end
932
+
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]
975
+ end
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
1002
+ end
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 }
1016
+ end
1017
+ end
1018
+ end
1019
+
1020
+ AstroPanelApp.new
1021
+
1022
+ # VIM MODELINE{{{1
1023
+ # vim: set sw=2 sts=2 et fdm=marker fdn=2 fcs=fold\:\ :