dsh-intertidal 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * 潮间 · palette —— 这一帧的天色、海色与天体位置
3
+ *
4
+ * 契约(docs/CONTRACTS.md §6)只需三个导出:
5
+ * paletteFor(now) /intertidal/now 的响应体 → 一整套颜色 + 天体归一化坐标
6
+ * mix(a, b, t) 两个 '#rrggbb' 线性插值
7
+ * phasePalette(p) 五档时段基准色
8
+ *
9
+ * 设计要点(spec §4.2):
10
+ * - 五档不是硬切:awn/noon/afternoon/dusk/night 只是「高度角锚点」上的基准色,
11
+ * 中间按太阳高度角线性插值,所以天色随高度角连续变化;`phase` 只做兜底
12
+ * (低高度角时区分晨/昏,以及完全拿不到高度角时直接取基准色)。
13
+ * - 天体:y = 0.52 - sin(alt)·0.52,x = 0.5 + tan(方位角偏差)·0.5,
14
+ * 钳制到 -0.2..1.2;altitude < -1° → null。
15
+ * - 纯函数、零 IO、绝不抛:字段缺失/NaN/类型错一律退回 afternoon 的合法结构。
16
+ */
17
+
18
+ /* ---------- 契约常量 ---------- */
19
+ const HORIZON = 0.52; // 地平线 y(归一化)
20
+ const MIN_VISIBLE_ALT = -1; // 低于这个高度角的天体不画
21
+ const POS_MIN = -0.2; // 天体坐标钳制下界
22
+ const POS_MAX = 1.2; // 天体坐标钳制上界
23
+ const FALLBACK_PHASE = 'afternoon';
24
+ const PHASE_NAMES = Object.freeze(['dawn', 'noon', 'afternoon', 'dusk', 'night']);
25
+
26
+ /**
27
+ * 高度角锚点(度)——插值用,不参与时段判定(判定是宿主 sky.js 的事)。
28
+ * twilight 落在 0°,afternoon 落在 12°,于是 spec 的 6° 判定边界正好是
29
+ * 「晨昏 ↔ 午后」的 50% 混合点,画面与时段的边界对得上。
30
+ */
31
+ const ALT = Object.freeze({ night: -14, twilight: 0, afternoon: 16, noon: 48 });
32
+
33
+ /* ---------- 五档基准色 ---------- */
34
+ /**
35
+ * 每档必带:天空三段 + 地平线辉光 + 三层海 + 泡沫 + 天体色 + 星密度 + 曝光。
36
+ * 这些值可以直接改,渲染侧只吃这里的颜色(sea-canvas 不写死任何颜色)。
37
+ */
38
+ const BASE = Object.freeze({
39
+ // 晨曦:橘粉 → 淡青,低对比雾感(海面几乎与天同调)
40
+ dawn: Object.freeze({
41
+ skyTop: '#5d7f9d',
42
+ skyMid: '#c69b9c',
43
+ skyBottom: '#f3b78e',
44
+ horizonGlow: '#ffd2a3',
45
+ seaFar: '#9aa8b0',
46
+ seaMid: '#6b8492',
47
+ seaNear: '#4a6673',
48
+ foam: '#f7e9dd',
49
+ sunColor: '#ffd9a8',
50
+ moonColor: '#eef2f8',
51
+ stars: 0.18,
52
+ exposure: 0.72,
53
+ }),
54
+ // 正午:高饱和蓝,强反光白浪
55
+ noon: Object.freeze({
56
+ skyTop: '#0f63c8',
57
+ skyMid: '#3f9fe0',
58
+ skyBottom: '#b7e1f6',
59
+ horizonGlow: '#f4fbff',
60
+ seaFar: '#3d8fc4',
61
+ seaMid: '#1f74a8',
62
+ seaNear: '#0d5182',
63
+ foam: '#ffffff',
64
+ sunColor: '#fffdf0',
65
+ moonColor: '#eaf2ff',
66
+ stars: 0,
67
+ exposure: 1,
68
+ }),
69
+ // 午后:蓝偏暖(青蓝到米黄的地平线,海偏青),标准反差
70
+ afternoon: Object.freeze({
71
+ skyTop: '#2c78a8',
72
+ skyMid: '#79b1c4',
73
+ skyBottom: '#e8ddbd',
74
+ horizonGlow: '#ffe9c2',
75
+ seaFar: '#4a94a6',
76
+ seaMid: '#2a7185',
77
+ seaNear: '#194f63',
78
+ foam: '#fdf8ea',
79
+ sunColor: '#fff3cf',
80
+ moonColor: '#eef2f8',
81
+ stars: 0,
82
+ exposure: 0.94,
83
+ }),
84
+ // 黄昏:金橙 → 绛紫,逆光金路
85
+ dusk: Object.freeze({
86
+ skyTop: '#372a5b',
87
+ skyMid: '#8c4a77',
88
+ skyBottom: '#f5a04f',
89
+ horizonGlow: '#ffbe6a',
90
+ seaFar: '#8a6172',
91
+ seaMid: '#523a55',
92
+ seaNear: '#2c2039',
93
+ foam: '#ffe7cd',
94
+ sunColor: '#ffb35a',
95
+ moonColor: '#f0ecf4',
96
+ stars: 0.22,
97
+ exposure: 0.62,
98
+ }),
99
+ // 夜:深靛 → 近黑,月光带
100
+ night: Object.freeze({
101
+ skyTop: '#04060f',
102
+ skyMid: '#0a1128',
103
+ skyBottom: '#182a4a',
104
+ horizonGlow: '#2c4570',
105
+ seaFar: '#16253d',
106
+ seaMid: '#0d1729',
107
+ seaNear: '#060b16',
108
+ foam: '#b9c8db',
109
+ sunColor: '#ffeccd',
110
+ moonColor: '#e6eeff',
111
+ stars: 0.9,
112
+ exposure: 0.3,
113
+ }),
114
+ });
115
+
116
+ const COLOR_KEYS = Object.freeze([
117
+ 'skyTop',
118
+ 'skyMid',
119
+ 'skyBottom',
120
+ 'horizonGlow',
121
+ 'seaFar',
122
+ 'seaMid',
123
+ 'seaNear',
124
+ 'foam',
125
+ 'sunColor',
126
+ 'moonColor',
127
+ ]);
128
+ const NUM_KEYS = Object.freeze(['stars', 'exposure']);
129
+
130
+ /* ---------- 小工具(全部容错) ---------- */
131
+
132
+ function num(value, fallback) {
133
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
134
+ }
135
+
136
+ function clamp(value, lo, hi) {
137
+ return value < lo ? lo : value > hi ? hi : value;
138
+ }
139
+
140
+ function clamp01(value) {
141
+ return clamp(num(value, 0), 0, 1);
142
+ }
143
+
144
+ function round(value, digits) {
145
+ const base = Math.pow(10, digits);
146
+ return Math.round(num(value, 0) * base) / base;
147
+ }
148
+
149
+ function parseHex(color) {
150
+ if (typeof color !== 'string') return null;
151
+ const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
152
+ if (!match) return null;
153
+ let hex = match[1];
154
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
155
+ const r = parseInt(hex.slice(0, 2), 16);
156
+ const g = parseInt(hex.slice(2, 4), 16);
157
+ const b = parseInt(hex.slice(4, 6), 16);
158
+ if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) return null;
159
+ return [r, g, b];
160
+ }
161
+
162
+ function toHex(channels) {
163
+ const part = (value) => {
164
+ const byte = Math.round(clamp(num(value, 0), 0, 255));
165
+ return (byte < 16 ? '0' : '') + byte.toString(16);
166
+ };
167
+ return '#' + part(channels[0]) + part(channels[1]) + part(channels[2]);
168
+ }
169
+
170
+ /** 两个 '#rrggbb' 线性插值(t 钳制 0..1;坏输入退回另一侧,绝不抛)。 */
171
+ export function mix(a, b, t) {
172
+ const ca = parseHex(a);
173
+ const cb = parseHex(b);
174
+ if (!ca && !cb) return '#000000';
175
+ if (!ca) return toHex(cb);
176
+ if (!cb) return toHex(ca);
177
+ const k = clamp01(t);
178
+ return toHex([
179
+ ca[0] + (cb[0] - ca[0]) * k,
180
+ ca[1] + (cb[1] - ca[1]) * k,
181
+ ca[2] + (cb[2] - ca[2]) * k,
182
+ ]);
183
+ }
184
+
185
+ function normalizePhase(phase) {
186
+ if (typeof phase !== 'string') return null;
187
+ const key = phase.trim().toLowerCase();
188
+ return PHASE_NAMES.indexOf(key) >= 0 ? key : null;
189
+ }
190
+
191
+ /** 'dawn'|'noon'|'afternoon'|'dusk'|'night' → 基准色(未知一律 afternoon)。 */
192
+ export function phasePalette(phase) {
193
+ const key = normalizePhase(phase) || FALLBACK_PHASE;
194
+ const base = BASE[key];
195
+ const out = { phase: key };
196
+ for (const name of COLOR_KEYS) out[name] = base[name];
197
+ for (const name of NUM_KEYS) out[name] = base[name];
198
+ return out;
199
+ }
200
+
201
+ /** 最后一道兜底:连 computePalette 都抛了(理论上不会),给出写死的合法结构。 */
202
+ function hardFallback() {
203
+ return {
204
+ skyTop: '#1f6fb4',
205
+ skyMid: '#63a7d2',
206
+ skyBottom: '#dcd9c6',
207
+ horizonGlow: '#ffeccb',
208
+ seaFar: '#3b87ab',
209
+ seaMid: '#21688d',
210
+ seaNear: '#12486a',
211
+ foam: '#fdfaf0',
212
+ sun: null,
213
+ moon: null,
214
+ stars: 0,
215
+ exposure: 0.93,
216
+ };
217
+ }
218
+
219
+ /** 两套色阶按 k 混合(0=a, 1=b)。 */
220
+ function blendPalette(a, b, k) {
221
+ const t = clamp01(k);
222
+ const out = {};
223
+ for (const name of COLOR_KEYS) out[name] = mix(a[name], b[name], t);
224
+ for (const name of NUM_KEYS) out[name] = clamp01(a[name] + (b[name] - a[name]) * t);
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * 低高度角时晨/昏同高,只能靠 phase 区分。
230
+ * phase 不可用时用方位角兜底:日出于东(az<180)、日落于西(az>180)。
231
+ * 方位角是连续变化的,所以 altitude 越过 ±6°(宿主在此切 dawn/dusk→afternoon)
232
+ * 时晨/昏的选择不会突然翻面 —— 色阶也就不会硬切。
233
+ */
234
+ function twilightKey(phase, azimuth) {
235
+ if (phase === 'dawn' || phase === 'dusk') return phase;
236
+ if (typeof azimuth === 'number' && Number.isFinite(azimuth)) {
237
+ const normalized = ((azimuth % 360) + 360) % 360;
238
+ return normalized < 180 ? 'dawn' : 'dusk';
239
+ }
240
+ return 'dusk';
241
+ }
242
+
243
+ /** 高度角 → 连续色阶(锚点间线性插值,避免 5 档硬切)。 */
244
+ function ladder(altitude, phase, azimuth) {
245
+ const alt = clamp(num(altitude, 0), -90, 90);
246
+ const twilight = BASE[twilightKey(phase, azimuth)];
247
+ if (alt <= ALT.night) return phasePalette('night');
248
+ if (alt < ALT.twilight) {
249
+ const k = (alt - ALT.night) / (ALT.twilight - ALT.night);
250
+ return blendPalette(BASE.night, twilight, k);
251
+ }
252
+ if (alt < ALT.afternoon) return blendPalette(twilight, BASE.afternoon, alt / ALT.afternoon);
253
+ if (alt < ALT.noon) {
254
+ const k = (alt - ALT.afternoon) / (ALT.noon - ALT.afternoon);
255
+ return blendPalette(BASE.afternoon, BASE.noon, k);
256
+ }
257
+ return phasePalette('noon');
258
+ }
259
+
260
+ /** 读太阳高度角:只要数值合法就用来算天色(**与「能不能看见太阳」无关**)。 */
261
+ function readAltitude(body) {
262
+ if (!body || typeof body !== 'object') return null;
263
+ const altitude = typeof body.altitude === 'number' ? body.altitude : NaN;
264
+ return Number.isFinite(altitude) ? clamp(altitude, -90, 90) : null;
265
+ }
266
+
267
+ /**
268
+ * 天体屏幕坐标(契约 §6):
269
+ * y = 0.52 - sin(altitude)·0.52
270
+ * x = 0.5 + tan(归一化到 [-180,180] 的 (azimuth-180))·0.5
271
+ * 结果钳制到 -0.2..1.2;altitude < -1° 或数值非法 → null。
272
+ */
273
+ function bodyPosition(body) {
274
+ if (!body || typeof body !== 'object') return null;
275
+ const rawAltitude = typeof body.altitude === 'number' ? body.altitude : NaN;
276
+ if (!Number.isFinite(rawAltitude)) return null;
277
+ const altitude = clamp(rawAltitude, -90, 90);
278
+ if (altitude < MIN_VISIBLE_ALT) return null;
279
+
280
+ const azimuth = num(body.azimuth, 180);
281
+ // 归一化到 [-180, 180)
282
+ const deviation = ((((azimuth - 180) % 360) + 540) % 360) - 180;
283
+ const radians = (deviation * Math.PI) / 180;
284
+ const tan = Math.tan(radians);
285
+ const x = clamp(0.5 + (Number.isFinite(tan) ? tan : Math.sign(radians) * 1e6) * 0.5, POS_MIN, POS_MAX);
286
+ const y = clamp(HORIZON - Math.sin((altitude * Math.PI) / 180) * HORIZON, POS_MIN, POS_MAX);
287
+ return { x: round(x, 4), y: round(y, 4), altitude };
288
+ }
289
+
290
+ function normalizeMoonPhase(value) {
291
+ const phase = num(value, 0.5);
292
+ return round(clamp(phase, 0, 1), 4);
293
+ }
294
+
295
+ /** 月面被照亮的比例:0 = 朔,0.5 = 望。 */
296
+ function illuminatedFraction(phase) {
297
+ return clamp01((1 - Math.cos(Math.PI * 2 * phase)) / 2);
298
+ }
299
+
300
+ function sunOf(position, colors) {
301
+ if (!position) return null;
302
+ return {
303
+ x: position.x,
304
+ y: position.y,
305
+ color: colors.sunColor,
306
+ alpha: round(clamp01(0.55 + position.altitude * 0.045), 3),
307
+ };
308
+ }
309
+
310
+ function moonOf(position, colors, phase) {
311
+ if (!position) return null;
312
+ const lit = illuminatedFraction(phase);
313
+ const base = clamp01(0.45 + position.altitude * 0.05);
314
+ return {
315
+ x: position.x,
316
+ y: position.y,
317
+ color: colors.moonColor,
318
+ alpha: round(base * (0.35 + 0.65 * lit), 3),
319
+ phase,
320
+ };
321
+ }
322
+
323
+ function computePalette(now) {
324
+ const source = now && typeof now === 'object' ? now : {};
325
+ const phase = normalizePhase(source.phase);
326
+ // 「太阳挂多高」决定天色(太阳落到 -1° 以下看不见,但天色仍然由它决定)
327
+ const sunAltitude = readAltitude(source.sun);
328
+ const sunPosition = bodyPosition(source.sun);
329
+ const moonPhase = normalizeMoonPhase(source.moon && source.moon.phase);
330
+
331
+ // 有太阳高度角 → 连续插值;没有 → 用 phase 兜底(没有 phase 就是 afternoon)
332
+ const colors =
333
+ sunAltitude === null
334
+ ? phasePalette(phase || FALLBACK_PHASE)
335
+ : ladder(sunAltitude, phase, num(source.sun && source.sun.azimuth, NaN));
336
+
337
+ // 星星:插值值再乘一个「太阳是否真的下去了」的门(白天不出星)
338
+ let stars = clamp01(colors.stars);
339
+ if (sunAltitude !== null) stars *= clamp01((-sunAltitude + 1) / 7);
340
+ else if ((phase || FALLBACK_PHASE) !== 'night') stars = 0;
341
+
342
+ const sun = sunOf(sunPosition, colors);
343
+ const moon = moonOf(bodyPosition(source.moon), colors, moonPhase);
344
+
345
+ return {
346
+ skyTop: colors.skyTop,
347
+ skyMid: colors.skyMid,
348
+ skyBottom: colors.skyBottom,
349
+ horizonGlow: colors.horizonGlow,
350
+ seaFar: colors.seaFar,
351
+ seaMid: colors.seaMid,
352
+ seaNear: colors.seaNear,
353
+ foam: colors.foam,
354
+ sun,
355
+ moon,
356
+ stars: round(stars, 3),
357
+ exposure: round(clamp01(colors.exposure), 3),
358
+ };
359
+ }
360
+
361
+ /**
362
+ * now 就是 GET /intertidal/now 的响应体:
363
+ * { t, sun:{altitude,azimuth}, moon:{altitude,azimuth,phase}, tide:{height,trend}, phase, dayLength }
364
+ * 任何字段缺失/NaN/类型错都返回合法结构,绝不抛。
365
+ */
366
+ export function paletteFor(now) {
367
+ try {
368
+ return computePalette(now);
369
+ } catch (_error) {
370
+ try {
371
+ return computePalette(null);
372
+ } catch (_ignored) {
373
+ return hardFallback();
374
+ }
375
+ }
376
+ }