dsh-intertidal 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/LICENSE +21 -0
- package/README.en.md +103 -0
- package/README.md +102 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshots/media-modes.png +0 -0
- package/assets/screenshots/overlay-countdown.png +0 -0
- package/assets/screenshots/procedural-overview.png +0 -0
- package/assets/screenshots/settings-narrow.png +0 -0
- package/cordis.patch.yml +4 -0
- package/dist/client.js +3828 -0
- package/package.json +70 -4
- package/screenshots.json +6 -0
- package/src/cities.js +165 -0
- package/src/client/api.js +140 -0
- package/src/client/birds.js +112 -0
- package/src/client/format.js +28 -0
- package/src/client/i18n.js +250 -0
- package/src/client/index.jsx +163 -0
- package/src/client/overlay.jsx +260 -0
- package/src/client/palette.js +391 -0
- package/src/client/rhythm.js +538 -0
- package/src/client/sea-canvas.jsx +1290 -0
- package/src/client/settings-panel.jsx +430 -0
- package/src/client/sound.js +163 -0
- package/src/client/styles.js +217 -0
- package/src/client/water.js +330 -0
- package/src/host.js +458 -0
- package/src/media.js +423 -0
- package/src/settings.js +322 -0
- package/src/sky.js +428 -0
- package/src/store.js +169 -0
package/src/sky.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 潮间 · 太阳 / 月亮 / 潮汐 / 时段 / 日照长度
|
|
3
|
+
*
|
|
4
|
+
* 纯函数模块:零依赖、零网络、**零 IO**。
|
|
5
|
+
* 所有时间由调用方显式传入(Date | number(ms)),本文件绝不调用 Date.now()。
|
|
6
|
+
* 任何非法输入都不得抛异常——最差情况返回结构合法、数值有限的结果。
|
|
7
|
+
*
|
|
8
|
+
* 算法:NOAA 简化太阳位置(儒略日 → 平黄经/平近点角 → 黄赤交角 → 赤纬 →
|
|
9
|
+
* 恒星时/时角 → 高度角、方位角)。方位角 0..360,正北为 0、顺时针。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const RAD = Math.PI / 180;
|
|
13
|
+
const DEG = 180 / Math.PI;
|
|
14
|
+
|
|
15
|
+
const MS_PER_DAY = 86400000;
|
|
16
|
+
/** J2000 历元对应的「儒略日 - 2451545」基准毫秒:2000-01-01T12:00:00Z */
|
|
17
|
+
const J2000_MS = Date.UTC(2000, 0, 1, 12, 0, 0);
|
|
18
|
+
/** 朔望月长度(天) */
|
|
19
|
+
const SYNODIC_DAYS = 29.530588853;
|
|
20
|
+
/** 参考朔(新月)时刻:2000-01-06T18:14:00Z,JD 2451550.26 附近 */
|
|
21
|
+
const NEW_MOON_MS = Date.UTC(2000, 0, 6, 18, 14, 0);
|
|
22
|
+
/** 半日潮周期(小时) */
|
|
23
|
+
const TIDE_PERIOD_H = 12.42;
|
|
24
|
+
/** 半日潮振幅(米) */
|
|
25
|
+
const TIDE_AMPLITUDE = 1.2;
|
|
26
|
+
/** 日出日落判定用的太阳高度角(含大气折射与日面半径),度 */
|
|
27
|
+
const SUNRISE_ALTITUDE = -0.833;
|
|
28
|
+
|
|
29
|
+
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
30
|
+
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
31
|
+
|
|
32
|
+
/** 归一化到 [0, 360) */
|
|
33
|
+
function norm360(deg) {
|
|
34
|
+
const d = deg % 360;
|
|
35
|
+
return d < 0 ? d + 360 : d;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 归一化到 (-180, 180] */
|
|
39
|
+
function norm180(deg) {
|
|
40
|
+
const d = norm360(deg);
|
|
41
|
+
return d > 180 ? d - 360 : d;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Date | number(ms) -> ms;非法返回 NaN */
|
|
45
|
+
function toMs(date) {
|
|
46
|
+
if (date instanceof Date) return date.getTime();
|
|
47
|
+
if (finite(date)) return date;
|
|
48
|
+
return NaN;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ms -> 儒略日(UT) */
|
|
52
|
+
function julianDay(ms) {
|
|
53
|
+
return ms / MS_PER_DAY + 2440587.5;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 赤道坐标 + 时角 -> 地平坐标(角度制) */
|
|
57
|
+
function horizontal(latDeg, decDeg, hourAngleDeg) {
|
|
58
|
+
const lat = latDeg * RAD;
|
|
59
|
+
const dec = decDeg * RAD;
|
|
60
|
+
const H = hourAngleDeg * RAD;
|
|
61
|
+
|
|
62
|
+
const sinAlt = Math.sin(lat) * Math.sin(dec) + Math.cos(lat) * Math.cos(dec) * Math.cos(H);
|
|
63
|
+
const altitude = Math.asin(clamp(sinAlt, -1, 1)) * DEG;
|
|
64
|
+
|
|
65
|
+
// 以「正南为 0、向西为正」的方位,再加 180° 换成「正北为 0、顺时针」
|
|
66
|
+
const azSouth = Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(lat) - Math.tan(dec) * Math.cos(lat));
|
|
67
|
+
const azimuth = norm360(azSouth * DEG + 180);
|
|
68
|
+
|
|
69
|
+
return { altitude, azimuth };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 格林尼治平恒星时(度) */
|
|
73
|
+
function gmstDeg(jd) {
|
|
74
|
+
const d = jd - 2451545.0;
|
|
75
|
+
const T = d / 36525;
|
|
76
|
+
return norm360(
|
|
77
|
+
280.46061837 +
|
|
78
|
+
360.98564736629 * d +
|
|
79
|
+
0.000387933 * T * T -
|
|
80
|
+
(T * T * T) / 38710000,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 太阳黄经与黄赤交角(度),供太阳与月亮共用 */
|
|
85
|
+
function sunEcliptic(ms) {
|
|
86
|
+
const jd = julianDay(ms);
|
|
87
|
+
const d = jd - 2451545.0;
|
|
88
|
+
const L = 280.46 + 0.9856474 * d; // 平黄经
|
|
89
|
+
const g = 357.528 + 0.9856003 * d; // 平近点角
|
|
90
|
+
const lambda = L + 1.915 * Math.sin(g * RAD) + 0.02 * Math.sin(2 * g * RAD); // 黄经
|
|
91
|
+
const eps = 23.439 - 0.0000004 * d; // 黄赤交角
|
|
92
|
+
return { jd, d, lambda: norm360(lambda), eps, gmst: gmstDeg(jd) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const FALLBACK = Object.freeze({ altitude: 0, azimuth: 0 });
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 太阳位置。date: Date | number(ms)。返回角度制。
|
|
99
|
+
* @returns {{altitude:number, azimuth:number}} 方位角 0..360(正北起顺时针)
|
|
100
|
+
*/
|
|
101
|
+
export function solarPosition(date, lat, lon) {
|
|
102
|
+
const ms = toMs(date);
|
|
103
|
+
const phi = Number(lat);
|
|
104
|
+
const lam = Number(lon);
|
|
105
|
+
if (!Number.isFinite(ms) || !Number.isFinite(phi) || !Number.isFinite(lam)) {
|
|
106
|
+
return { ...FALLBACK };
|
|
107
|
+
}
|
|
108
|
+
if (phi < -90 || phi > 90) return { ...FALLBACK };
|
|
109
|
+
|
|
110
|
+
const { lambda, eps, gmst } = sunEcliptic(ms);
|
|
111
|
+
const sinDec = Math.sin(eps * RAD) * Math.sin(lambda * RAD);
|
|
112
|
+
const dec = Math.asin(clamp(sinDec, -1, 1)) * DEG;
|
|
113
|
+
const ra = Math.atan2(Math.cos(eps * RAD) * Math.sin(lambda * RAD), Math.cos(lambda * RAD)) * DEG;
|
|
114
|
+
const hourAngle = norm180(gmst + lam - ra);
|
|
115
|
+
|
|
116
|
+
return horizontal(phi, dec, hourAngle);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 月亮位置与月相。phase: 0..1,0=朔、0.5=望。
|
|
121
|
+
* @returns {{altitude:number, azimuth:number, phase:number}}
|
|
122
|
+
*/
|
|
123
|
+
export function moonPosition(date, lat, lon) {
|
|
124
|
+
const ms = toMs(date);
|
|
125
|
+
const phi = Number(lat);
|
|
126
|
+
const lam = Number(lon);
|
|
127
|
+
if (!Number.isFinite(ms) || !Number.isFinite(phi) || !Number.isFinite(lam)) {
|
|
128
|
+
return { ...FALLBACK, phase: 0 };
|
|
129
|
+
}
|
|
130
|
+
if (phi < -90 || phi > 90) return { ...FALLBACK, phase: 0 };
|
|
131
|
+
|
|
132
|
+
const { d, lambda: sunLambda, eps, gmst } = sunEcliptic(ms);
|
|
133
|
+
|
|
134
|
+
// 低精度月球位置:平黄经 + 主要摄动项
|
|
135
|
+
const Lm = 218.316 + 13.176396 * d;
|
|
136
|
+
const Mm = 134.963 + 13.064993 * d;
|
|
137
|
+
const Fm = 93.272 + 13.22935 * d;
|
|
138
|
+
const moonLambda = norm360(Lm + 6.289 * Math.sin(Mm * RAD));
|
|
139
|
+
const moonBeta = 5.128 * Math.sin(Fm * RAD);
|
|
140
|
+
|
|
141
|
+
const sinBeta = Math.sin(moonBeta * RAD);
|
|
142
|
+
const cosBeta = Math.cos(moonBeta * RAD);
|
|
143
|
+
const sinLam = Math.sin(moonLambda * RAD);
|
|
144
|
+
const cosLam = Math.cos(moonLambda * RAD);
|
|
145
|
+
const sinEps = Math.sin(eps * RAD);
|
|
146
|
+
const cosEps = Math.cos(eps * RAD);
|
|
147
|
+
|
|
148
|
+
const dec =
|
|
149
|
+
Math.asin(clamp(sinBeta * cosEps + cosBeta * sinEps * sinLam, -1, 1)) * DEG;
|
|
150
|
+
const ra =
|
|
151
|
+
Math.atan2(sinLam * cosEps - (sinBeta / Math.max(cosBeta, 1e-9)) * sinEps, cosLam) * DEG;
|
|
152
|
+
|
|
153
|
+
const hourAngle = norm180(gmst + lam - ra);
|
|
154
|
+
const { altitude, azimuth } = horizontal(phi, dec, hourAngle);
|
|
155
|
+
|
|
156
|
+
// 月相=日月黄经差占一圈的比例:0=朔,0.5=望
|
|
157
|
+
const elongation = norm360(moonLambda - sunLambda);
|
|
158
|
+
|
|
159
|
+
return { altitude, azimuth, phase: elongation / 360 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 半日潮:h = A·sin(2π(t-t₀)/12.42h),A = 1.2 m。
|
|
164
|
+
* t₀ 取距朔日的近似(新月时刻),lon 用来给潮时一个经度相位偏移。
|
|
165
|
+
* @returns {{height:number, trend:'rising'|'falling'}}
|
|
166
|
+
*/
|
|
167
|
+
export function tideAt(date, lon) {
|
|
168
|
+
const ms = toMs(date);
|
|
169
|
+
if (!Number.isFinite(ms)) return { height: 0, trend: 'rising' };
|
|
170
|
+
const lam = Number(lon);
|
|
171
|
+
const lonPhase = Number.isFinite(lam) && Math.abs(lam) <= 180 ? lam / 360 : 0;
|
|
172
|
+
|
|
173
|
+
const hoursSinceNewMoon = (ms - NEW_MOON_MS) / 3600000;
|
|
174
|
+
const cycles = hoursSinceNewMoon / TIDE_PERIOD_H - lonPhase;
|
|
175
|
+
const angle = 2 * Math.PI * cycles;
|
|
176
|
+
|
|
177
|
+
const height = TIDE_AMPLITUDE * Math.sin(angle);
|
|
178
|
+
// 导数 d/dt sin(angle) ∝ cos(angle),正为涨、负为落
|
|
179
|
+
const trend = Math.cos(angle) >= 0 ? 'rising' : 'falling';
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
height: Number.isFinite(height) ? height : 0,
|
|
183
|
+
trend,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 时段:按太阳高度角 + 升落趋势判定。
|
|
189
|
+
* > 40 noon;6~40 afternoon;-6~6 上升 dawn / 下降 dusk;< -6 night。
|
|
190
|
+
* 非法输入(非有限数)返回 'afternoon'(与 skyAt 的兜底一致,不抛)。
|
|
191
|
+
* @returns {'dawn'|'noon'|'afternoon'|'dusk'|'night'}
|
|
192
|
+
*/
|
|
193
|
+
export function classifyPhase(altitude, rising) {
|
|
194
|
+
const alt = Number(altitude);
|
|
195
|
+
if (!Number.isFinite(alt)) return 'afternoon';
|
|
196
|
+
if (alt > 40) return 'noon';
|
|
197
|
+
if (alt >= 6) return 'afternoon';
|
|
198
|
+
if (alt >= -6) return rising ? 'dawn' : 'dusk';
|
|
199
|
+
return 'night';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ------------------------------------------------------------------ *
|
|
203
|
+
* 时区工具(只用传入的时间,不碰 Date.now;Intl 失败一律退回 UTC)
|
|
204
|
+
* ------------------------------------------------------------------ */
|
|
205
|
+
|
|
206
|
+
const tzCache = new Map();
|
|
207
|
+
const dtfCache = new Map();
|
|
208
|
+
|
|
209
|
+
const DTF_OPTIONS = Object.freeze({
|
|
210
|
+
hourCycle: 'h23',
|
|
211
|
+
year: 'numeric',
|
|
212
|
+
month: '2-digit',
|
|
213
|
+
day: '2-digit',
|
|
214
|
+
hour: '2-digit',
|
|
215
|
+
minute: '2-digit',
|
|
216
|
+
second: '2-digit',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** 取(并缓存)某时区的格式化器;非法时区返回 null */
|
|
220
|
+
function getFormatter(timeZone) {
|
|
221
|
+
const hit = dtfCache.get(timeZone);
|
|
222
|
+
if (hit !== undefined) return hit;
|
|
223
|
+
let dtf = null;
|
|
224
|
+
try {
|
|
225
|
+
dtf = new Intl.DateTimeFormat('en-US', { ...DTF_OPTIONS, timeZone });
|
|
226
|
+
} catch {
|
|
227
|
+
dtf = null;
|
|
228
|
+
}
|
|
229
|
+
dtfCache.set(timeZone, dtf);
|
|
230
|
+
return dtf;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 校验并缓存时区;非法时区退回 'UTC'(不抛) */
|
|
234
|
+
function safeTimeZone(tz) {
|
|
235
|
+
if (typeof tz !== 'string' || tz.trim() === '') return 'UTC';
|
|
236
|
+
const key = tz.trim();
|
|
237
|
+
const hit = tzCache.get(key);
|
|
238
|
+
if (hit !== undefined) return hit;
|
|
239
|
+
let resolved = 'UTC';
|
|
240
|
+
try {
|
|
241
|
+
new Intl.DateTimeFormat('en-US', { ...DTF_OPTIONS, timeZone: key });
|
|
242
|
+
resolved = key;
|
|
243
|
+
} catch {
|
|
244
|
+
resolved = 'UTC';
|
|
245
|
+
}
|
|
246
|
+
tzCache.set(key, resolved);
|
|
247
|
+
return resolved;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 某时刻某时区相对 UTC 的偏移(ms)。失败返回 0。 */
|
|
251
|
+
function tzOffsetMs(timeZone, ms) {
|
|
252
|
+
try {
|
|
253
|
+
const dtf = getFormatter(timeZone);
|
|
254
|
+
if (!dtf) return 0;
|
|
255
|
+
const parts = dtf.formatToParts(new Date(ms));
|
|
256
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
257
|
+
const asUtc = Date.UTC(
|
|
258
|
+
get('year'),
|
|
259
|
+
get('month') - 1,
|
|
260
|
+
get('day'),
|
|
261
|
+
get('hour') % 24,
|
|
262
|
+
get('minute'),
|
|
263
|
+
get('second'),
|
|
264
|
+
);
|
|
265
|
+
if (!Number.isFinite(asUtc)) return 0;
|
|
266
|
+
return asUtc - Math.floor(ms / 1000) * 1000;
|
|
267
|
+
} catch {
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 某时刻在该时区的当地日期 + 当地 0 点对应的 UTC ms */
|
|
273
|
+
function localDayStart(timeZone, ms) {
|
|
274
|
+
const offset = tzOffsetMs(timeZone, ms);
|
|
275
|
+
const localMs = ms + offset;
|
|
276
|
+
const localMidnight = Math.floor(localMs / MS_PER_DAY) * MS_PER_DAY;
|
|
277
|
+
const guess = localMidnight - offset;
|
|
278
|
+
// 用落点再算一次偏移,跨夏令时那天也能对得上
|
|
279
|
+
const offset2 = tzOffsetMs(timeZone, guess);
|
|
280
|
+
return localMidnight - offset2;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 把 UTC ms 按某时区格式化成 HH:MM */
|
|
284
|
+
function formatHHMM(timeZone, ms) {
|
|
285
|
+
const offset = tzOffsetMs(timeZone, ms);
|
|
286
|
+
const tod = ((ms + offset) % MS_PER_DAY + MS_PER_DAY) % MS_PER_DAY;
|
|
287
|
+
const totalMinutes = Math.floor(tod / 60000);
|
|
288
|
+
const hh = Math.floor(totalMinutes / 60);
|
|
289
|
+
const mm = totalMinutes % 60;
|
|
290
|
+
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 日出日落;极昼/极夜返回 null(不得出现 00:00)。
|
|
295
|
+
* 逐分钟扫描高度角穿越 -0.833°:先在该时区的当地一天内找太阳中天,
|
|
296
|
+
* 再由中天向两侧各扫一段找上下穿越(天然处理极昼极夜)。
|
|
297
|
+
* tz 非法不抛,退回 UTC 并仍返回可读结果。
|
|
298
|
+
* @returns {{sunrise:string, sunset:string} | null}
|
|
299
|
+
*/
|
|
300
|
+
export function dayLength(date, lat, lon, tz) {
|
|
301
|
+
const ms = toMs(date);
|
|
302
|
+
const phi = Number(lat);
|
|
303
|
+
const lam = Number(lon);
|
|
304
|
+
if (!Number.isFinite(ms) || !Number.isFinite(phi) || !Number.isFinite(lam)) return null;
|
|
305
|
+
if (phi < -90 || phi > 90) return null;
|
|
306
|
+
|
|
307
|
+
const timeZone = safeTimeZone(tz);
|
|
308
|
+
const start = localDayStart(timeZone, ms);
|
|
309
|
+
const MINUTE = 60000;
|
|
310
|
+
const altAt = (offsetMinutes) => solarPosition(start + offsetMinutes * MINUTE, phi, lam).altitude;
|
|
311
|
+
|
|
312
|
+
// 1) 当地这一天里的太阳中天(最高点)
|
|
313
|
+
let transitIndex = 0;
|
|
314
|
+
let transitAltitude = -Infinity;
|
|
315
|
+
for (let i = 0; i <= 1440; i += 1) {
|
|
316
|
+
const alt = altAt(i);
|
|
317
|
+
if (alt > transitAltitude) {
|
|
318
|
+
transitAltitude = alt;
|
|
319
|
+
transitIndex = i;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// 中天都到不了 -0.833° → 极夜
|
|
323
|
+
if (transitAltitude < SUNRISE_ALTITUDE) return null;
|
|
324
|
+
|
|
325
|
+
// 2) 由中天向两侧找一次上/下穿越(最多各扫 18 小时,覆盖极区最长白昼)
|
|
326
|
+
const REACH = 18 * 60;
|
|
327
|
+
let sunriseMs = null;
|
|
328
|
+
let sunsetMs = null;
|
|
329
|
+
|
|
330
|
+
for (let i = transitIndex - 1; i >= transitIndex - REACH; i -= 1) {
|
|
331
|
+
if (altAt(i) < SUNRISE_ALTITUDE) {
|
|
332
|
+
sunriseMs = start + (i + 0.5) * MINUTE; // 穿越落在 i 与 i+1 分钟之间
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (let i = transitIndex + 1; i <= transitIndex + REACH; i += 1) {
|
|
337
|
+
if (altAt(i) < SUNRISE_ALTITUDE) {
|
|
338
|
+
sunsetMs = start + (i - 0.5) * MINUTE;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 极昼:太阳整天都在 -0.833° 之上,没有穿越
|
|
344
|
+
if (sunriseMs === null || sunsetMs === null) return null;
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
sunrise: formatHHMM(timeZone, sunriseMs),
|
|
348
|
+
sunset: formatHHMM(timeZone, sunsetMs),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 输入非法时给 skyAt 的合法兜底结构(phase 固定 afternoon,全数值有限) */
|
|
353
|
+
function fallbackSky(t) {
|
|
354
|
+
return {
|
|
355
|
+
t,
|
|
356
|
+
sun: { ...FALLBACK },
|
|
357
|
+
moon: { ...FALLBACK, phase: 0 },
|
|
358
|
+
tide: { height: 0, trend: 'rising' },
|
|
359
|
+
phase: 'afternoon',
|
|
360
|
+
dayLength: null,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 一次拿全,给 GET /intertidal/now 用。tz 为 IANA 时区名。
|
|
366
|
+
* 非法 lat/lon/tz/date 一律不抛:返回 phase='afternoon' 的合法结构。
|
|
367
|
+
* @param {Date|number} date
|
|
368
|
+
* @param {{lat:number, lon:number, tz?:string}} location
|
|
369
|
+
* @returns {{t:number, sun:object, moon:object, tide:object, phase:string, dayLength:object|null}}
|
|
370
|
+
*/
|
|
371
|
+
export function skyAt(date, location) {
|
|
372
|
+
const ms = toMs(date);
|
|
373
|
+
const t = Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const loc = location && typeof location === 'object' ? location : {};
|
|
377
|
+
const phi = Number(loc.lat);
|
|
378
|
+
const lam = Number(loc.lon);
|
|
379
|
+
|
|
380
|
+
if (!Number.isFinite(ms) || !Number.isFinite(phi) || !Number.isFinite(lam)) {
|
|
381
|
+
return fallbackSky(t);
|
|
382
|
+
}
|
|
383
|
+
if (phi < -90 || phi > 90 || lam < -180 || lam > 180) return fallbackSky(t);
|
|
384
|
+
|
|
385
|
+
const sun = solarPosition(ms, phi, lam);
|
|
386
|
+
const moon = moonPosition(ms, phi, lam);
|
|
387
|
+
const tide = tideAt(ms, lam);
|
|
388
|
+
|
|
389
|
+
// 升落趋势:以 1 分钟前后的高度角之差判定
|
|
390
|
+
const step = 60000;
|
|
391
|
+
const altBefore = solarPosition(ms - step, phi, lam).altitude;
|
|
392
|
+
const altAfter = solarPosition(ms + step, phi, lam).altitude;
|
|
393
|
+
const rising = altAfter >= altBefore;
|
|
394
|
+
|
|
395
|
+
const sky = {
|
|
396
|
+
t,
|
|
397
|
+
sun: { altitude: sun.altitude, azimuth: sun.azimuth },
|
|
398
|
+
moon: { altitude: moon.altitude, azimuth: moon.azimuth, phase: moon.phase },
|
|
399
|
+
tide: { height: tide.height, trend: tide.trend },
|
|
400
|
+
phase: classifyPhase(sun.altitude, rising),
|
|
401
|
+
dayLength: dayLength(ms, phi, lam, loc.tz),
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// 极端数值检查:任何非有限值都退回兜底,绝不把 NaN 送到画布
|
|
405
|
+
const ok =
|
|
406
|
+
Number.isFinite(sky.t) &&
|
|
407
|
+
Number.isFinite(sky.sun.altitude) &&
|
|
408
|
+
Number.isFinite(sky.sun.azimuth) &&
|
|
409
|
+
Number.isFinite(sky.moon.altitude) &&
|
|
410
|
+
Number.isFinite(sky.moon.azimuth) &&
|
|
411
|
+
Number.isFinite(sky.moon.phase) &&
|
|
412
|
+
Number.isFinite(sky.tide.height) &&
|
|
413
|
+
typeof sky.phase === 'string';
|
|
414
|
+
if (!ok) return fallbackSky(t);
|
|
415
|
+
|
|
416
|
+
return sky;
|
|
417
|
+
} catch {
|
|
418
|
+
return fallbackSky(t);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export const SKY_CONSTANTS = Object.freeze({
|
|
423
|
+
SYNODIC_DAYS,
|
|
424
|
+
NEW_MOON_MS,
|
|
425
|
+
TIDE_PERIOD_H,
|
|
426
|
+
TIDE_AMPLITUDE,
|
|
427
|
+
SUNRISE_ALTITUDE,
|
|
428
|
+
});
|
package/src/store.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 潮间 · 状态文件读写
|
|
3
|
+
*
|
|
4
|
+
* 路径:<home>/intertidal/state.json(home 默认 $DSH_HOME ?? ~/.dsh)。
|
|
5
|
+
* 装载期不碰文件系统:只有函数被调用时才 IO。
|
|
6
|
+
* 写入一律原子(tmp + rename),文件 0o600、目录 0o700。
|
|
7
|
+
* 读取遇到文件缺失 / 坏 JSON / 结构不对 → 返回 emptyState,绝不抛、也不删用户文件。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fsp from 'node:fs/promises';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
import { DEFAULTS, LIMITS, mergeSettings, sanitizeSettings, sanitizeRhythm } from './settings.js';
|
|
16
|
+
|
|
17
|
+
const STATE_VERSION = 1;
|
|
18
|
+
const DIR_NAME = 'intertidal';
|
|
19
|
+
const FILE_NAME = 'state.json';
|
|
20
|
+
|
|
21
|
+
/** $DSH_HOME ?? ~/.dsh(支持以 ~ 开头的手写路径) */
|
|
22
|
+
export function resolveDshHome() {
|
|
23
|
+
const raw = process.env.DSH_HOME;
|
|
24
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
25
|
+
const t = raw.trim();
|
|
26
|
+
if (t === '~') return os.homedir();
|
|
27
|
+
if (t.startsWith('~/') || t.startsWith('~\\')) return path.join(os.homedir(), t.slice(2));
|
|
28
|
+
return t;
|
|
29
|
+
}
|
|
30
|
+
return path.join(os.homedir(), '.dsh');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** <home>/intertidal/state.json */
|
|
34
|
+
export function statePath(home) {
|
|
35
|
+
const base = typeof home === 'string' && home.trim() ? home : resolveDshHome();
|
|
36
|
+
return path.join(base, DIR_NAME, FILE_NAME);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 空状态:版本 1 + 默认设置 + 空的 rhythm */
|
|
40
|
+
export function emptyState(nowMs) {
|
|
41
|
+
const ms = nowMs instanceof Date ? nowMs.getTime() : Number(nowMs);
|
|
42
|
+
const nowSec = Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
43
|
+
return {
|
|
44
|
+
version: STATE_VERSION,
|
|
45
|
+
settings: sanitizeSettings(DEFAULTS),
|
|
46
|
+
rhythm: { lastShownAt: null, lastHideAt: null, installedAt: nowSec, skipDates: [] },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 任意对象 → 合法 state(缺项补默认,非法项钳制;不抛) */
|
|
51
|
+
function normalizeState(input, nowMs) {
|
|
52
|
+
const empty = emptyState(nowMs);
|
|
53
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return empty;
|
|
54
|
+
return {
|
|
55
|
+
version: STATE_VERSION,
|
|
56
|
+
settings: sanitizeSettings(input.settings),
|
|
57
|
+
rhythm: sanitizeRhythm(input.rhythm, nowMs),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 读 state;文件缺失 / 坏 JSON / 结构不对 → emptyState(now)。不抛、不删文件。 */
|
|
62
|
+
export async function readState(home) {
|
|
63
|
+
const file = statePath(home);
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
text = await fsp.readFile(file, 'utf8');
|
|
68
|
+
} catch {
|
|
69
|
+
return emptyState(now);
|
|
70
|
+
}
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(text);
|
|
74
|
+
} catch {
|
|
75
|
+
return emptyState(now);
|
|
76
|
+
}
|
|
77
|
+
return normalizeState(parsed, now);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 原子写 state:建目录 → 写 tmp → fsync → rename;文件 0o600、目录 0o700 */
|
|
81
|
+
export async function writeState(home, state) {
|
|
82
|
+
const file = statePath(home);
|
|
83
|
+
const dir = path.dirname(file);
|
|
84
|
+
const normalized = normalizeState(state, Date.now());
|
|
85
|
+
const payload = JSON.stringify(normalized, null, 2) + '\n';
|
|
86
|
+
|
|
87
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
88
|
+
// mkdir 的 mode 会被 umask 削,显式再 chmod 一次(best-effort)
|
|
89
|
+
await fsp.chmod(dir, 0o700).catch(() => {});
|
|
90
|
+
|
|
91
|
+
const tmp = path.join(
|
|
92
|
+
dir,
|
|
93
|
+
`.${FILE_NAME}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const handle = await fsp.open(tmp, 'wx', 0o600);
|
|
98
|
+
try {
|
|
99
|
+
await handle.writeFile(payload, 'utf8');
|
|
100
|
+
await handle.sync();
|
|
101
|
+
} finally {
|
|
102
|
+
await handle.close();
|
|
103
|
+
}
|
|
104
|
+
await fsp.chmod(tmp, 0o600).catch(() => {});
|
|
105
|
+
await fsp.rename(tmp, file);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
await fsp.unlink(tmp).catch(() => {});
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return normalized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 合并并落盘 settings,返回钳制后的完整 settings */
|
|
115
|
+
export async function patchSettings(home, patch) {
|
|
116
|
+
const state = await readState(home);
|
|
117
|
+
const settings = mergeSettings(state.settings, patch);
|
|
118
|
+
const next = { ...state, settings };
|
|
119
|
+
await writeState(home, next);
|
|
120
|
+
return settings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** rhythm 事件:shown / hidden / skip-today / manual-open → 最新 rhythm */
|
|
124
|
+
export async function rhythmEvent(home, event, nowMs) {
|
|
125
|
+
const ms = nowMs instanceof Date ? nowMs.getTime() : Number(nowMs);
|
|
126
|
+
const now = Number.isFinite(ms) ? ms : Date.now();
|
|
127
|
+
const nowSec = Math.floor(now / 1000);
|
|
128
|
+
|
|
129
|
+
const state = await readState(home);
|
|
130
|
+
const rhythm = sanitizeRhythm(state.rhythm, now);
|
|
131
|
+
|
|
132
|
+
switch (event) {
|
|
133
|
+
case 'shown':
|
|
134
|
+
rhythm.lastShownAt = nowSec;
|
|
135
|
+
break;
|
|
136
|
+
case 'hidden':
|
|
137
|
+
rhythm.lastHideAt = nowSec;
|
|
138
|
+
break;
|
|
139
|
+
case 'manual-open':
|
|
140
|
+
// 手动看过 → 也写 lastShownAt,配合「手动看过后顺延」
|
|
141
|
+
rhythm.lastShownAt = nowSec;
|
|
142
|
+
break;
|
|
143
|
+
case 'skip-today': {
|
|
144
|
+
const today = localDate(now);
|
|
145
|
+
// 追加当天日期,去重排序后只保留最近 7 天
|
|
146
|
+
rhythm.skipDates = sanitizeRhythm(
|
|
147
|
+
{ ...rhythm, skipDates: [...rhythm.skipDates, today] },
|
|
148
|
+
now,
|
|
149
|
+
).skipDates;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
throw new TypeError(`unknown rhythm event: ${String(event)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await writeState(home, { ...state, rhythm });
|
|
157
|
+
return rhythm;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 系统本地日期 YYYY-MM-DD(不依赖实现里的时区设置:skipDates 记的是本机日历日) */
|
|
161
|
+
function localDate(nowMs) {
|
|
162
|
+
const d = new Date(nowMs);
|
|
163
|
+
const y = d.getFullYear();
|
|
164
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
165
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
166
|
+
return `${y}-${m}-${day}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export { DEFAULTS, LIMITS };
|