@codincod/ornament 0.0.0-stage → 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.
package/src/sizes.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * How big a thing is drawn, given how big it is and how far off it is.
3
+ *
4
+ * One statement of scale for the whole scene, because the alternative is what
5
+ * this water had: every file picking a share of the box's width that looked
6
+ * right on its own, and a boat coming out the size of a turtle. Nothing catches
7
+ * that. Each number is defensible where it is written and the picture is still
8
+ * wrong, because the thing being got wrong lives between the files.
9
+ *
10
+ * So each animal and each hull is written down in metres, at a distance in
11
+ * metres, and this turns the pair into a share of the box's width. A reader who
12
+ * doubts a size can look the animal up.
13
+ *
14
+ * ## Why it is not the angle a thing subtends
15
+ *
16
+ * Drawn strictly to the angle, this scene has no picture in it. A reef fish is
17
+ * a fifth of a metre and a green turtle is well over a metre, so the fish comes
18
+ * out at a handful of pixels: a speck, and a speck will not hold a drawing. Go
19
+ * the other way and size the fish to read, and a submarine is wider than the
20
+ * screen.
21
+ *
22
+ * The root is the compression that fixes it. Doubling a real length still draws
23
+ * bigger, so the order of things is never wrong and a shark never comes out
24
+ * under a turtle; but the ends are pulled in, so the smallest thing in the
25
+ * water is still a shape and the largest still fits. It is the same bargain a
26
+ * map makes with a river's width, and for the same reason: what is being kept
27
+ * is which is bigger, not by how much.
28
+ *
29
+ * ## The distances are the honest part
30
+ *
31
+ * A boat is not small. It is far: the surface is a long way up from a seabed,
32
+ * and a hull crossing it is a shape on the ceiling rather than a thing you are
33
+ * swimming beside. Writing that down as a distance rather than as a smaller
34
+ * boat is what keeps the next boat from being wrong again.
35
+ *
36
+ * ## Where the animals are written down
37
+ *
38
+ * Here is the sum and nothing else. What each thing measures stays beside the
39
+ * thing, because a table of lengths in one file is a table nobody updates when
40
+ * an animal changes: `shoal.ts` carries the fish, `visitors.ts` the large ones
41
+ * crossing, `jellies.ts` the bells, `walkers.ts` the floor, `cephalopods.ts`
42
+ * the squid and the octopus, `passers.ts` the hulls. Two of them are deliberate
43
+ * exceptions and say so where they stand. A fish inside a flock is measured
44
+ * against the flock (`swarm.ts`) and a clownfish against its anemone
45
+ * (`nemos.ts`), because in both cases the two things are at one distance and
46
+ * nothing between them wants compressing. Marine snow (`drift.ts`) is a texture
47
+ * rather than an animal.
48
+ *
49
+ * How many of each there are is the other half, and it is `census.ts`.
50
+ */
51
+
52
+ /**
53
+ * How much of the box's width a thing a metre off would take.
54
+ *
55
+ * The one number the scene is tuned by. It was fixed by the shark, which is the
56
+ * animal this water was drawn around: at four and a half metres and eight off,
57
+ * it comes out where it always was, and everything else moved to meet it.
58
+ */
59
+ const AT_ARM = 0.15
60
+
61
+ /**
62
+ * How far off the things that swim past you are.
63
+ *
64
+ * They are in the same water, so they share a distance, and what still tells
65
+ * them apart is `Visitor.depth`, which is the nearer and further of this.
66
+ */
67
+ export const SWIMMING_OFF = 8
68
+
69
+ /**
70
+ * How far off the floor of the picture is.
71
+ *
72
+ * Further than the animals crossing the water, because the bed is under them
73
+ * and behind them: what a reader is looking at is a stretch of ground that
74
+ * starts a little beyond arm's reach and goes back until the haze takes it.
75
+ * Everything that lives down there shares it, which is the point of writing it
76
+ * once. A crab, a starfish and an octopus at the mouth of a den are the sizes
77
+ * they are against each other because they are the same distance away, and a
78
+ * crab that came out smaller than the stones it walks between is a crab that
79
+ * was given a number of its own.
80
+ */
81
+ export const BED_OFF = 10
82
+
83
+ /** A share of the box's width, for something this long and this far away. */
84
+ export function drawnAt(metres: number, off: number): number {
85
+ return AT_ARM * Math.sqrt(Math.max(metres, 0) / Math.max(off, 0.001))
86
+ }
package/src/sun.ts ADDED
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Where the sun is.
3
+ *
4
+ * The ornament draws a window into water, and water at four in the morning is
5
+ * not water at noon. This works out which of those it is, from the clock and
6
+ * from where the clock says it is being read, and hands back the two numbers
7
+ * every renderer needs: how light it is, and how near the light is to a sunset.
8
+ *
9
+ * The moon is here too, and it is the real one: where it is tonight, and what
10
+ * shape the sun has left of it. It rises later each night, is a different
11
+ * sliver each night, and comes back to the same shape after 29.53 of them.
12
+ *
13
+ * It lives in this package rather than beside the site's hook because the desktop
14
+ * background wants the same sky. `seascape` bundles this package into its
15
+ * renderer, so a moon that rises on the site at
16
+ * half past nine rises on the desktop at half past nine, from these lines. That
17
+ * is the whole reason the two are one piece of code.
18
+ *
19
+ * Nothing here asks for a permission and nothing leaves the machine. The
20
+ * position is estimated: longitude from the time zone's current offset from
21
+ * UTC, which is 15 degrees an hour and already correct for daylight saving, and
22
+ * latitude from the zone's own name. That is accurate to a few hundred
23
+ * kilometres, which moves sunset by minutes. Nobody is navigating by this.
24
+ *
25
+ * Daylight saving needs no handling of its own. The altitude is computed from
26
+ * absolute time, so a clock that jumps an hour changes nothing about where the
27
+ * sun is; only the offset used for longitude moves, and it moves with the zone.
28
+ */
29
+
30
+ const RAD = Math.PI / 180
31
+
32
+ /**
33
+ * Days since J2000.0, the epoch every term below is expressed in.
34
+ */
35
+ const J2000_OFFSET_DAYS = 10957.5
36
+ const DAY_MS = 86_400_000
37
+
38
+ /**
39
+ * Earth's axial tilt.
40
+ */
41
+ const OBLIQUITY = 23.4397 * RAD
42
+
43
+ /**
44
+ * Latitude by time-zone region, in degrees, north positive.
45
+ *
46
+ * A region is a continent and a continent is not a latitude, so these are the
47
+ * middle of where people in that region actually live, and they are what a zone
48
+ * the table below has never heard of falls back to. The error that shows is a
49
+ * reader put in the wrong hemisphere: it inverts the seasons, and a scene that
50
+ * is dark at eight in the evening in Buenos Aires in January would be wrong by
51
+ * hours rather than by minutes.
52
+ */
53
+ const REGION_LATITUDE: Record<string, number> = {
54
+ Africa: 5,
55
+ America: 38,
56
+ Antarctica: -70,
57
+ Arctic: 78,
58
+ Asia: 30,
59
+ Atlantic: 35,
60
+ Australia: -30,
61
+ Europe: 50,
62
+ Indian: -15,
63
+ Pacific: -20,
64
+ }
65
+
66
+ /**
67
+ * Where a zone's people actually are, north and east positive.
68
+ *
69
+ * A zone is named after a city and a city is not its meridian: Brussels keeps
70
+ * the meridian of Prague, which is ten degrees and forty minutes of moonset. So
71
+ * the zones most readers are in say where they are, and everything else falls
72
+ * back to the middle of its region and the middle of its offset.
73
+ */
74
+ const ZONE_PLACE: Record<string, readonly [number, number]> = {
75
+ "Africa/Cairo": [30, 31.2],
76
+ "Africa/Casablanca": [33.6, -7.6],
77
+ "Africa/Johannesburg": [-26.2, 28],
78
+ "Africa/Lagos": [6.5, 3.4],
79
+ "Africa/Nairobi": [-1.3, 36.8],
80
+ "America/Argentina/Buenos_Aires": [-34.6, -58.4],
81
+ "America/Bogota": [4.7, -74.1],
82
+ "America/Chicago": [41.9, -87.6],
83
+ "America/Denver": [39.7, -105],
84
+ "America/Halifax": [44.6, -63.6],
85
+ "America/Lima": [-12, -77],
86
+ "America/Los_Angeles": [34.1, -118.2],
87
+ "America/Mexico_City": [19.4, -99.1],
88
+ "America/New_York": [40.7, -74],
89
+ "America/Phoenix": [33.4, -112.1],
90
+ "America/Santiago": [-33.4, -70.7],
91
+ "America/Sao_Paulo": [-23.5, -46.6],
92
+ "America/Toronto": [43.7, -79.4],
93
+ "America/Vancouver": [49.3, -123.1],
94
+ "Asia/Bangkok": [13.8, 100.5],
95
+ "Asia/Dubai": [25.2, 55.3],
96
+ "Asia/Hong_Kong": [22.3, 114.2],
97
+ "Asia/Jakarta": [-6.2, 106.8],
98
+ "Asia/Jerusalem": [31.8, 35.2],
99
+ "Asia/Karachi": [24.9, 67],
100
+ "Asia/Kolkata": [22.6, 88.4],
101
+ "Asia/Manila": [14.6, 121],
102
+ "Asia/Seoul": [37.6, 127],
103
+ "Asia/Shanghai": [31.2, 121.5],
104
+ "Asia/Singapore": [1.3, 103.8],
105
+ "Asia/Taipei": [25, 121.5],
106
+ "Asia/Tehran": [35.7, 51.4],
107
+ "Asia/Tokyo": [35.7, 139.7],
108
+ "Atlantic/Reykjavik": [64.1, -21.9],
109
+ "Australia/Brisbane": [-27.5, 153],
110
+ "Australia/Melbourne": [-37.8, 145],
111
+ "Australia/Perth": [-31.9, 115.9],
112
+ "Australia/Sydney": [-33.9, 151.2],
113
+ "Europe/Amsterdam": [52.4, 4.9],
114
+ "Europe/Athens": [38, 23.7],
115
+ "Europe/Berlin": [52.5, 13.4],
116
+ "Europe/Brussels": [50.8, 4.4],
117
+ "Europe/Bucharest": [44.4, 26.1],
118
+ "Europe/Dublin": [53.3, -6.3],
119
+ "Europe/Helsinki": [60.2, 24.9],
120
+ "Europe/Istanbul": [41, 29],
121
+ "Europe/Kyiv": [50.5, 30.5],
122
+ "Europe/Lisbon": [38.7, -9.1],
123
+ "Europe/London": [51.5, -0.1],
124
+ "Europe/Madrid": [40.4, -3.7],
125
+ "Europe/Moscow": [55.8, 37.6],
126
+ "Europe/Oslo": [59.9, 10.8],
127
+ "Europe/Paris": [48.9, 2.4],
128
+ "Europe/Prague": [50.1, 14.4],
129
+ "Europe/Rome": [41.9, 12.5],
130
+ "Europe/Stockholm": [59.3, 18.1],
131
+ "Europe/Vienna": [48.2, 16.4],
132
+ "Europe/Warsaw": [52.2, 21],
133
+ "Europe/Zurich": [47.4, 8.5],
134
+ "Pacific/Auckland": [-36.9, 174.8],
135
+ "Pacific/Fiji": [-18.1, 178.4],
136
+ "Pacific/Honolulu": [21.3, -157.9],
137
+ }
138
+
139
+ export interface Position {
140
+ latitude: number
141
+ longitude: number
142
+ }
143
+
144
+ /**
145
+ * A guess at where this is being read, from the time zone alone.
146
+ *
147
+ * The `try` is not defensive dressing. This runs under Qt's JavaScript engine
148
+ * as well as in a browser, and an engine built without ICU has no `Intl` to
149
+ * ask; the fallback is the middle of the northern hemisphere, which is wrong by
150
+ * a few degrees for most readers and never wrong by a season.
151
+ */
152
+ export function position(now: Date): Position {
153
+ const longitude = clamp(-standard(now) / 4, -180, 180)
154
+
155
+ let zone = ""
156
+ try {
157
+ zone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""
158
+ } catch {
159
+ zone = ""
160
+ }
161
+
162
+ const place = ZONE_PLACE[zone]
163
+ if (place) return { latitude: place[0], longitude: place[1] }
164
+
165
+ const region = zone.split("/")[0] ?? ""
166
+ return { latitude: REGION_LATITUDE[region] ?? 45, longitude }
167
+ }
168
+
169
+ /**
170
+ * The zone's own offset from UTC in minutes, with summer time taken back out.
171
+ *
172
+ * A zone is a meridian and summer time is a law, so a clock put forward an hour
173
+ * is not a reader who moved fifteen degrees east. Reading the offset as it
174
+ * stands put Brussels on the meridian of Kyiv every summer, which is an hour of
175
+ * error in every rise and set: the moon left the water while it was still up
176
+ * outside the window, and the sun set an hour early from March to October.
177
+ *
178
+ * The standard offset is the larger of midwinter's and midsummer's, since
179
+ * `getTimezoneOffset` counts minutes behind UTC and summer time can only take
180
+ * that number down. That holds in both hemispheres and on zones that never move
181
+ * their clocks at all.
182
+ */
183
+ function standard(now: Date): number {
184
+ const year = now.getFullYear()
185
+ const january = new Date(year, 0, 1).getTimezoneOffset()
186
+ const july = new Date(year, 6, 1).getTimezoneOffset()
187
+
188
+ return Math.max(january, july)
189
+ }
190
+
191
+ /**
192
+ * The sun's altitude above the horizon in degrees.
193
+ *
194
+ * The low-precision solar position from the Astronomical Almanac, the same
195
+ * formulation SunCalc uses: mean anomaly, then ecliptic longitude, then a
196
+ * rotation into the observer's horizon. Good to about a minute of arc, which is
197
+ * four orders of magnitude better than the position it is fed.
198
+ */
199
+ export function solarAltitude(now: Date, position: Position): number {
200
+ const days = epochDays(now)
201
+
202
+ return horizon(days, position, equatorial(sunEcliptic(days), 0)).altitude
203
+ }
204
+
205
+ /**
206
+ * The moon's altitude above the horizon in degrees.
207
+ *
208
+ * The sibling of `solarAltitude`, and the reason there is one: a moon is up for
209
+ * its own hours rather than for the hours the sun is not. It is a couple of
210
+ * tenths of a degree out, which is a fraction of the disc's own width and a few
211
+ * minutes of the time it rises.
212
+ */
213
+ export function lunarAltitude(now: Date, position: Position): number {
214
+ const days = epochDays(now)
215
+ const { latitude, longitude } = moonEcliptic(days)
216
+
217
+ return horizon(days, position, equatorial(longitude, latitude)).altitude
218
+ }
219
+
220
+ /**
221
+ * Days since J2000.0, which every series below is written in.
222
+ */
223
+ function epochDays(now: Date): number {
224
+ return now.getTime() / DAY_MS - J2000_OFFSET_DAYS
225
+ }
226
+
227
+ /**
228
+ * The sun's ecliptic longitude in radians.
229
+ *
230
+ * Mean anomaly, then the equation of the centre, then round to the longitude of
231
+ * perihelion. Good to about a minute of arc, which is four orders of magnitude
232
+ * better than the position it is fed.
233
+ */
234
+ function sunEcliptic(days: number): number {
235
+ const meanAnomaly = (357.5291 + 0.98560028 * days) * RAD
236
+
237
+ return (
238
+ meanAnomaly +
239
+ (1.9148 * Math.sin(meanAnomaly) +
240
+ 0.02 * Math.sin(2 * meanAnomaly) +
241
+ 0.0003 * Math.sin(3 * meanAnomaly)) *
242
+ RAD +
243
+ 102.9372 * RAD +
244
+ Math.PI
245
+ )
246
+ }
247
+
248
+ /**
249
+ * The moon's ecliptic longitude and latitude in radians.
250
+ *
251
+ * The shortest lunar theory anybody publishes: a mean longitude, a mean
252
+ * anomaly and an argument of latitude, each turning at its own rate, each with
253
+ * the one correction that matters. It lands within a fraction of a degree,
254
+ * which moves moonrise by a few minutes and the shape of the disc by an hour or
255
+ * two of a month. Nobody can tell an hour of a month by looking.
256
+ *
257
+ * Those three rates are the month. The longitude turns a little over thirteen
258
+ * degrees a day and the sun turns one, so the moon laps it every 29.53 days,
259
+ * and that lap is what the disc's shape is.
260
+ */
261
+ function moonEcliptic(days: number): { latitude: number; longitude: number } {
262
+ const argument = (93.272 + 13.22935 * days) * RAD
263
+ const meanAnomaly = (134.963 + 13.064993 * days) * RAD
264
+ const meanLongitude = (218.316 + 13.176396 * days) * RAD
265
+
266
+ return {
267
+ latitude: 5.128 * RAD * Math.sin(argument),
268
+ longitude: meanLongitude + 6.289 * RAD * Math.sin(meanAnomaly),
269
+ }
270
+ }
271
+
272
+ /**
273
+ * An ecliptic position turned into the frame the sky turns in.
274
+ *
275
+ * The sun never leaves the ecliptic and is handed a latitude of zero, which
276
+ * drops the two terms that then vanish; the moon is up to five degrees off it,
277
+ * and dropping them for the moon would put it a disc's width from where it is.
278
+ */
279
+ function equatorial(
280
+ longitude: number,
281
+ latitude: number,
282
+ ): { declination: number; rightAscension: number } {
283
+ return {
284
+ declination: Math.asin(
285
+ Math.sin(latitude) * Math.cos(OBLIQUITY) +
286
+ Math.cos(latitude) * Math.sin(OBLIQUITY) * Math.sin(longitude),
287
+ ),
288
+ rightAscension: Math.atan2(
289
+ Math.sin(longitude) * Math.cos(OBLIQUITY) - Math.tan(latitude) * Math.sin(OBLIQUITY),
290
+ Math.cos(longitude),
291
+ ),
292
+ }
293
+ }
294
+
295
+ /**
296
+ * How high a body is and which side of the sky it is on, from where it is read.
297
+ *
298
+ * The hour angle comes back with the altitude because it is what says which
299
+ * side, and a body that rises where it set is a body on a loop. It is zero at
300
+ * local transit, negative on the way up and positive on the way down.
301
+ */
302
+ function horizon(
303
+ days: number,
304
+ { latitude, longitude }: Position,
305
+ { declination, rightAscension }: { declination: number; rightAscension: number },
306
+ ): { altitude: number; hourAngle: number } {
307
+ const siderealTime = (280.16 + 360.9856235 * days) * RAD + longitude * RAD
308
+ const hourAngle = siderealTime - rightAscension
309
+
310
+ const phi = latitude * RAD
311
+ const altitude = Math.asin(
312
+ Math.sin(phi) * Math.sin(declination) +
313
+ Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle),
314
+ )
315
+
316
+ return { altitude: altitude / RAD, hourAngle }
317
+ }
318
+
319
+ /**
320
+ * Where a body is in its own passage across the sky.
321
+ *
322
+ * `march` is 0 where it comes up and 1 where it goes down, so a renderer that
323
+ * knows nothing about astronomy can put a disc at `left + march * (right -
324
+ * left)` and have it cross by the clock. `arc` is 0 at either horizon and 1 at
325
+ * the top, which is what lifts the middle of the passage and lets the light
326
+ * under it stand more upright the higher the body gets. `up` is whether the
327
+ * thing is there to be drawn at all: 1 once it is clear of the horizon and 0
328
+ * while it is under it.
329
+ *
330
+ * Twelve hours from one horizon to the other, because that is what an hour
331
+ * angle gives: half a turn of the Earth. The real figure is longer in summer
332
+ * and shorter in winter and this does not model that. It is a disc on a
333
+ * wallpaper, and being an hour out at the solstice costs nothing; what would
334
+ * cost something is the moon jumping.
335
+ *
336
+ * `up` is the one that has to be honest, and it is honest because it is the
337
+ * altitude rather than the hour angle. A moon three days past full is under the
338
+ * ground for the first half of the night, and a renderer that took `march`
339
+ * alone would park it on the horizon and leave it there, lit, in an empty sky.
340
+ * The fade runs over a few degrees so it goes out where it sets rather than
341
+ * being switched off.
342
+ */
343
+ export interface Passage {
344
+ arc: number
345
+ march: number
346
+ up: number
347
+ }
348
+
349
+ function passage({ altitude, hourAngle }: { altitude: number; hourAngle: number }): Passage {
350
+ const wrapped = wrap(hourAngle)
351
+ const march = clamp(0.5 + wrapped / Math.PI, 0, 1)
352
+
353
+ return { arc: Math.sin(march * Math.PI), march, up: clamp((altitude + 1) / 6, 0, 1) }
354
+ }
355
+
356
+ /**
357
+ * How much of the moon the sun has hold of, and which way it is going.
358
+ *
359
+ * `lit` is the share of the disc in the light, 0 at the new moon and 1 at the
360
+ * full one. `waxing` is which side that share is on, and it is the right side
361
+ * of the disc for the half of the month the moon is filling up.
362
+ *
363
+ * The elongation is taken along the ecliptic alone, which ignores the moon's
364
+ * few degrees off it and the fact that the sun is not infinitely far away.
365
+ * Both are worth about a percent of the lit share at the quarters and nothing
366
+ * at either end of the month, and neither changes the shape a person sees.
367
+ */
368
+ export interface Phase {
369
+ lit: number
370
+ waxing: boolean
371
+ }
372
+
373
+ export function phase(now: Date): Phase {
374
+ const days = epochDays(now)
375
+ const elongation = wrap(moonEcliptic(days).longitude - sunEcliptic(days))
376
+
377
+ return { lit: (1 - Math.cos(elongation)) / 2, waxing: elongation > 0 }
378
+ }
379
+
380
+ /**
381
+ * The lit part of the moon, as an SVG path about a point.
382
+ *
383
+ * Two arcs. The limb is the disc's own edge, a half circle on the side the sun
384
+ * is; the terminator is the other edge of the light, and it is a half ellipse
385
+ * because it is a circle around the moon seen at an angle. That ellipse is
386
+ * squeezed flat at the quarters, bulges away from the light through the
387
+ * crescent, and bulges into the dark through the gibbous, which is the whole of
388
+ * why a gibbous moon looks fat and a crescent looks thin.
389
+ *
390
+ * The centre is asked for rather than assumed, because a renderer that has to
391
+ * move the path afterwards has to move the box it is drawn in as well, and a
392
+ * shape that leaves its box is a shape somebody's toolkit will quietly cut in
393
+ * half.
394
+ *
395
+ * At the quarters the ellipse has no width left, and an arc with a zero radius
396
+ * is a straight line by the SVG rules, which is exactly the edge a half moon
397
+ * has. Nothing here special-cases it.
398
+ */
399
+ export function crescent(x: number, y: number, radius: number, { lit, waxing }: Phase): string {
400
+ const limb = waxing ? 1 : 0
401
+ const terminator = lit > 0.5 ? limb : 1 - limb
402
+ const waist = round(radius * Math.abs(2 * lit - 1))
403
+ const r = round(radius)
404
+ const top = `${round(x)} ${round(y - radius)}`
405
+ const foot = `${round(x)} ${round(y + radius)}`
406
+
407
+ return `M${top}A${r} ${r} 0 0 ${limb} ${foot}A${waist} ${r} 0 0 ${terminator} ${top}Z`
408
+ }
409
+
410
+ /**
411
+ * How far into the light a point on the disc is, as a share of the radius.
412
+ *
413
+ * Positive in the light, negative in the dark, and near zero on the terminator
414
+ * itself. It is here so a renderer can put the same craters on the moon every
415
+ * night and have them go out as the terminator crosses them, rather than
416
+ * carrying a mark on a part of the moon nothing is lighting.
417
+ *
418
+ * The point is in units of the radius, x to the right and y down, which is what
419
+ * a renderer holds anyway.
420
+ */
421
+ export function sunlit(x: number, y: number, { lit, waxing }: Phase): number {
422
+ const across = waxing ? x : -x
423
+
424
+ return across + (2 * lit - 1) * Math.sqrt(Math.max(0, 1 - y * y))
425
+ }
426
+
427
+ /**
428
+ * The two numbers a renderer reads, from an altitude in degrees.
429
+ *
430
+ * Daylight runs out at -6 degrees, the end of civil twilight, which is when a
431
+ * person outside stops being able to read. It is full by +6, a little after
432
+ * sunrise, because the half hour either side of the horizon is the part worth
433
+ * drawing differently. Dusk peaks at the horizon itself and is gone 8 degrees
434
+ * away, so it rises and falls twice a day and is zero at noon and at midnight.
435
+ */
436
+ export function light(altitude: number): { daylight: number; dusk: number } {
437
+ return {
438
+ daylight: clamp((altitude + 6) / 12, 0, 1),
439
+ dusk: clamp(1 - Math.abs(altitude) / 8, 0, 1),
440
+ }
441
+ }
442
+
443
+ /**
444
+ * The whole sky, right now and where this is running.
445
+ *
446
+ * How light it is, how near a sunset, where each of the two bodies has got to
447
+ * in its own crossing, and what shape the moon is in tonight. `Date` is read
448
+ * here rather than passed, because every caller wants the actual time and the
449
+ * arithmetic above is already testable without it.
450
+ *
451
+ * Both bodies come back, not the one that happens to be up. They are drawn as
452
+ * two discs that fade with `daylight` and against it, which is the only way the
453
+ * changeover works: a single disc that swapped identity at dusk would set in
454
+ * the west and reappear a moment later in the east, and the sky would have
455
+ * jumped. Fading a setting sun out where it set while a moon comes up on the
456
+ * other side is what actually happens, and it costs one more circle.
457
+ *
458
+ * The moon is worked out rather than put opposite the sun. Opposite the sun is
459
+ * the full moon's path and only the full moon's: it has the moon rising at
460
+ * sunset every night of the year, which is right twice a month and an hour or
461
+ * more out the rest of the time. The real one rises about fifty minutes later
462
+ * each night and is a different shape every one of them, and a wallpaper that
463
+ * gets that right is a wallpaper somebody can look up from and check.
464
+ */
465
+ export function sunNow(): {
466
+ daylight: number
467
+ dusk: number
468
+ moon: Passage & Phase
469
+ sun: Passage
470
+ } {
471
+ const now = new Date()
472
+ const here = position(now)
473
+ const days = epochDays(now)
474
+
475
+ const sun = horizon(days, here, equatorial(sunEcliptic(days), 0))
476
+ const moon = moonEcliptic(days)
477
+
478
+ return {
479
+ ...light(sun.altitude),
480
+ moon: {
481
+ ...passage(horizon(days, here, equatorial(moon.longitude, moon.latitude))),
482
+ ...phase(now),
483
+ },
484
+ sun: passage(sun),
485
+ }
486
+ }
487
+
488
+ function clamp(value: number, low: number, high: number): number {
489
+ return Math.min(high, Math.max(low, value))
490
+ }
491
+
492
+ /** An angle brought back into the half turn either side of zero. */
493
+ function wrap(angle: number): number {
494
+ return Math.atan2(Math.sin(angle), Math.cos(angle))
495
+ }
496
+
497
+ /** Enough of a number for a path, and no more of one. */
498
+ function round(value: number): number {
499
+ return Math.round(value * 1000) / 1000
500
+ }