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.
- checksums.yaml +4 -4
- data/README.md +108 -0
- data/bin/astropanel +1023 -0
- metadata +13 -18
- 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><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 }
|
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\:\ :
|