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.
- checksums.yaml +4 -4
- data/README.md +108 -0
- data/bin/astropanel +919 -1048
- 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
|
-
#
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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 =
|
135
|
-
m
|
136
|
-
|
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
|
-
|
142
|
-
|
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
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
"
|
172
|
-
"
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
"
|
193
|
-
|
194
|
-
"
|
195
|
-
"
|
196
|
-
"
|
197
|
-
"
|
198
|
-
|
199
|
-
"
|
200
|
-
"
|
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
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
d,
|
230
|
-
|
231
|
-
|
232
|
-
|
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
|
236
|
-
|
237
|
-
|
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
|
252
|
-
|
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
|
-
|
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
|
262
|
-
|
263
|
-
|
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
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
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
|
-
|
273
|
-
|
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
|
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
|
-
|
283
|
+
int + frc[1..5]
|
283
284
|
end
|
284
285
|
|
285
|
-
|
286
|
-
|
286
|
+
# Header + separator in plain color
|
287
|
+
header = "Planet │ RA │ Dec │ d=AU │ Rise │ Trans │ Set \n"
|
288
|
+
header << "────────────┼─────────┼──────────┼───────┼───────┼───────┼──────\n"
|
287
289
|
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
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
|
-
|
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
|
-
|
321
|
+
|
322
|
+
header + rows.join("\n") + "\n"
|
301
323
|
end
|
302
324
|
|
303
|
-
|
304
|
-
|
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
|
-
|
307
|
-
|
308
|
-
|
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
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
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
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
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
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
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
|
-
|
393
|
-
yeclip += @ys
|
405
|
+
@footer.say("Longitude must be between -180 and 180")
|
394
406
|
end
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
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
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
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
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
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
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
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
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
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
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
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
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
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
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
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
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
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
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
|
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
|
-
|
640
|
-
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
@
|
647
|
-
|
648
|
-
|
649
|
-
|
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
|
-
|
654
|
-
@w_b.fill
|
655
|
-
@w_b.write
|
668
|
+
get_events
|
656
669
|
rescue StandardError => e
|
657
|
-
|
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
|
-
|
665
|
-
|
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
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
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
|
-
|
741
|
-
def
|
742
|
-
|
743
|
-
|
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
|
-
|
759
|
-
|
760
|
-
|
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><p>(.*?).<\/p><\/description>/,1].decoder
|
769
|
-
event.gsub!(/&deg;/, "°")
|
770
|
-
event.gsub!(/&#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
|
-
|
775
|
-
|
776
|
-
|
777
|
-
|
778
|
-
|
779
|
-
|
780
|
-
|
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
|
-
|
796
|
-
def
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
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
|
-
|
803
|
-
|
804
|
-
|
805
|
-
|
806
|
-
|
807
|
-
|
808
|
-
|
809
|
-
|
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
|
-
|
821
|
-
|
822
|
-
#
|
823
|
-
|
824
|
-
|
825
|
-
|
826
|
-
|
827
|
-
|
828
|
-
|
829
|
-
|
830
|
-
|
831
|
-
|
832
|
-
|
833
|
-
|
834
|
-
|
835
|
-
|
836
|
-
|
837
|
-
|
838
|
-
|
839
|
-
|
840
|
-
|
841
|
-
|
842
|
-
|
843
|
-
|
844
|
-
|
845
|
-
|
846
|
-
|
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
|
-
|
849
|
-
def
|
850
|
-
|
851
|
-
|
852
|
-
|
853
|
-
|
854
|
-
|
855
|
-
|
856
|
-
|
857
|
-
|
858
|
-
|
859
|
-
@
|
860
|
-
|
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
|
-
|
815
|
+
buf << "No ephemeris data available for #{date}\n"
|
867
816
|
end
|
868
|
-
|
869
|
-
|
870
|
-
|
871
|
-
|
872
|
-
|
873
|
-
|
874
|
-
|
875
|
-
|
876
|
-
|
877
|
-
|
878
|
-
|
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
|
-
|
883
|
-
@
|
884
|
-
|
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
|
-
|
924
|
-
|
925
|
-
|
926
|
-
|
927
|
-
|
928
|
-
|
929
|
-
|
930
|
-
|
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
|
-
|
942
|
-
|
943
|
-
|
944
|
-
|
945
|
-
|
946
|
-
|
947
|
-
|
948
|
-
|
949
|
-
|
950
|
-
|
951
|
-
|
952
|
-
|
953
|
-
|
954
|
-
|
955
|
-
|
956
|
-
|
957
|
-
|
958
|
-
|
959
|
-
|
960
|
-
|
961
|
-
|
962
|
-
|
963
|
-
|
964
|
-
|
965
|
-
|
966
|
-
|
967
|
-
|
968
|
-
|
969
|
-
|
970
|
-
|
971
|
-
|
972
|
-
|
973
|
-
|
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
|
-
|
976
|
-
|
883
|
+
|
884
|
+
return if @index == old_index
|
885
|
+
|
886
|
+
update_left
|
887
|
+
update_main
|
977
888
|
end
|
978
|
-
|
979
|
-
def
|
980
|
-
|
981
|
-
|
982
|
-
|
983
|
-
|
984
|
-
|
985
|
-
|
986
|
-
|
987
|
-
|
988
|
-
|
989
|
-
|
990
|
-
|
991
|
-
|
992
|
-
|
993
|
-
|
994
|
-
|
995
|
-
|
996
|
-
|
997
|
-
|
998
|
-
|
999
|
-
|
1000
|
-
|
1001
|
-
|
1002
|
-
|
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
|
-
|
1061
|
-
@
|
1062
|
-
|
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
|
-
|
1069
|
-
|
1070
|
-
|
1071
|
-
|
1072
|
-
|
1073
|
-
|
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
|
-
|
1081
|
-
|
1082
|
-
|
1083
|
-
|
1084
|
-
|
1085
|
-
|
1086
|
-
|
1087
|
-
|
1088
|
-
|
1089
|
-
|
1090
|
-
|
1091
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
|
1096
|
-
|
1097
|
-
#
|
1098
|
-
|
1099
|
-
|
1100
|
-
|
1101
|
-
|
1102
|
-
|
1103
|
-
|
1104
|
-
|
1105
|
-
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
@
|
1110
|
-
|
1111
|
-
|
1112
|
-
|
1113
|
-
|
1114
|
-
|
1115
|
-
|
1116
|
-
|
1117
|
-
|
1118
|
-
|
1119
|
-
|
1120
|
-
|
1121
|
-
|
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
|
-
|
1131
|
-
|
1132
|
-
|
1133
|
-
|
1134
|
-
|
1135
|
-
|
1136
|
-
|
1137
|
-
|
1138
|
-
|
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
|
-
|
1141
|
-
|
1142
|
-
|
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><p>(.*?)<\/p>/, 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
|
-
|
1020
|
+
AstroPanelApp.new
|
1021
|
+
|
1022
|
+
# VIM MODELINE{{{1
|
1023
|
+
# vim: set sw=2 sts=2 et fdm=marker fdn=2 fcs=fold\:\ :
|