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/package.json
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-intertidal",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "潮间(Intertidal):让 DSH 按设定间隔把屏幕交给一片此刻的海,到点自己退走。天光跟随本地太阳位置,可用自己的图片或视频作海面。不采集数据。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/host.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/host.js",
|
|
10
|
+
"./client": "./dist/client.js",
|
|
11
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"dist",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"screenshots.json",
|
|
19
|
+
"assets",
|
|
20
|
+
"README.md",
|
|
21
|
+
"README.en.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"dsh": {
|
|
25
|
+
"manifestVersion": 1,
|
|
26
|
+
"bundle": {
|
|
27
|
+
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web",
|
|
31
|
+
"immediately": true,
|
|
32
|
+
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20",
|
|
39
|
+
"dsh": ">=0.2.1-rc.5 <0.3.0"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"dsh",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"dsh-plugin",
|
|
45
|
+
"dsh-market",
|
|
46
|
+
"intertidal",
|
|
47
|
+
"sea",
|
|
48
|
+
"ambience",
|
|
49
|
+
"screensaver",
|
|
50
|
+
"canvas"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "node scripts/build.mjs",
|
|
54
|
+
"build:check": "node scripts/build.mjs --check",
|
|
55
|
+
"test": "node --test test/*.test.js",
|
|
56
|
+
"verify": "node scripts/build.mjs && node --test test/*.test.js",
|
|
57
|
+
"prepack": "node scripts/build.mjs"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"esbuild": "^0.25.10"
|
|
61
|
+
},
|
|
62
|
+
"author": "winditer",
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/winditer/dsh-intertidal.git"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/winditer/dsh-intertidal#readme",
|
|
68
|
+
"bugs": {
|
|
69
|
+
"type": "url",
|
|
70
|
+
"url": "https://github.com/winditer/dsh-intertidal/issues"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/screenshots.json
ADDED
package/src/cities.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 潮间 · 内置城市表(≥ 40)与按系统时区猜城市
|
|
3
|
+
*
|
|
4
|
+
* 零依赖、零 IO。城市表只含「经纬度 + IANA 时区」,不含任何气候/水温等外部数据。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** 内置城市表:id 用拉丁小写,zh/en 给 i18n 用 */
|
|
8
|
+
export const CITIES = Object.freeze(
|
|
9
|
+
[
|
|
10
|
+
{ id: 'shanghai', zh: '上海', en: 'Shanghai', lat: 31.23, lon: 121.47, tz: 'Asia/Shanghai' },
|
|
11
|
+
{ id: 'beijing', zh: '北京', en: 'Beijing', lat: 39.9, lon: 116.41, tz: 'Asia/Shanghai' },
|
|
12
|
+
{ id: 'guangzhou', zh: '广州', en: 'Guangzhou', lat: 23.13, lon: 113.26, tz: 'Asia/Shanghai' },
|
|
13
|
+
{ id: 'shenzhen', zh: '深圳', en: 'Shenzhen', lat: 22.54, lon: 114.06, tz: 'Asia/Shanghai' },
|
|
14
|
+
{ id: 'chengdu', zh: '成都', en: 'Chengdu', lat: 30.57, lon: 104.07, tz: 'Asia/Shanghai' },
|
|
15
|
+
{ id: 'hangzhou', zh: '杭州', en: 'Hangzhou', lat: 30.27, lon: 120.15, tz: 'Asia/Shanghai' },
|
|
16
|
+
{ id: 'nanjing', zh: '南京', en: 'Nanjing', lat: 32.06, lon: 118.8, tz: 'Asia/Shanghai' },
|
|
17
|
+
{ id: 'wuhan', zh: '武汉', en: 'Wuhan', lat: 30.59, lon: 114.31, tz: 'Asia/Shanghai' },
|
|
18
|
+
{ id: 'xian', zh: '西安', en: "Xi'an", lat: 34.34, lon: 108.94, tz: 'Asia/Shanghai' },
|
|
19
|
+
{ id: 'chongqing', zh: '重庆', en: 'Chongqing', lat: 29.56, lon: 106.55, tz: 'Asia/Shanghai' },
|
|
20
|
+
{ id: 'qingdao', zh: '青岛', en: 'Qingdao', lat: 36.07, lon: 120.38, tz: 'Asia/Shanghai' },
|
|
21
|
+
{ id: 'xiamen', zh: '厦门', en: 'Xiamen', lat: 24.48, lon: 118.09, tz: 'Asia/Shanghai' },
|
|
22
|
+
{ id: 'harbin', zh: '哈尔滨', en: 'Harbin', lat: 45.8, lon: 126.53, tz: 'Asia/Shanghai' },
|
|
23
|
+
{ id: 'kunming', zh: '昆明', en: 'Kunming', lat: 25.04, lon: 102.71, tz: 'Asia/Shanghai' },
|
|
24
|
+
{ id: 'urumqi', zh: '乌鲁木齐', en: 'Urumqi', lat: 43.83, lon: 87.62, tz: 'Asia/Shanghai' },
|
|
25
|
+
{ id: 'lhasa', zh: '拉萨', en: 'Lhasa', lat: 29.65, lon: 91.14, tz: 'Asia/Shanghai' },
|
|
26
|
+
{ id: 'hongkong', zh: '香港', en: 'Hong Kong', lat: 22.32, lon: 114.17, tz: 'Asia/Hong_Kong' },
|
|
27
|
+
{ id: 'macau', zh: '澳门', en: 'Macau', lat: 22.2, lon: 113.54, tz: 'Asia/Macau' },
|
|
28
|
+
{ id: 'taipei', zh: '台北', en: 'Taipei', lat: 25.03, lon: 121.57, tz: 'Asia/Taipei' },
|
|
29
|
+
{ id: 'tokyo', zh: '东京', en: 'Tokyo', lat: 35.68, lon: 139.69, tz: 'Asia/Tokyo' },
|
|
30
|
+
{ id: 'osaka', zh: '大阪', en: 'Osaka', lat: 34.69, lon: 135.5, tz: 'Asia/Tokyo' },
|
|
31
|
+
{ id: 'seoul', zh: '首尔', en: 'Seoul', lat: 37.57, lon: 126.98, tz: 'Asia/Seoul' },
|
|
32
|
+
{ id: 'singapore', zh: '新加坡', en: 'Singapore', lat: 1.35, lon: 103.82, tz: 'Asia/Singapore' },
|
|
33
|
+
{ id: 'bangkok', zh: '曼谷', en: 'Bangkok', lat: 13.76, lon: 100.5, tz: 'Asia/Bangkok' },
|
|
34
|
+
{ id: 'hochiminh', zh: '胡志明市', en: 'Ho Chi Minh City', lat: 10.82, lon: 106.63, tz: 'Asia/Ho_Chi_Minh' },
|
|
35
|
+
{ id: 'jakarta', zh: '雅加达', en: 'Jakarta', lat: -6.21, lon: 106.85, tz: 'Asia/Jakarta' },
|
|
36
|
+
{ id: 'kualalumpur', zh: '吉隆坡', en: 'Kuala Lumpur', lat: 3.14, lon: 101.69, tz: 'Asia/Kuala_Lumpur' },
|
|
37
|
+
{ id: 'manila', zh: '马尼拉', en: 'Manila', lat: 14.6, lon: 120.98, tz: 'Asia/Manila' },
|
|
38
|
+
{ id: 'mumbai', zh: '孟买', en: 'Mumbai', lat: 19.08, lon: 72.88, tz: 'Asia/Kolkata' },
|
|
39
|
+
{ id: 'delhi', zh: '新德里', en: 'New Delhi', lat: 28.61, lon: 77.21, tz: 'Asia/Kolkata' },
|
|
40
|
+
{ id: 'dubai', zh: '迪拜', en: 'Dubai', lat: 25.2, lon: 55.27, tz: 'Asia/Dubai' },
|
|
41
|
+
{ id: 'istanbul', zh: '伊斯坦布尔', en: 'Istanbul', lat: 41.01, lon: 28.98, tz: 'Europe/Istanbul' },
|
|
42
|
+
{ id: 'moscow', zh: '莫斯科', en: 'Moscow', lat: 55.76, lon: 37.62, tz: 'Europe/Moscow' },
|
|
43
|
+
{ id: 'athens', zh: '雅典', en: 'Athens', lat: 37.98, lon: 23.73, tz: 'Europe/Athens' },
|
|
44
|
+
{ id: 'rome', zh: '罗马', en: 'Rome', lat: 41.9, lon: 12.5, tz: 'Europe/Rome' },
|
|
45
|
+
{ id: 'berlin', zh: '柏林', en: 'Berlin', lat: 52.52, lon: 13.4, tz: 'Europe/Berlin' },
|
|
46
|
+
{ id: 'paris', zh: '巴黎', en: 'Paris', lat: 48.86, lon: 2.35, tz: 'Europe/Paris' },
|
|
47
|
+
{ id: 'madrid', zh: '马德里', en: 'Madrid', lat: 40.42, lon: -3.7, tz: 'Europe/Madrid' },
|
|
48
|
+
{ id: 'lisbon', zh: '里斯本', en: 'Lisbon', lat: 38.72, lon: -9.14, tz: 'Europe/Lisbon' },
|
|
49
|
+
{ id: 'amsterdam', zh: '阿姆斯特丹', en: 'Amsterdam', lat: 52.37, lon: 4.9, tz: 'Europe/Amsterdam' },
|
|
50
|
+
{ id: 'london', zh: '伦敦', en: 'London', lat: 51.51, lon: -0.13, tz: 'Europe/London' },
|
|
51
|
+
{ id: 'stockholm', zh: '斯德哥尔摩', en: 'Stockholm', lat: 59.33, lon: 18.07, tz: 'Europe/Stockholm' },
|
|
52
|
+
{ id: 'oslo', zh: '奥斯陆', en: 'Oslo', lat: 59.91, lon: 10.75, tz: 'Europe/Oslo' },
|
|
53
|
+
{ id: 'helsinki', zh: '赫尔辛基', en: 'Helsinki', lat: 60.17, lon: 24.94, tz: 'Europe/Helsinki' },
|
|
54
|
+
{ id: 'reykjavik', zh: '雷克雅未克', en: 'Reykjavik', lat: 64.15, lon: -21.94, tz: 'Atlantic/Reykjavik' },
|
|
55
|
+
{ id: 'tromso', zh: '特罗姆瑟', en: 'Tromso', lat: 69.65, lon: 18.96, tz: 'Europe/Oslo' },
|
|
56
|
+
{ id: 'longyearbyen', zh: '朗伊尔城', en: 'Longyearbyen', lat: 78.22, lon: 15.65, tz: 'Arctic/Longyearbyen' },
|
|
57
|
+
{ id: 'newyork', zh: '纽约', en: 'New York', lat: 40.71, lon: -74.01, tz: 'America/New_York' },
|
|
58
|
+
{ id: 'losangeles', zh: '洛杉矶', en: 'Los Angeles', lat: 34.05, lon: -118.24, tz: 'America/Los_Angeles' },
|
|
59
|
+
{ id: 'sanfrancisco', zh: '旧金山', en: 'San Francisco', lat: 37.77, lon: -122.42, tz: 'America/Los_Angeles' },
|
|
60
|
+
{ id: 'seattle', zh: '西雅图', en: 'Seattle', lat: 47.61, lon: -122.33, tz: 'America/Los_Angeles' },
|
|
61
|
+
{ id: 'chicago', zh: '芝加哥', en: 'Chicago', lat: 41.88, lon: -87.63, tz: 'America/Chicago' },
|
|
62
|
+
{ id: 'denver', zh: '丹佛', en: 'Denver', lat: 39.74, lon: -104.99, tz: 'America/Denver' },
|
|
63
|
+
{ id: 'anchorage', zh: '安克雷奇', en: 'Anchorage', lat: 61.22, lon: -149.9, tz: 'America/Anchorage' },
|
|
64
|
+
{ id: 'toronto', zh: '多伦多', en: 'Toronto', lat: 43.65, lon: -79.38, tz: 'America/Toronto' },
|
|
65
|
+
{ id: 'vancouver', zh: '温哥华', en: 'Vancouver', lat: 49.28, lon: -123.12, tz: 'America/Vancouver' },
|
|
66
|
+
{ id: 'mexicocity', zh: '墨西哥城', en: 'Mexico City', lat: 19.43, lon: -99.13, tz: 'America/Mexico_City' },
|
|
67
|
+
{ id: 'bogota', zh: '波哥大', en: 'Bogota', lat: 4.71, lon: -74.07, tz: 'America/Bogota' },
|
|
68
|
+
{ id: 'lima', zh: '利马', en: 'Lima', lat: -12.05, lon: -77.04, tz: 'America/Lima' },
|
|
69
|
+
{ id: 'saopaulo', zh: '圣保罗', en: 'Sao Paulo', lat: -23.55, lon: -46.63, tz: 'America/Sao_Paulo' },
|
|
70
|
+
{ id: 'buenosaires', zh: '布宜诺斯艾利斯', en: 'Buenos Aires', lat: -34.6, lon: -58.38, tz: 'America/Argentina/Buenos_Aires' },
|
|
71
|
+
{ id: 'santiago', zh: '圣地亚哥', en: 'Santiago', lat: -33.45, lon: -70.67, tz: 'America/Santiago' },
|
|
72
|
+
{ id: 'cairo', zh: '开罗', en: 'Cairo', lat: 30.04, lon: 31.24, tz: 'Africa/Cairo' },
|
|
73
|
+
{ id: 'nairobi', zh: '内罗毕', en: 'Nairobi', lat: -1.29, lon: 36.82, tz: 'Africa/Nairobi' },
|
|
74
|
+
{ id: 'lagos', zh: '拉各斯', en: 'Lagos', lat: 6.52, lon: 3.38, tz: 'Africa/Lagos' },
|
|
75
|
+
{ id: 'capetown', zh: '开普敦', en: 'Cape Town', lat: -33.92, lon: 18.42, tz: 'Africa/Johannesburg' },
|
|
76
|
+
{ id: 'sydney', zh: '悉尼', en: 'Sydney', lat: -33.87, lon: 151.21, tz: 'Australia/Sydney' },
|
|
77
|
+
{ id: 'melbourne', zh: '墨尔本', en: 'Melbourne', lat: -37.81, lon: 144.96, tz: 'Australia/Melbourne' },
|
|
78
|
+
{ id: 'brisbane', zh: '布里斯班', en: 'Brisbane', lat: -27.47, lon: 153.03, tz: 'Australia/Brisbane' },
|
|
79
|
+
{ id: 'perth', zh: '珀斯', en: 'Perth', lat: -31.95, lon: 115.86, tz: 'Australia/Perth' },
|
|
80
|
+
{ id: 'auckland', zh: '奥克兰', en: 'Auckland', lat: -36.85, lon: 174.76, tz: 'Pacific/Auckland' },
|
|
81
|
+
{ id: 'wellington', zh: '惠灵顿', en: 'Wellington', lat: -41.29, lon: 174.78, tz: 'Pacific/Auckland' },
|
|
82
|
+
{ id: 'honolulu', zh: '檀香山', en: 'Honolulu', lat: 21.31, lon: -157.86, tz: 'Pacific/Honolulu' },
|
|
83
|
+
].map(Object.freeze),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/** 兜底城市:猜不到就回上海(契约要求 guessCity 永不返回 undefined) */
|
|
87
|
+
const FALLBACK_CITY = CITIES[0];
|
|
88
|
+
|
|
89
|
+
const REGION_CITY = Object.freeze({
|
|
90
|
+
CN: 'shanghai', TW: 'taipei', HK: 'hongkong', MO: 'macau',
|
|
91
|
+
JP: 'tokyo', KR: 'seoul', SG: 'singapore', TH: 'bangkok',
|
|
92
|
+
VN: 'hochiminh', ID: 'jakarta', MY: 'kualalumpur', PH: 'manila',
|
|
93
|
+
IN: 'mumbai', AE: 'dubai', TR: 'istanbul', RU: 'moscow',
|
|
94
|
+
GR: 'athens', IT: 'rome', DE: 'berlin', FR: 'paris',
|
|
95
|
+
ES: 'madrid', PT: 'lisbon', NL: 'amsterdam', GB: 'london',
|
|
96
|
+
SE: 'stockholm', NO: 'oslo', FI: 'helsinki', IS: 'reykjavik',
|
|
97
|
+
US: 'newyork', CA: 'toronto', MX: 'mexicocity', BR: 'saopaulo',
|
|
98
|
+
AR: 'buenosaires', CL: 'santiago', CO: 'bogota', PE: 'lima',
|
|
99
|
+
EG: 'cairo', KE: 'nairobi', NG: 'lagos', ZA: 'capetown',
|
|
100
|
+
AU: 'sydney', NZ: 'auckland',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** 球面距离(km) */
|
|
104
|
+
function distanceKm(lat1, lon1, lat2, lon2) {
|
|
105
|
+
const R = 6371;
|
|
106
|
+
const toRad = Math.PI / 180;
|
|
107
|
+
const dLat = (lat2 - lat1) * toRad;
|
|
108
|
+
const dLon = (lon2 - lon1) * toRad;
|
|
109
|
+
const a =
|
|
110
|
+
Math.sin(dLat / 2) ** 2 +
|
|
111
|
+
Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
|
|
112
|
+
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function nearestOf(list, lat, lon) {
|
|
116
|
+
let best = list[0];
|
|
117
|
+
let bestD = Infinity;
|
|
118
|
+
for (const city of list) {
|
|
119
|
+
const d = distanceKm(lat, lon, city.lat, city.lon);
|
|
120
|
+
if (d < bestD) {
|
|
121
|
+
bestD = d;
|
|
122
|
+
best = city;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return best;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 按 tz / locale / 坐标猜城市。永不返回 undefined(猜不到就返回上海)。
|
|
130
|
+
* 优先级:tz 精确命中 → 坐标最近 → locale 区域 → 上海。
|
|
131
|
+
*/
|
|
132
|
+
export function guessCity(input) {
|
|
133
|
+
const opts = input && typeof input === 'object' ? input : {};
|
|
134
|
+
const { tz, locale } = opts;
|
|
135
|
+
const lat = Number(opts.lat);
|
|
136
|
+
const lon = Number(opts.lon);
|
|
137
|
+
const hasCoords =
|
|
138
|
+
Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
|
|
139
|
+
|
|
140
|
+
if (typeof tz === 'string' && tz) {
|
|
141
|
+
const sameTz = CITIES.filter((c) => c.tz === tz);
|
|
142
|
+
if (sameTz.length > 0) return hasCoords ? nearestOf(sameTz, lat, lon) : sameTz[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (hasCoords) return nearestOf(CITIES, lat, lon);
|
|
146
|
+
|
|
147
|
+
if (typeof locale === 'string' && locale) {
|
|
148
|
+
const region = locale.split(/[-_]/)[1];
|
|
149
|
+
const id = region ? REGION_CITY[region.toUpperCase()] : undefined;
|
|
150
|
+
const hit = id ? findCity(id) : undefined;
|
|
151
|
+
if (hit) return hit;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return FALLBACK_CITY;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 按 id / 中文名 / 英文名查城市(忽略大小写与首尾空格) */
|
|
158
|
+
export function findCity(id) {
|
|
159
|
+
if (typeof id !== 'string') return undefined;
|
|
160
|
+
const key = id.trim().toLowerCase();
|
|
161
|
+
if (!key) return undefined;
|
|
162
|
+
return CITIES.find(
|
|
163
|
+
(c) => c.id === key || c.zh === id.trim() || c.en.toLowerCase() === key,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// 潮间 —— `/intertidal/*` 的浏览器侧封装。
|
|
2
|
+
//
|
|
3
|
+
// 三条要求(spec §8):
|
|
4
|
+
// 1. **永远不抛**:所有方法都 resolve,失败返回 `{ ok:false }`,由 runtime 决定降级;
|
|
5
|
+
// 2. 设置写入失败时用 `localStorage` 镜像,刷新后仍能用你刚改的值;
|
|
6
|
+
// 3. 超时必须有:宿主卡住时不能把 UI 拖住(4 秒)。
|
|
7
|
+
|
|
8
|
+
const BASE = '/intertidal'
|
|
9
|
+
const MIRROR_KEY = 'dsh-intertidal:settings'
|
|
10
|
+
const TIMEOUT_MS = 4000
|
|
11
|
+
|
|
12
|
+
/** 读镜像(坏 JSON 就当没有)。 */
|
|
13
|
+
export function readMirror(storage = safeStorage()) {
|
|
14
|
+
try {
|
|
15
|
+
const raw = storage?.getItem(MIRROR_KEY)
|
|
16
|
+
if (!raw) return null
|
|
17
|
+
const parsed = JSON.parse(raw)
|
|
18
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeMirror(settings, storage = safeStorage()) {
|
|
25
|
+
try {
|
|
26
|
+
storage?.setItem(MIRROR_KEY, JSON.stringify(settings))
|
|
27
|
+
} catch {
|
|
28
|
+
/* 隐私模式 / 配额满 → 忽略 */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeStorage() {
|
|
33
|
+
try {
|
|
34
|
+
return globalThis.localStorage ?? null
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function call(path, { method = 'GET', body, signal } = {}, fetchImpl) {
|
|
41
|
+
const doFetch = fetchImpl ?? globalThis.fetch
|
|
42
|
+
if (typeof doFetch !== 'function') return { ok: false, status: 0, error: 'no-fetch' }
|
|
43
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
44
|
+
const timer = controller ? setTimeout(() => controller.abort(), TIMEOUT_MS) : null
|
|
45
|
+
try {
|
|
46
|
+
const init = { method, cache: 'no-store', credentials: 'same-origin', signal: controller?.signal ?? signal }
|
|
47
|
+
if (body !== undefined) {
|
|
48
|
+
init.headers = { 'content-type': 'application/json' }
|
|
49
|
+
init.body = JSON.stringify(body)
|
|
50
|
+
}
|
|
51
|
+
const res = await doFetch(`${BASE}${path}`, init)
|
|
52
|
+
if (!res.ok) return { ok: false, status: res.status, error: `http-${res.status}` }
|
|
53
|
+
const value = await res.json()
|
|
54
|
+
return { ok: true, value }
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return { ok: false, status: 0, error: String(error?.name === 'AbortError' ? 'timeout' : (error?.message ?? error)) }
|
|
57
|
+
} finally {
|
|
58
|
+
if (timer) clearTimeout(timer)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 造一个 api。`onStatus` 用来把"宿主是否可用"报给 runtime(设置页据此显示一行告警)。
|
|
64
|
+
*/
|
|
65
|
+
export function createApi({ fetchImpl, storage = safeStorage(), onStatus } = {}) {
|
|
66
|
+
let offline = false
|
|
67
|
+
const note = (ok) => {
|
|
68
|
+
if (offline === !ok) return
|
|
69
|
+
offline = !ok
|
|
70
|
+
try {
|
|
71
|
+
onStatus?.(offline)
|
|
72
|
+
} catch {
|
|
73
|
+
/* 回调自己抛了不该影响请求 */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get offline() {
|
|
79
|
+
return offline
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** GET /state → `{ settings, rhythm, now }`;失败时退镜像 + null。 */
|
|
83
|
+
async state() {
|
|
84
|
+
const result = await call('/state', {}, fetchImpl)
|
|
85
|
+
note(result.ok)
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
writeMirror(result.value?.settings, storage)
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
const mirror = readMirror(storage)
|
|
91
|
+
return { ok: false, error: result.error, value: mirror ? { settings: mirror, rhythm: null, now: null } : null }
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/** GET /now;带地点参数就按参数算,不带就用宿主设置。 */
|
|
95
|
+
async now(location) {
|
|
96
|
+
const query = location && Number.isFinite(location.lat) && Number.isFinite(location.lon)
|
|
97
|
+
? `?lat=${encodeURIComponent(location.lat)}&lon=${encodeURIComponent(location.lon)}&tz=${encodeURIComponent(location.tz ?? '')}`
|
|
98
|
+
: ''
|
|
99
|
+
const result = await call(`/now${query}`, {}, fetchImpl)
|
|
100
|
+
note(result.ok)
|
|
101
|
+
return result
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/** POST /settings:整份覆盖式提交(宿主做合并与钳制)。 */
|
|
105
|
+
async saveSettings(patch) {
|
|
106
|
+
const result = await call('/settings', { method: 'POST', body: patch }, fetchImpl)
|
|
107
|
+
note(result.ok)
|
|
108
|
+
if (result.ok) writeMirror(result.value, storage)
|
|
109
|
+
else {
|
|
110
|
+
const mirror = readMirror(storage) ?? {}
|
|
111
|
+
writeMirror({ ...mirror, ...patch }, storage)
|
|
112
|
+
}
|
|
113
|
+
return result
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/** POST /rhythm:`shown` / `hidden` / `skip-today` / `manual-open`。 */
|
|
117
|
+
async rhythm(event) {
|
|
118
|
+
const result = await call('/rhythm', { method: 'POST', body: { event } }, fetchImpl)
|
|
119
|
+
note(result.ok)
|
|
120
|
+
return result
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/** GET /media:这一次浮现用哪张素材(`fresh` 强制重新抽一张)。 */
|
|
124
|
+
async media(fresh = false) {
|
|
125
|
+
const result = await call(`/media${fresh ? '?fresh=1' : ''}`, {}, fetchImpl)
|
|
126
|
+
// 素材失败不影响看海(会退回程序化海),所以不改变 offline 判定
|
|
127
|
+
return result
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/** POST /media/scan:设置页验证目录里有多少素材。 */
|
|
131
|
+
async scanMedia(dir) {
|
|
132
|
+
return call('/media/scan', { method: 'POST', body: { dir } }, fetchImpl)
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/** POST /media/resolve:把一个网页/图片链接抓成本地缓存。 */
|
|
136
|
+
async resolveMedia(url) {
|
|
137
|
+
return call('/media/resolve', { method: 'POST', body: { url } }, fetchImpl)
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 潮间 · 白天的海鸥。
|
|
2
|
+
//
|
|
3
|
+
// 设计约束(都在 spec 里):不联网、不加载素材、**不依赖帧数**、随时可停帧、任何分支都不抛。
|
|
4
|
+
// 所以这里不用"状态机 + 随机生成器",而是**时间函数**:第 i 只鸟的起飞时刻、航线、扇翅相位
|
|
5
|
+
// 全部由 t 和一个整数哈希决定。好处是:
|
|
6
|
+
// - 同一 t 画出同一帧(可单测、可截图复现);
|
|
7
|
+
// - 暂停/隐藏再恢复时不会"补飞"一段突兀的动画;
|
|
8
|
+
// - 不会因为掉帧而多出或少掉一只鸟。
|
|
9
|
+
//
|
|
10
|
+
// 视觉:远处的小剪影、两段贝塞尔翅膀 + 轻微上下起伏,一队 3 只错开飞过。做成"随机"的观感靠
|
|
11
|
+
// 每只鸟的周期/航线/相位互质,屏幕上不会出现明显规律。
|
|
12
|
+
|
|
13
|
+
const TAU = Math.PI * 2;
|
|
14
|
+
|
|
15
|
+
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
16
|
+
|
|
17
|
+
/** 整数哈希 → [0,1),用来给每只鸟不同的参数。 */
|
|
18
|
+
function hash(n) {
|
|
19
|
+
let x = Math.imul(n | 0, 2654435761);
|
|
20
|
+
x = Math.imul(x ^ (x >>> 15), 2246822507);
|
|
21
|
+
return ((x ^ (x >>> 13)) >>> 0) / 4294967296;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const BIRD_CONSTANTS = Object.freeze({
|
|
25
|
+
slots: 9, // 同时在"档期"里的鸟槽(不是同屏数量)
|
|
26
|
+
minPeriod: 19, // 最短的一个轮回(秒)
|
|
27
|
+
periodSpan: 23, // 周期跨度:每只鸟都不同,避免同拍
|
|
28
|
+
minFlight: 7, // 飞过一整屏需要的秒数(下界)
|
|
29
|
+
flightSpan: 5,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 白天度:0 = 完全夜里(不出鸟),1 = 大白天。深夜/晨曦微光时自动降到 0。
|
|
34
|
+
* @param {any} pal paletteFor() 的返回值
|
|
35
|
+
*/
|
|
36
|
+
export function daylightOf(pal) {
|
|
37
|
+
if (!pal || typeof pal !== 'object') return 0;
|
|
38
|
+
// 没有 exposure 就当作"不知道天光"——宁可不画鸟,也不要在大半夜画出一群海鸥。
|
|
39
|
+
if (!Number.isFinite(pal.exposure)) return 0;
|
|
40
|
+
const stars = Number.isFinite(pal.stars) ? pal.stars : 0;
|
|
41
|
+
return clamp01((pal.exposure - 0.45) / 0.4) * clamp01(1 - stars * 1.6);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 画一帧海鸥。
|
|
46
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
47
|
+
* @param {{ W:number, H:number, horizonY:number, t:number, pal:any }} view
|
|
48
|
+
* @returns {number} 这一帧画出的鸟数(测试用)
|
|
49
|
+
*/
|
|
50
|
+
export function drawBirds(ctx, view) {
|
|
51
|
+
const { W, H, horizonY, t } = view;
|
|
52
|
+
const pal = view.pal ?? {};
|
|
53
|
+
const daylight = daylightOf(pal);
|
|
54
|
+
if (daylight <= 0.02) return 0;
|
|
55
|
+
if (!Number.isFinite(W) || !Number.isFinite(H) || !Number.isFinite(horizonY)) return 0;
|
|
56
|
+
if (horizonY <= 4 || !ctx || typeof ctx.beginPath !== 'function') return 0;
|
|
57
|
+
|
|
58
|
+
const { slots, minPeriod, periodSpan, minFlight, flightSpan } = BIRD_CONSTANTS;
|
|
59
|
+
const skyH = horizonY; // 鸟只在地平线以上的天空里
|
|
60
|
+
const scale = Math.max(0.55, Math.min(2.2, H / 720));
|
|
61
|
+
// 白天越亮,鸟越多;夜里 0
|
|
62
|
+
const density = 0.55 + 0.45 * daylight;
|
|
63
|
+
|
|
64
|
+
let drawn = 0;
|
|
65
|
+
for (let i = 0; i < slots; i += 1) {
|
|
66
|
+
const period = minPeriod + hash(i * 3 + 1) * periodSpan;
|
|
67
|
+
const flight = minFlight + hash(i * 7 + 5) * flightSpan;
|
|
68
|
+
const offset = hash(i * 11 + 2) * period;
|
|
69
|
+
// 每一轮里"在飞"的时间占比 = flight/period,其余时间不出现
|
|
70
|
+
const local = (t + offset) % period;
|
|
71
|
+
if (local > flight) continue;
|
|
72
|
+
// 密度筛选:不同的鸟按不同概率出现,视觉上不是固定 3 只
|
|
73
|
+
if (hash(i * 13 + 9) > density) continue;
|
|
74
|
+
|
|
75
|
+
const p = local / flight; // 0 → 1 航线进度
|
|
76
|
+
const r1 = hash(i * 17 + 3);
|
|
77
|
+
const r2 = hash(i * 19 + 11);
|
|
78
|
+
const r3 = hash(i * 23 + 7);
|
|
79
|
+
const leftToRight = r1 > 0.45;
|
|
80
|
+
// 从屏幕外飞进来、再飞出屏幕外
|
|
81
|
+
const x0 = leftToRight ? -0.08 * W : 1.08 * W;
|
|
82
|
+
const x1 = leftToRight ? 1.08 * W : -0.08 * W;
|
|
83
|
+
const x = x0 + (x1 - x0) * p;
|
|
84
|
+
// 高度:地平线以上、偏上;越靠近地平线画得越小(远)
|
|
85
|
+
const depth = 0.28 + 0.42 * r2; // 0.28(低/远) → 0.70(高/近)
|
|
86
|
+
const bob = Math.sin((p * 2.0 + r3) * TAU) * skyH * 0.012;
|
|
87
|
+
const y = horizonY * depth + bob;
|
|
88
|
+
|
|
89
|
+
const size = (11 + 9 * r3) * scale * (0.55 + 0.6 * depth);
|
|
90
|
+
// 翅膀扇动:每只鸟频率不同,period 内做 6–11 次
|
|
91
|
+
const flaps = 6 + Math.floor(r3 * 5);
|
|
92
|
+
const wing = Math.sin(p * flaps * TAU + r2 * TAU);
|
|
93
|
+
// 侧倾:扇翅到极值时翅膀更"平",中间更"折",看起来像在滑翔与扇动之间切换
|
|
94
|
+
const lift = 0.55 + 0.45 * Math.abs(wing);
|
|
95
|
+
const alpha = clamp01((0.5 + 0.32 * daylight) * (0.7 + 0.3 * Math.sin(p * Math.PI)));
|
|
96
|
+
|
|
97
|
+
ctx.save();
|
|
98
|
+
ctx.strokeStyle = `rgba(28,34,44,${alpha.toFixed(3)})`;
|
|
99
|
+
ctx.lineWidth = Math.max(1.1, 1.7 * scale);
|
|
100
|
+
ctx.lineCap = 'round';
|
|
101
|
+
if (typeof ctx.beginPath === 'function') {
|
|
102
|
+
// 两段翅膀:左翼 + 右翼,用二次曲线做出"上反角"
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.moveTo(x - size, y - wing * size * 0.55 * lift);
|
|
105
|
+
ctx.quadraticCurveTo(x - size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x, y);
|
|
106
|
+
ctx.quadraticCurveTo(x + size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x + size, y - wing * size * 0.55 * lift);
|
|
107
|
+
ctx.stroke();
|
|
108
|
+
}
|
|
109
|
+
drawn += 1;
|
|
110
|
+
}
|
|
111
|
+
return drawn;
|
|
112
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// 潮间 —— 覆盖层里那些"给人看的小格式"。单独放一个纯 JS 文件,好让 node --test 直接 import
|
|
2
|
+
// (.jsx 在 node 里没法直接加载,测试只能绕)。
|
|
3
|
+
|
|
4
|
+
/** 剩余毫秒 → `m:ss`(不足 1 小时)或 `h:mm:ss`;向上取整,起点显示 3:00 而不是 2:59。 */
|
|
5
|
+
export function formatRemaining(ms) {
|
|
6
|
+
const total = Math.max(0, Math.ceil(Number(ms) / 1000) || 0)
|
|
7
|
+
const hours = Math.floor(total / 3600)
|
|
8
|
+
const minutes = Math.floor((total % 3600) / 60)
|
|
9
|
+
const seconds = total % 60
|
|
10
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
11
|
+
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 某个地点的钟面时间(HH:MM);时区非法就退回本机时区。 */
|
|
15
|
+
export function formatClock(date, tz) {
|
|
16
|
+
try {
|
|
17
|
+
return new Intl.DateTimeFormat('en-GB', {
|
|
18
|
+
hour: '2-digit',
|
|
19
|
+
minute: '2-digit',
|
|
20
|
+
hour12: false,
|
|
21
|
+
timeZone: tz || undefined,
|
|
22
|
+
}).format(date)
|
|
23
|
+
} catch {
|
|
24
|
+
const h = String(date.getHours()).padStart(2, '0')
|
|
25
|
+
const m = String(date.getMinutes()).padStart(2, '0')
|
|
26
|
+
return `${h}:${m}`
|
|
27
|
+
}
|
|
28
|
+
}
|