tsukimoji 0.1.2 β 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +67 -51
- data/lib/tsukimoji.rb +204 -68
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fb9b2cca84275711a343a05ced5281a81ed094d3f78cb226a87e63c13b51025
|
|
4
|
+
data.tar.gz: 8157533920c3a951fe130c62cc475d090b3ff9a56eb630031c42d021de011c0f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f02df3636dce9febc4f2bc7edda2a0c3e958806bacf4419be1165ae020373370d3b944ff3260d5f9d9a45fb4a7262b59996ca9e0a2c6d1d1f85931c5a8ad2e5f
|
|
7
|
+
data.tar.gz: 3fc8e93325f116f60fa3b494de670cc8af01cb527fc6ba030223dfd4c3ea4274faf9d614e08b3ef36537713ea028bc191604c938851846b024068cd5d3722a48
|
data/README.md
CHANGED
|
@@ -1,51 +1,67 @@
|
|
|
1
|
-
# tsukiMOJi
|
|
2
|
-
|
|
3
|
-
Moon phase emoji for any date. ζ (tsuki, "moon") + moji, as in emoji.
|
|
4
|
-
|
|
5
|
-
```ruby
|
|
6
|
-
require "tsukimoji"
|
|
7
|
-
|
|
8
|
-
phase = Tsukimoji.phase
|
|
9
|
-
phase.emoji # => "π"
|
|
10
|
-
phase.name # => "Waxing Gibbous"
|
|
11
|
-
phase.age_days # => 12.3
|
|
12
|
-
phase.illumination # => 0.87
|
|
13
|
-
|
|
14
|
-
Tsukimoji.emoji # just the emoji, for "now"
|
|
15
|
-
Tsukimoji.phase(Time.utc(2026, 1, 1)) # phase for any specific time
|
|
16
|
-
|
|
17
|
-
# Moon faces: π for New Moon and π for Full Moon
|
|
18
|
-
Tsukimoji.emoji(Time.utc(2026, 10, 26, 4), faces: true) # => "π"
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
1
|
+
# tsukiMOJi
|
|
2
|
+
|
|
3
|
+
Moon phase emoji for any date. ζ (tsuki, "moon") + moji, as in emoji.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "tsukimoji"
|
|
7
|
+
|
|
8
|
+
phase = Tsukimoji.phase
|
|
9
|
+
phase.emoji # => "π"
|
|
10
|
+
phase.name # => "Waxing Gibbous"
|
|
11
|
+
phase.age_days # => 12.3
|
|
12
|
+
phase.illumination # => 0.87
|
|
13
|
+
|
|
14
|
+
Tsukimoji.emoji # just the emoji, for "now"
|
|
15
|
+
Tsukimoji.phase(Time.utc(2026, 1, 1)) # phase for any specific time
|
|
16
|
+
|
|
17
|
+
# Moon faces: π for New Moon and π for Full Moon
|
|
18
|
+
Tsukimoji.emoji(Time.utc(2026, 10, 26, 4), faces: true) # => "π"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Phase calendar
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Tsukimoji.calendar("2026-10") # one CalendarDay per day of October 2026
|
|
25
|
+
Tsukimoji.calendar_csv("2026-11", "2027-02") # November through February, as CSV
|
|
26
|
+
Tsukimoji.calendar_json(2026) # the whole year, as JSON
|
|
27
|
+
Tsukimoji.calendar_text("2026", "2027") # two years of month grids, as text
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Pass a year (`"2026"` or `2026`), a month (`"2026-10"`), a day
|
|
31
|
+
(`"2026-10-04"`) or a `Date` or `Time`, plus an optional second one to make a range.
|
|
32
|
+
Each new moon, quarter and full moon appears on the day it happens, with the
|
|
33
|
+
exact time. See the
|
|
34
|
+
[main README](https://github.com/samlehman/tsukimoji#phase-calendar) for the
|
|
35
|
+
formats.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
gem install tsukimoji
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or add to your Gemfile:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
gem "tsukimoji"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
tsukiMOJi measures elapsed time since a known new moon (2000-01-06
|
|
52
|
+
18:14 UTC) against the synodic month (29.530588853 days) to find how far
|
|
53
|
+
into the current lunar cycle a given moment falls, then maps that
|
|
54
|
+
fraction onto the eight standard moon-phase emoji (ππππππππ).
|
|
55
|
+
Illumination is derived separately from the same cycle position via a
|
|
56
|
+
cosine curve, for display purposes.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
<p align="center">
|
|
65
|
+
<img src="https://raw.githubusercontent.com/samlehman/tsukimoji/main/made-in-baltimore.png" alt="Made in Baltimore" width="88" align="middle">
|
|
66
|
+
β <a href="https://paypal.me/samlehman">Buy me a coffee</a> Β· <a href="https://paypal.me/samlehman">γγΌγ²γΌγγγγ£γ¦</a>
|
|
67
|
+
</p>
|
data/lib/tsukimoji.rb
CHANGED
|
@@ -1,68 +1,204 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{ emoji: "\u{
|
|
20
|
-
{ emoji: "\u{
|
|
21
|
-
{ emoji: "\u{
|
|
22
|
-
{ emoji: "\u{
|
|
23
|
-
{ emoji: "\u{
|
|
24
|
-
{ emoji: "\u{
|
|
25
|
-
{ emoji: "\u{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
# tsukiMOJi ("moon" + "moji", as in emoji) computes the current lunar
|
|
7
|
+
# phase for any date and returns the matching emoji, phase name, age in
|
|
8
|
+
# days, and illumination fraction.
|
|
9
|
+
#
|
|
10
|
+
# Ported from the original moon-phase.html JavaScript prototype.
|
|
11
|
+
module Tsukimoji
|
|
12
|
+
SYNODIC_MONTH_DAYS = 29.530588853
|
|
13
|
+
SYNODIC_MONTH_SECONDS = SYNODIC_MONTH_DAYS * 24 * 60 * 60
|
|
14
|
+
|
|
15
|
+
# A known new moon reference point: 2000-01-06 18:14 UTC.
|
|
16
|
+
KNOWN_NEW_MOON = Time.utc(2000, 1, 6, 18, 14, 0)
|
|
17
|
+
|
|
18
|
+
PHASES = [
|
|
19
|
+
{ emoji: "\u{1F311}", name: "New Moon" },
|
|
20
|
+
{ emoji: "\u{1F312}", name: "Waxing Crescent" },
|
|
21
|
+
{ emoji: "\u{1F313}", name: "First Quarter" },
|
|
22
|
+
{ emoji: "\u{1F314}", name: "Waxing Gibbous" },
|
|
23
|
+
{ emoji: "\u{1F315}", name: "Full Moon" },
|
|
24
|
+
{ emoji: "\u{1F316}", name: "Waning Gibbous" },
|
|
25
|
+
{ emoji: "\u{1F317}", name: "Last Quarter" },
|
|
26
|
+
{ emoji: "\u{1F318}", name: "Waning Crescent" },
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Optional face emoji, by phase index: π for New Moon, π for Full Moon.
|
|
30
|
+
FACES = { 0 => "\u{1F31A}", 4 => "\u{1F31D}" }.freeze
|
|
31
|
+
|
|
32
|
+
# New moon, first quarter, full moon and last quarter are a quarter cycle apart.
|
|
33
|
+
QUARTER_SECONDS = SYNODIC_MONTH_SECONDS / 4
|
|
34
|
+
|
|
35
|
+
MONTH_NAMES = %w[January February March April May June July
|
|
36
|
+
August September October November December].freeze
|
|
37
|
+
|
|
38
|
+
# Immutable result of a phase calculation.
|
|
39
|
+
Phase = Struct.new(:emoji, :name, :age_days, :illumination, keyword_init: true) do
|
|
40
|
+
def to_s
|
|
41
|
+
emoji
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One day of a phase calendar. event_time is set on the day of an exact
|
|
46
|
+
# new moon, quarter or full moon, and nil otherwise.
|
|
47
|
+
CalendarDay = Struct.new(:date, :emoji, :name, :event_time, :age_days, :illumination,
|
|
48
|
+
keyword_init: true)
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Returns a Tsukimoji::Phase for the given time (defaults to now, UTC).
|
|
52
|
+
# Pass faces: true to get π and π for new and full moons.
|
|
53
|
+
def phase(time = Time.now.utc, faces: false)
|
|
54
|
+
time = time.utc
|
|
55
|
+
elapsed_seconds = time.to_f - KNOWN_NEW_MOON.to_f
|
|
56
|
+
cycles = elapsed_seconds / SYNODIC_MONTH_SECONDS
|
|
57
|
+
age = ((cycles % 1) + 1) % 1
|
|
58
|
+
index = ((age + 1.0 / 16) * 8).floor % 8
|
|
59
|
+
illumination = (1 - Math.cos(age * 2 * Math::PI)) / 2
|
|
60
|
+
|
|
61
|
+
data = PHASES[index]
|
|
62
|
+
Phase.new(
|
|
63
|
+
emoji: (faces && FACES[index]) || data[:emoji],
|
|
64
|
+
name: data[:name],
|
|
65
|
+
age_days: age * SYNODIC_MONTH_DAYS,
|
|
66
|
+
illumination: illumination
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Convenience shortcut: just the emoji for the given time.
|
|
71
|
+
def emoji(time = Time.now.utc, faces: false)
|
|
72
|
+
phase(time, faces: faces).emoji
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Convenience shortcut: just the phase name for the given time.
|
|
76
|
+
def name(time = Time.now.utc)
|
|
77
|
+
phase(time).name
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# One Tsukimoji::CalendarDay per UTC day from the start of +from+ to the
|
|
81
|
+
# end of +to+ (or just +from+). Each is a year ("2026" or 2026), a month
|
|
82
|
+
# ("2026-10"), a day ("2026-10-04"), a Date or a Time.
|
|
83
|
+
#
|
|
84
|
+
# New moon, first quarter, full moon and last quarter each fall on exactly
|
|
85
|
+
# one day, the day they happen, with event_time set. Days in between get
|
|
86
|
+
# the crescent or gibbous phase. age_days and illumination are at 12:00 UTC.
|
|
87
|
+
def calendar(from, to = nil, faces: false)
|
|
88
|
+
first = span(from).first
|
|
89
|
+
last = span(to.nil? ? from : to).last
|
|
90
|
+
raise ArgumentError, "to is before from" if last < first
|
|
91
|
+
|
|
92
|
+
(first..last).map do |day|
|
|
93
|
+
day_start = Time.utc(day.year, day.month, day.day)
|
|
94
|
+
# The last principal phase before the end of this day.
|
|
95
|
+
n = ((day_start.to_f + 86_400 - KNOWN_NEW_MOON.to_f) / QUARTER_SECONDS).ceil - 1
|
|
96
|
+
event = KNOWN_NEW_MOON + (n * QUARTER_SECONDS)
|
|
97
|
+
is_event = event >= day_start
|
|
98
|
+
index = ((n % 4) * 2) + (is_event ? 0 : 1)
|
|
99
|
+
noon = phase(day_start + 43_200)
|
|
100
|
+
CalendarDay.new(
|
|
101
|
+
date: day,
|
|
102
|
+
emoji: (faces && FACES[index]) || PHASES[index][:emoji],
|
|
103
|
+
name: PHASES[index][:name],
|
|
104
|
+
event_time: is_event ? event : nil,
|
|
105
|
+
age_days: noon.age_days,
|
|
106
|
+
illumination: noon.illumination
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The calendar as CSV, one row per day, with a header row.
|
|
112
|
+
def calendar_csv(from, to = nil, faces: false)
|
|
113
|
+
lines = ["date,emoji,name,event_time,age_days,illumination"]
|
|
114
|
+
calendar(from, to, faces: faces).each do |d|
|
|
115
|
+
event = d.event_time ? minute_iso(d.event_time) : ""
|
|
116
|
+
lines << [d.date.iso8601, d.emoji, d.name, event,
|
|
117
|
+
format("%.2f", d.age_days), format("%.3f", d.illumination)].join(",")
|
|
118
|
+
end
|
|
119
|
+
"#{lines.join("
|
|
120
|
+
")}
|
|
121
|
+
"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The calendar as a JSON array, one object per day.
|
|
125
|
+
def calendar_json(from, to = nil, faces: false)
|
|
126
|
+
rows = calendar(from, to, faces: faces).map do |d|
|
|
127
|
+
event = d.event_time ? "\"#{minute_iso(d.event_time)}\"" : "null"
|
|
128
|
+
%( {"date": "#{d.date.iso8601}", "emoji": "#{d.emoji}", "name": "#{d.name}", ) +
|
|
129
|
+
%("event_time": #{event}, ) +
|
|
130
|
+
%("age_days": #{format("%.2f", d.age_days)}, "illumination": #{format("%.3f", d.illumination)}})
|
|
131
|
+
end
|
|
132
|
+
"[
|
|
133
|
+
#{rows.join(",
|
|
134
|
+
")}
|
|
135
|
+
]
|
|
136
|
+
"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The calendar as plain text: a Monday-first grid per month, followed by
|
|
140
|
+
# that month's exact new moon, quarter and full moon times.
|
|
141
|
+
def calendar_text(from, to = nil, faces: false)
|
|
142
|
+
months = calendar(from, to, faces: faces).group_by { |d| [d.date.year, d.date.month] }
|
|
143
|
+
|
|
144
|
+
blocks = months.map do |(year, month), days|
|
|
145
|
+
length = Date.new(year, month, -1).day
|
|
146
|
+
offset = Date.new(year, month, 1).cwday - 1
|
|
147
|
+
cells = Array.new(offset + length)
|
|
148
|
+
days.each { |d| cells[offset + d.date.day - 1] = d }
|
|
149
|
+
|
|
150
|
+
lines = ["#{MONTH_NAMES[month - 1]} #{year}", " Mo Tu We Th Fr Sa Su"]
|
|
151
|
+
cells.each_slice(7) do |week|
|
|
152
|
+
week.pop until week.empty? || week.last
|
|
153
|
+
next if week.empty?
|
|
154
|
+
|
|
155
|
+
lines << week.map { |d| d ? "#{d.date.day.to_s.rjust(2)} #{d.emoji}" : " " }.join(" ")
|
|
156
|
+
end
|
|
157
|
+
events = days.select(&:event_time)
|
|
158
|
+
unless events.empty?
|
|
159
|
+
lines << ""
|
|
160
|
+
events.each do |d|
|
|
161
|
+
t = minute_iso(d.event_time)
|
|
162
|
+
lines << "#{d.emoji} #{d.name.ljust(13)} #{t[0, 10]} #{t[11, 5]} UTC"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
lines.join("
|
|
166
|
+
")
|
|
167
|
+
end
|
|
168
|
+
"#{blocks.join("
|
|
169
|
+
|
|
170
|
+
")}
|
|
171
|
+
"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# "2026", "2026-10", "2026-10-04", 2026, a Date or a Time -> [first day, last day].
|
|
177
|
+
def span(value)
|
|
178
|
+
case value
|
|
179
|
+
when Time then return [value.getutc.to_date] * 2
|
|
180
|
+
when DateTime then return [value.new_offset(0).to_date] * 2
|
|
181
|
+
when Date then return [value, value]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
m = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/.match(value.to_s) if value.is_a?(String) || value.is_a?(Integer)
|
|
185
|
+
year = m ? m[1].to_i : 0
|
|
186
|
+
month = m && m[2] ? m[2].to_i : 1
|
|
187
|
+
raise ArgumentError, "Expected YYYY, YYYY-MM or YYYY-MM-DD, got #{value}" if !m || year < 1 || !month.between?(1, 12)
|
|
188
|
+
|
|
189
|
+
if m[3]
|
|
190
|
+
raise ArgumentError, "No such date: #{value}" unless Date.valid_date?(year, month, m[3].to_i)
|
|
191
|
+
|
|
192
|
+
return [Date.new(year, month, m[3].to_i)] * 2
|
|
193
|
+
end
|
|
194
|
+
return [Date.new(year, month, 1), Date.new(year, month, -1)] if m[2]
|
|
195
|
+
|
|
196
|
+
[Date.new(year, 1, 1), Date.new(year, 12, 31)]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# "2026-10-04T00:02Z": event times are only accurate to minutes at best.
|
|
200
|
+
def minute_iso(time)
|
|
201
|
+
time.utc.strftime("%Y-%m-%dT%H:%MZ")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|