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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -51
  3. data/lib/tsukimoji.rb +204 -68
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db5da3254050bfb603c97f5c55a0c990d8c49676af3df8baf054a08b6b83c08d
4
- data.tar.gz: e7824f345eb8a39f1002363a05bc6ef9470be1f82b431f07cdbdd93ecc0c2e9c
3
+ metadata.gz: 3fb9b2cca84275711a343a05ced5281a81ed094d3f78cb226a87e63c13b51025
4
+ data.tar.gz: 8157533920c3a951fe130c62cc475d090b3ff9a56eb630031c42d021de011c0f
5
5
  SHA512:
6
- metadata.gz: bd030575b8e4e7a7908a58ff44ad95fd903c8e9ec1d1e404889a094b1b591f4f3f22f8a3f4ee40dc2d5211e01a60745a665fa210b205cdeba20f5725cba37cdb
7
- data.tar.gz: ac14edf172c6166962bd112a0b8e64f35c6c4a31da149599ed47c4dcffd56f2fdca81de3f0a989d558b32af8b8f9f154fcdf7ec243172be545edd3aaf3149866
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
- ## Installation
22
-
23
- ```
24
- gem install tsukimoji
25
- ```
26
-
27
- Or add to your Gemfile:
28
-
29
- ```ruby
30
- gem "tsukimoji"
31
- ```
32
-
33
- ## How it works
34
-
35
- tsukiMOJi measures elapsed time since a known new moon (2000-01-06
36
- 18:14 UTC) against the synodic month (29.530588853 days) to find how far
37
- into the current lunar cycle a given moment falls, then maps that
38
- fraction onto the eight standard moon-phase emoji (πŸŒ‘πŸŒ’πŸŒ“πŸŒ”πŸŒ•πŸŒ–πŸŒ—πŸŒ˜).
39
- Illumination is derived separately from the same cycle position via a
40
- cosine curve, for display purposes.
41
-
42
- ## License
43
-
44
- MIT
45
-
46
- ---
47
-
48
- <p align="center">
49
- <img src="https://raw.githubusercontent.com/samlehman/tsukimoji/main/made-in-baltimore.png" alt="Made in Baltimore" width="88" align="middle">
50
- &nbsp;&nbsp;β˜• <a href="https://paypal.me/samlehman">Buy me a coffee</a> Β· <a href="https://paypal.me/samlehman">γ“γƒΌγ²γƒΌγ‚’γŠγ”γ£γ¦</a>
51
- </p>
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
+ &nbsp;&nbsp;β˜• <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 "time"
4
-
5
- # tsukiMOJi ("moon" + "moji", as in emoji) computes the current lunar
6
- # phase for any date and returns the matching emoji, phase name, age in
7
- # days, and illumination fraction.
8
- #
9
- # Ported from the original moon-phase.html JavaScript prototype.
10
- module Tsukimoji
11
- SYNODIC_MONTH_DAYS = 29.530588853
12
- SYNODIC_MONTH_SECONDS = SYNODIC_MONTH_DAYS * 24 * 60 * 60
13
-
14
- # A known new moon reference point: 2000-01-06 18:14 UTC.
15
- KNOWN_NEW_MOON = Time.utc(2000, 1, 6, 18, 14, 0)
16
-
17
- PHASES = [
18
- { emoji: "\u{1F311}", name: "New Moon" },
19
- { emoji: "\u{1F312}", name: "Waxing Crescent" },
20
- { emoji: "\u{1F313}", name: "First Quarter" },
21
- { emoji: "\u{1F314}", name: "Waxing Gibbous" },
22
- { emoji: "\u{1F315}", name: "Full Moon" },
23
- { emoji: "\u{1F316}", name: "Waning Gibbous" },
24
- { emoji: "\u{1F317}", name: "Last Quarter" },
25
- { emoji: "\u{1F318}", name: "Waning Crescent" },
26
- ].freeze
27
-
28
- # Optional face emoji, by phase index: 🌚 for New Moon, 🌝 for Full Moon.
29
- FACES = { 0 => "\u{1F31A}", 4 => "\u{1F31D}" }.freeze
30
-
31
- # Immutable result of a phase calculation.
32
- Phase = Struct.new(:emoji, :name, :age_days, :illumination, keyword_init: true) do
33
- def to_s
34
- emoji
35
- end
36
- end
37
-
38
- class << self
39
- # Returns a Tsukimoji::Phase for the given time (defaults to now, UTC).
40
- # Pass faces: true to get 🌚 and 🌝 for new and full moons.
41
- def phase(time = Time.now.utc, faces: false)
42
- time = time.utc
43
- elapsed_seconds = time.to_f - KNOWN_NEW_MOON.to_f
44
- cycles = elapsed_seconds / SYNODIC_MONTH_SECONDS
45
- age = ((cycles % 1) + 1) % 1
46
- index = ((age + 1.0 / 16) * 8).floor % 8
47
- illumination = (1 - Math.cos(age * 2 * Math::PI)) / 2
48
-
49
- data = PHASES[index]
50
- Phase.new(
51
- emoji: (faces && FACES[index]) || data[:emoji],
52
- name: data[:name],
53
- age_days: age * SYNODIC_MONTH_DAYS,
54
- illumination: illumination
55
- )
56
- end
57
-
58
- # Convenience shortcut: just the emoji for the given time.
59
- def emoji(time = Time.now.utc, faces: false)
60
- phase(time, faces: faces).emoji
61
- end
62
-
63
- # Convenience shortcut: just the phase name for the given time.
64
- def name(time = Time.now.utc)
65
- phase(time).name
66
- end
67
- end
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tsukimoji
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Lehman