@yunlefun-home/air-conditioner-core 0.2.0 → 0.3.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/dist/index.cjs CHANGED
@@ -22,17 +22,74 @@ const acAudioFileNames = {
22
22
  };
23
23
  const minTemperature = 16;
24
24
  const maxTemperature = 31;
25
+ const defaultAcLimits = {
26
+ min: 16,
27
+ max: 31,
28
+ step: 1
29
+ };
25
30
  const recommendedColdTemperature = 26;
26
31
  const recommendedHotTemperature = 20;
27
32
  const defaultAcState = {
28
33
  mode: "cold",
29
34
  status: false,
30
- temperature: 26
35
+ temperature: 26,
36
+ sound: "normal"
37
+ };
38
+ /**
39
+ * 遥控器「声音」键依次切换的顺序。
40
+ */
41
+ const acSoundModes = [
42
+ "normal",
43
+ "sleep",
44
+ "mute"
45
+ ];
46
+ const acSoundModeLabels = {
47
+ normal: "标准",
48
+ sleep: "睡眠",
49
+ mute: "静音"
50
+ };
51
+ /**
52
+ * 各声音模式的总音量(0–1)。
53
+ */
54
+ const acSoundVolumes = {
55
+ normal: 1,
56
+ sleep: .3,
57
+ mute: 0
31
58
  };
59
+ function isAcSoundMode(value) {
60
+ return acSoundModes.includes(value);
61
+ }
62
+ /**
63
+ * 声音键的下一个模式:标准 → 睡眠 → 静音 → 标准。
64
+ */
65
+ function nextAcSoundMode(mode) {
66
+ const index = Math.max(acSoundModes.indexOf(mode), 0);
67
+ return acSoundModes[(index + 1) % acSoundModes.length];
68
+ }
69
+ /**
70
+ * 音效时序。时长单位为毫秒,`*Level` 是风声相对音量(0–1)。
71
+ */
32
72
  const soundTimings = {
73
+ /** 开机后多久接上持续的风声(启动声约 9 秒) */
33
74
  startDelayMs: 8e3,
34
- noiseStartTime: 2,
35
- noiseDuration: 56
75
+ /** 风声循环首尾的交叉淡化时长 */
76
+ loopCrossfadeMs: 1500,
77
+ /** 关机时声音淡出时长 */
78
+ stopFadeMs: 600,
79
+ /** 开机运行多久后室温达标、风声降下来 */
80
+ settleAfterMs: 9e4,
81
+ /** 风声降下来的渐变时长 */
82
+ settleRampMs: 2e4,
83
+ /** 达标后的安静风声 */
84
+ settleLevel: .5,
85
+ /** 室温回升、压缩机重新发力时的风声 */
86
+ surgeLevel: .85,
87
+ /** 两次发力之间安静运行的时长范围 */
88
+ quietDurationMs: [18e4, 36e4],
89
+ /** 每次发力持续的时长范围 */
90
+ surgeDurationMs: [6e4, 12e4],
91
+ /** 发力 / 回落的渐变时长 */
92
+ surgeRampMs: 1e4
36
93
  };
37
94
  /**
38
95
  * 根据基础路径解析出三个音频文件的完整 URL。
@@ -52,78 +109,535 @@ function resolveAcAudioAssets(baseUrl = "/assets/audio", ext = "m4a") {
52
109
  }
53
110
  //#endregion
54
111
  //#region src/audio.ts
55
- function createAcAudioController(elements) {
56
- let playStartSoundTimeoutId;
57
- let playWorkSoundTimeoutId;
58
- let playWorkSoundIntervalId;
59
- function clearWorkTimers() {
60
- if (playStartSoundTimeoutId) clearTimeout(playStartSoundTimeoutId);
61
- if (playWorkSoundTimeoutId) clearTimeout(playWorkSoundTimeoutId);
62
- if (playWorkSoundIntervalId) clearInterval(playWorkSoundIntervalId);
63
- playStartSoundTimeoutId = void 0;
64
- playWorkSoundTimeoutId = void 0;
65
- playWorkSoundIntervalId = void 0;
112
+ /** Web Audio 资源迟迟没加载好时,本次开机先用 `<audio>` 播放 */
113
+ const readyTimeoutMs = 1500;
114
+ const fallbackTweenStepMs = 100;
115
+ const curveLength = 64;
116
+ function sourceUrl(element) {
117
+ return element?.currentSrc || element?.src || "";
118
+ }
119
+ async function fetchAudioData(url) {
120
+ const response = await fetch(url);
121
+ if (!response.ok) throw new Error(`Failed to load ${url}: ${response.status}`);
122
+ return response.arrayBuffer();
123
+ }
124
+ function createDefaultAudioContext() {
125
+ const scope = globalThis;
126
+ const AudioContextConstructor = scope.AudioContext ?? scope.webkitAudioContext;
127
+ return AudioContextConstructor ? new AudioContextConstructor() : null;
128
+ }
129
+ /**
130
+ * 等功率淡入 / 淡出曲线,噪声类声音交叉淡化时中段不会变小。
131
+ */
132
+ function fadeCurve(direction) {
133
+ const curve = new Float32Array(curveLength);
134
+ for (let i = 0; i < curveLength; i++) {
135
+ const phase = i / 63 * Math.PI / 2;
136
+ curve[i] = direction === "in" ? Math.sin(phase) : Math.cos(phase);
66
137
  }
67
- function play(audio) {
68
- if (!audio) return;
69
- audio.load();
70
- audio.play();
138
+ return curve;
139
+ }
140
+ function randomBetween(range, random) {
141
+ return range[0] + (range[1] - range[0]) * random();
142
+ }
143
+ function playElement(element) {
144
+ if (!element) return;
145
+ element.load();
146
+ Promise.resolve(element.play()).catch(() => {});
147
+ }
148
+ function stopElement(element) {
149
+ if (!element) return;
150
+ if (element.pause) element.pause();
151
+ else element.load();
152
+ }
153
+ /**
154
+ * 空调音效:按键「嘀」声、开机启动声与持续的风声。
155
+ *
156
+ * 默认通过 Web Audio API 播放:风声无缝循环,开关机淡入淡出,运行一段时间后
157
+ * 风声变小、并不定时随压缩机发力起伏。环境不支持或音频无法获取时退回
158
+ * `<audio>` 元素。
159
+ *
160
+ * 需在用户交互(点击遥控器)里首次调用,浏览器才允许发声。
161
+ *
162
+ * @param elements 三个音频元素;Web Audio 会从它们的 `src` 获取音频
163
+ * @param options 声音模式与可替换的环境依赖
164
+ */
165
+ function createAcAudioController(elements, options = {}) {
166
+ const random = options.random ?? Math.random;
167
+ let soundMode = options.soundMode ?? "normal";
168
+ /** undefined:尚未尝试;null:不可用 */
169
+ let engine;
170
+ let run;
171
+ /** 每次开关机自增,丢弃过期的异步回调 */
172
+ let session = 0;
173
+ const timers = /* @__PURE__ */ new Set();
174
+ /** `<audio>` 播放时的风声相对音量 */
175
+ let fallbackFanLevel = 1;
176
+ let fallbackTween;
177
+ function later(callback, delayMs) {
178
+ const timer = setTimeout(() => {
179
+ timers.delete(timer);
180
+ callback();
181
+ }, delayMs);
182
+ timers.add(timer);
183
+ }
184
+ function clearTimers() {
185
+ timers.forEach((timer) => clearTimeout(timer));
186
+ timers.clear();
187
+ }
188
+ function volume() {
189
+ return acSoundVolumes[soundMode];
190
+ }
191
+ function applyElementVolume(element, level = 1) {
192
+ if (!element) return;
193
+ if (element.volume !== void 0) element.volume = Math.min(1, Math.max(0, volume() * level));
194
+ if (element.muted !== void 0) element.muted = soundMode === "mute";
195
+ }
196
+ function cancelFallbackTween() {
197
+ if (fallbackTween) clearInterval(fallbackTween);
198
+ fallbackTween = void 0;
199
+ }
200
+ function tweenFallbackFan(target, durationMs, onDone) {
201
+ cancelFallbackTween();
202
+ const from = fallbackFanLevel;
203
+ const steps = Math.max(1, Math.round(durationMs / fallbackTweenStepMs));
204
+ let step = 0;
205
+ fallbackTween = setInterval(() => {
206
+ step++;
207
+ fallbackFanLevel = from + (target - from) * (step / steps);
208
+ applyElementVolume(elements.work, fallbackFanLevel);
209
+ if (step >= steps) {
210
+ cancelFallbackTween();
211
+ onDone?.();
212
+ }
213
+ }, fallbackTweenStepMs);
214
+ }
215
+ function ensureEngine() {
216
+ if (engine !== void 0) return engine;
217
+ engine = createEngine();
218
+ return engine;
71
219
  }
72
- function playWorkSound() {
73
- if (!elements.work) return;
74
- play(elements.work);
75
- playWorkSoundTimeoutId = setTimeout(() => {
76
- playWorkSoundIntervalId = setInterval(() => {
77
- if (elements.work) elements.work.currentTime = soundTimings.noiseStartTime;
78
- }, soundTimings.noiseDuration * 1e3);
79
- }, soundTimings.noiseStartTime * 1e3);
220
+ function createEngine() {
221
+ if (options.webAudio === false) return null;
222
+ let context;
223
+ try {
224
+ context = (options.createAudioContext ?? createDefaultAudioContext)();
225
+ } catch {
226
+ return null;
227
+ }
228
+ if (!context) return null;
229
+ const master = context.createGain();
230
+ master.gain.value = volume();
231
+ master.connect(context.destination);
232
+ const buffers = {};
233
+ const load = options.loadAudioData ?? fetchAudioData;
234
+ const decode = async (name) => {
235
+ const url = sourceUrl(elements[name]);
236
+ if (!url) return;
237
+ try {
238
+ buffers[name] = await context.decodeAudioData(await load(url));
239
+ } catch {}
240
+ };
241
+ const ready = Promise.all([
242
+ "beep",
243
+ "start",
244
+ "work"
245
+ ].map(decode)).then(() => Boolean(buffers.start && buffers.work));
246
+ return {
247
+ context,
248
+ master,
249
+ buffers,
250
+ ready
251
+ };
252
+ }
253
+ function resume(web) {
254
+ if (web.context.state === "suspended") Promise.resolve(web.context.resume()).catch(() => {});
255
+ }
256
+ function waitReady(web) {
257
+ return Promise.race([web.ready, new Promise((resolve) => setTimeout(resolve, readyTimeoutMs, false))]);
258
+ }
259
+ function playBuffer(web, buffer, destination, when) {
260
+ const source = web.context.createBufferSource();
261
+ source.buffer = buffer;
262
+ source.connect(destination);
263
+ source.start(when);
264
+ return source;
80
265
  }
81
- function playStartSound() {
82
- play(elements.start);
83
- playStartSoundTimeoutId = setTimeout(() => {
84
- playWorkSound();
266
+ function track(target, source, cleanup) {
267
+ target.sources.add(source);
268
+ source.onended = () => {
269
+ target.sources.delete(source);
270
+ source.disconnect();
271
+ cleanup?.();
272
+ };
273
+ }
274
+ /**
275
+ * 排一段风声,并在它结束前交叉淡化接上下一段。
276
+ */
277
+ function loopFan(web, target, when) {
278
+ const buffer = web.buffers.work;
279
+ const crossfade = Math.min(soundTimings.loopCrossfadeMs / 1e3, buffer.duration / 2);
280
+ const gain = web.context.createGain();
281
+ gain.gain.value = 0;
282
+ gain.gain.setValueCurveAtTime(fadeCurve("in"), when, crossfade);
283
+ gain.gain.setValueCurveAtTime(fadeCurve("out"), when + buffer.duration - crossfade, crossfade);
284
+ gain.connect(target.fan);
285
+ const source = playBuffer(web, buffer, gain, when);
286
+ source.stop(when + buffer.duration);
287
+ track(target, source, () => gain.disconnect());
288
+ const next = when + buffer.duration - crossfade;
289
+ later(() => {
290
+ if (run === target) loopFan(web, target, next);
291
+ }, Math.max(0, (next - web.context.currentTime - 1) * 1e3));
292
+ }
293
+ function setFanLevel(level, rampMs) {
294
+ if (run && engine) {
295
+ const gain = run.fan.gain;
296
+ const now = engine.context.currentTime;
297
+ gain.cancelScheduledValues(now);
298
+ gain.setValueAtTime(gain.value, now);
299
+ gain.linearRampToValueAtTime(level, now + rampMs / 1e3);
300
+ } else tweenFallbackFan(level, rampMs);
301
+ }
302
+ /**
303
+ * 室温达标后风声变小;之后不定时随压缩机发力变大,再回落。
304
+ */
305
+ function startFanCycle() {
306
+ const quiet = () => {
307
+ later(() => {
308
+ setFanLevel(soundTimings.surgeLevel, soundTimings.surgeRampMs);
309
+ later(() => {
310
+ setFanLevel(soundTimings.settleLevel, soundTimings.surgeRampMs);
311
+ quiet();
312
+ }, randomBetween(soundTimings.surgeDurationMs, random));
313
+ }, randomBetween(soundTimings.quietDurationMs, random));
314
+ };
315
+ later(() => {
316
+ setFanLevel(soundTimings.settleLevel, soundTimings.settleRampMs);
317
+ quiet();
318
+ }, soundTimings.settleAfterMs);
319
+ }
320
+ function startWeb(web) {
321
+ const output = web.context.createGain();
322
+ output.connect(web.master);
323
+ const fan = web.context.createGain();
324
+ fan.connect(output);
325
+ const target = {
326
+ output,
327
+ fan,
328
+ sources: /* @__PURE__ */ new Set()
329
+ };
330
+ run = target;
331
+ const now = web.context.currentTime;
332
+ track(target, playBuffer(web, web.buffers.start, output, now));
333
+ loopFan(web, target, now + soundTimings.startDelayMs / 1e3);
334
+ later(startFanCycle, soundTimings.startDelayMs);
335
+ }
336
+ function stopWeb(fadeMs) {
337
+ const target = run;
338
+ run = void 0;
339
+ if (!target || !engine) return;
340
+ const now = engine.context.currentTime;
341
+ const end = now + fadeMs / 1e3;
342
+ const gain = target.output.gain;
343
+ gain.cancelScheduledValues(now);
344
+ gain.setValueAtTime(gain.value, now);
345
+ gain.linearRampToValueAtTime(0, end);
346
+ target.sources.forEach((source) => {
347
+ try {
348
+ source.stop(end);
349
+ } catch {}
350
+ });
351
+ setTimeout(() => target.output.disconnect(), fadeMs + 100);
352
+ }
353
+ function startFallback() {
354
+ const { start, work } = elements;
355
+ stopElement(work);
356
+ applyElementVolume(start);
357
+ playElement(start);
358
+ later(() => {
359
+ if (!work) return;
360
+ fallbackFanLevel = 0;
361
+ applyElementVolume(work, 0);
362
+ if (work.loop !== void 0) work.loop = true;
363
+ playElement(work);
364
+ tweenFallbackFan(1, soundTimings.loopCrossfadeMs);
365
+ startFanCycle();
85
366
  }, soundTimings.startDelayMs);
86
367
  }
368
+ function stopFallback(fadeMs) {
369
+ stopElement(elements.start);
370
+ if (fadeMs > 0) tweenFallbackFan(0, fadeMs, () => stopElement(elements.work));
371
+ else stopElement(elements.work);
372
+ }
373
+ function powerOn() {
374
+ const id = ++session;
375
+ clearTimers();
376
+ cancelFallbackTween();
377
+ stopWeb(0);
378
+ stopFallback(0);
379
+ const web = ensureEngine();
380
+ if (!web) {
381
+ startFallback();
382
+ return;
383
+ }
384
+ resume(web);
385
+ waitReady(web).then((ok) => {
386
+ if (id !== session) return;
387
+ if (ok) startWeb(web);
388
+ else startFallback();
389
+ });
390
+ }
391
+ function powerOff() {
392
+ session++;
393
+ clearTimers();
394
+ stopWeb(soundTimings.stopFadeMs);
395
+ stopFallback(soundTimings.stopFadeMs);
396
+ }
87
397
  return {
88
398
  beep() {
89
- play(elements.beep);
399
+ if (soundMode === "mute") return;
400
+ const web = ensureEngine();
401
+ if (web) {
402
+ resume(web);
403
+ if (web.buffers.beep) {
404
+ playBuffer(web, web.buffers.beep, web.master, web.context.currentTime);
405
+ return;
406
+ }
407
+ }
408
+ applyElementVolume(elements.beep);
409
+ playElement(elements.beep);
90
410
  },
91
411
  toggleStatusAudio(previousStatus) {
92
- if (previousStatus) {
93
- clearWorkTimers();
94
- if (elements.start) elements.start.load();
95
- if (elements.work) elements.work.currentTime = soundTimings.noiseStartTime + soundTimings.noiseDuration;
96
- } else {
97
- clearWorkTimers();
98
- playStartSound();
99
- }
412
+ if (previousStatus) powerOff();
413
+ else powerOn();
414
+ },
415
+ setSoundMode(mode) {
416
+ soundMode = mode;
417
+ if (engine) engine.master.gain.setTargetAtTime(volume(), engine.context.currentTime, .08);
418
+ applyElementVolume(elements.beep);
419
+ applyElementVolume(elements.start);
420
+ applyElementVolume(elements.work, fallbackFanLevel);
100
421
  },
101
422
  dispose() {
102
- clearWorkTimers();
423
+ session++;
424
+ clearTimers();
425
+ cancelFallbackTween();
426
+ stopWeb(0);
427
+ stopFallback(0);
428
+ if (engine && !options.createAudioContext) Promise.resolve(engine.context.close()).catch(() => {});
429
+ engine = null;
103
430
  }
104
431
  };
105
432
  }
106
433
  //#endregion
434
+ //#region src/motion.ts
435
+ /**
436
+ * 动效 token:各框架视图层共用的时长与缓动曲线。
437
+ *
438
+ * 样式里统一通过 CSS 变量(`--acw-motion-*`)引用,
439
+ * 需要在 JS 里计时的地方(如 React 过渡组件)直接读取 `acMotion`。
440
+ */
441
+ const acMotion = {
442
+ duration: {
443
+ /** 按下反馈 */
444
+ instant: 90,
445
+ /** 悬停、指示灯、图标 */
446
+ fast: 160,
447
+ /** 显示屏、数字切换、提示 */
448
+ base: 240,
449
+ /** 导风板、出风淡入淡出 */
450
+ slow: 420,
451
+ /** 出风气流单次循环 */
452
+ flow: 1800
453
+ },
454
+ easing: {
455
+ /** 常规状态切换 */
456
+ standard: "cubic-bezier(0.2, 0, 0, 1)",
457
+ /** 进入:快起慢停 */
458
+ enter: "cubic-bezier(0, 0, 0, 1)",
459
+ /** 退出:慢起快走 */
460
+ exit: "cubic-bezier(0.3, 0, 1, 1)",
461
+ /** 轻微回弹(按钮松开、图标弹出) */
462
+ spring: "cubic-bezier(0.34, 1.56, 0.64, 1)"
463
+ }
464
+ };
465
+ /**
466
+ * `prefers-reduced-motion: reduce` 时使用的时长:
467
+ * 保留短促的淡入淡出(不涉及位移),去掉较长的过渡;循环动画由各样式表自行关闭。
468
+ */
469
+ const acReducedMotionDuration = {
470
+ instant: 0,
471
+ fast: 80,
472
+ base: 120,
473
+ slow: 120,
474
+ flow: 0
475
+ };
476
+ const acReducedMotionQuery = "(prefers-reduced-motion: reduce)";
477
+ /**
478
+ * 当前环境是否要求减少动效(SSR / 无 matchMedia 时返回 `false`)。
479
+ */
480
+ function prefersReducedMotion() {
481
+ return typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
482
+ }
483
+ function declarations(durations, withEasing, indent) {
484
+ const lines = Object.keys(durations).map((key) => `${indent}--acw-motion-duration-${key}: ${durations[key]}ms;`);
485
+ if (withEasing) for (const key of Object.keys(acMotion.easing)) lines.push(`${indent}--acw-motion-ease-${key}: ${acMotion.easing[key]};`);
486
+ return lines.join("\n");
487
+ }
488
+ /**
489
+ * 生成动效 CSS 变量声明(含 reduced-motion 覆盖),供 Shadow DOM 注入或样式表同步使用。
490
+ *
491
+ * @param selector 变量挂载的选择器,如 `:root`、`:host`
492
+ */
493
+ function renderAcMotionCssVars(selector = ":root") {
494
+ return [
495
+ `${selector} {`,
496
+ declarations(acMotion.duration, true, " "),
497
+ "}",
498
+ "",
499
+ `@media ${acReducedMotionQuery} {`,
500
+ ` ${selector} {`,
501
+ declarations(acReducedMotionDuration, false, " "),
502
+ " }",
503
+ "}"
504
+ ].join("\n");
505
+ }
506
+ //#endregion
507
+ //#region src/layout.ts
508
+ /**
509
+ * 机身的设计尺寸(px),约 3:1,接近真实壁挂机(如 800–900mm × 280–300mm)的比例。
510
+ * 窄容器下按比例整体缩放,保持「长款」外观不被压扁。
511
+ *
512
+ * - `compact`:Vue / React 组件库 / Web Component 使用的 360 × 128 机身
513
+ * - `wide`:React 主站使用的 600 × 200 机身
514
+ */
515
+ const acShellSize = {
516
+ compact: {
517
+ width: 360,
518
+ height: 128
519
+ },
520
+ wide: {
521
+ width: 600,
522
+ height: 200
523
+ }
524
+ };
525
+ /**
526
+ * 根据可用宽度计算机身缩放比例:只缩小、不放大。
527
+ */
528
+ function getAcScale(availableWidth, designWidth) {
529
+ if (!(availableWidth > 0) || !(designWidth > 0)) return 1;
530
+ return Math.min(1, availableWidth / designWidth);
531
+ }
532
+ /**
533
+ * 监听容器宽度变化并回调缩放比例。
534
+ *
535
+ * 不支持 `ResizeObserver` 的环境(如 SSR)直接返回空的清理函数,保持 1 倍显示。
536
+ *
537
+ * @param target 被测量宽度的容器(不要传被缩放的元素本身)
538
+ * @param designWidth 机身的设计宽度
539
+ * @param onScale 比例变化时的回调
540
+ * @returns 停止监听的清理函数
541
+ */
542
+ function observeAcScale(target, designWidth, onScale) {
543
+ if (typeof ResizeObserver === "undefined") return () => {};
544
+ let current;
545
+ const update = (width) => {
546
+ const scale = getAcScale(width, designWidth);
547
+ if (scale !== current) {
548
+ current = scale;
549
+ onScale(scale);
550
+ }
551
+ };
552
+ update(target.getBoundingClientRect().width);
553
+ const observer = new ResizeObserver((entries) => {
554
+ for (const entry of entries) update(entry.contentRect.width);
555
+ });
556
+ observer.observe(target);
557
+ return () => observer.disconnect();
558
+ }
559
+ /** 指针跟随倾斜的最大角度(deg) */
560
+ const acMaxTilt = 4;
561
+ const finePointerQuery = "(hover: hover) and (pointer: fine)";
562
+ /**
563
+ * 指针在元素上移动时回调轻微的 3D 倾斜角度(`rotateX` / `rotateY`,单位 deg),离开时回到 0。
564
+ *
565
+ * 仅在有悬停能力的精细指针(鼠标 / 触控板)上启用;`prefers-reduced-motion: reduce`、
566
+ * 触屏或 SSR 下直接返回空的清理函数。
567
+ *
568
+ * @returns 停止监听的清理函数
569
+ */
570
+ function observeAcTilt(target, onTilt, maxTilt = 4) {
571
+ const matchMedia = globalThis.matchMedia;
572
+ if (typeof matchMedia !== "function" || !matchMedia(finePointerQuery).matches || matchMedia("(prefers-reduced-motion: reduce)").matches) return () => {};
573
+ let frame = 0;
574
+ const schedule = (rotateX, rotateY) => {
575
+ cancelAnimationFrame(frame);
576
+ frame = requestAnimationFrame(() => onTilt(rotateX, rotateY));
577
+ };
578
+ const onMove = (event) => {
579
+ const rect = target.getBoundingClientRect();
580
+ if (!rect.width || !rect.height) return;
581
+ const x = (event.clientX - rect.left) / rect.width - .5;
582
+ const y = (event.clientY - rect.top) / rect.height - .5;
583
+ const round = (value) => Math.round(value * 100) / 100;
584
+ schedule(round(-y * 2 * maxTilt), round(x * 2 * maxTilt));
585
+ };
586
+ const onLeave = () => schedule(0, 0);
587
+ target.addEventListener("pointermove", onMove);
588
+ target.addEventListener("pointerleave", onLeave);
589
+ return () => {
590
+ cancelAnimationFrame(frame);
591
+ target.removeEventListener("pointermove", onMove);
592
+ target.removeEventListener("pointerleave", onLeave);
593
+ };
594
+ }
595
+ //#endregion
107
596
  //#region src/reducer.ts
108
- function normalizeAcState(state) {
597
+ /**
598
+ * Merge a partial state over the defaults and clamp its temperature.
599
+ *
600
+ * @param state - Partial or stored state
601
+ * @param limits - Device limits; omit to keep the default 16–31 °C range
602
+ */
603
+ function normalizeAcState(state, limits) {
109
604
  return {
110
605
  ...defaultAcState,
111
606
  ...state,
112
- temperature: clampTemperature(state?.temperature ?? defaultAcState.temperature)
607
+ temperature: clampTemperature(state?.temperature ?? defaultAcState.temperature, limits),
608
+ sound: isAcSoundMode(state?.sound) ? state.sound : defaultAcState.sound
113
609
  };
114
610
  }
115
- function clampTemperature(temperature) {
116
- return Math.min(31, Math.max(16, temperature));
611
+ /**
612
+ * Clamp a temperature into range. When `limits` are given the value also
613
+ * snaps to the device step, measured from `limits.min`.
614
+ *
615
+ * @param temperature - Requested temperature in Celsius
616
+ * @param limits - Device limits; omit to clamp to 16–31 °C without snapping
617
+ */
618
+ function clampTemperature(temperature, limits) {
619
+ if (!limits) return Math.min(31, Math.max(16, temperature));
620
+ const { min, max, step } = limits;
621
+ const snapped = step > 0 ? Math.round((temperature - min) / step) * step + min : temperature;
622
+ return Number(Math.min(max, Math.max(min, snapped)).toFixed(4));
117
623
  }
118
- function acReducer(state, action) {
624
+ /**
625
+ * Apply an action to the state.
626
+ *
627
+ * @param state - Current state
628
+ * @param action - Action to apply
629
+ * @param limits - Device limits; increments use `limits.step`
630
+ */
631
+ function acReducer(state, action, limits) {
632
+ const step = limits?.step ?? defaultAcLimits.step;
119
633
  switch (action.type) {
120
634
  case "increment": return {
121
635
  ...state,
122
- temperature: clampTemperature(state.temperature + 1)
636
+ temperature: clampTemperature(state.temperature + step, limits)
123
637
  };
124
638
  case "decrement": return {
125
639
  ...state,
126
- temperature: clampTemperature(state.temperature - 1)
640
+ temperature: clampTemperature(state.temperature - step, limits)
127
641
  };
128
642
  case "toggleStatus": return {
129
643
  ...state,
@@ -137,25 +651,53 @@ function acReducer(state, action) {
137
651
  ...state,
138
652
  mode: action.mode
139
653
  };
654
+ case "sound": return {
655
+ ...state,
656
+ sound: isAcSoundMode(action.sound) ? action.sound : state.sound
657
+ };
658
+ case "cycleSound": return {
659
+ ...state,
660
+ sound: nextAcSoundMode(state.sound)
661
+ };
140
662
  case "update": return normalizeAcState({
141
663
  ...state,
142
664
  ...action.payload
143
- });
665
+ }, limits);
144
666
  default: throw new Error("Unexpected Ac Action");
145
667
  }
146
668
  }
147
669
  function getRecommendedTemperatureMessage(mode, temperature) {
148
670
  return `建议将空调的制${mode === "cold" ? "冷" : "热"}温度调至 ${temperature} 度以${mode === "cold" ? "上" : "下"},为节能减排贡献一份力量!`;
149
671
  }
150
- function getAcActionNotification(previousState, nextState, action) {
151
- if (action.type === "increment" && previousState.temperature >= 31) return {
672
+ function getSoundModeMessage(sound) {
673
+ return `${acSoundModeLabels[sound]}模式:${{
674
+ normal: "风声和按键音恢复正常",
675
+ sleep: "风声和按键音都调小了,晚安",
676
+ mute: "空调不再发出任何声音"
677
+ }[sound]}`;
678
+ }
679
+ /**
680
+ * Visual tone for a state: `idle` when off, otherwise `cool` or `heat`.
681
+ *
682
+ * @param state - Current state
683
+ */
684
+ function acTone(state) {
685
+ if (!state.status) return "idle";
686
+ return state.mode === "hot" ? "heat" : "cool";
687
+ }
688
+ function getAcActionNotification(previousState, nextState, action, limits = defaultAcLimits) {
689
+ if (action.type === "increment" && previousState.temperature >= limits.max) return {
152
690
  message: "已经是最大温度啦!",
153
691
  severity: "error"
154
692
  };
155
- if (action.type === "decrement" && previousState.temperature <= 16) return {
693
+ if (action.type === "decrement" && previousState.temperature <= limits.min) return {
156
694
  message: "已经是最小温度啦!",
157
695
  severity: "error"
158
696
  };
697
+ if ((action.type === "sound" || action.type === "cycleSound") && nextState.sound !== previousState.sound) return {
698
+ message: getSoundModeMessage(nextState.sound),
699
+ severity: "info"
700
+ };
159
701
  if (action.type === "mode" && nextState.mode === "cold" && previousState.temperature < 26) return {
160
702
  message: getRecommendedTemperatureMessage("cold", 26),
161
703
  severity: "success"
@@ -211,11 +753,12 @@ function createAirConditionerStore(options = {}) {
211
753
  const storageKey = options.storageKey ?? "ac:state";
212
754
  const storage = options.storage === void 0 ? getBrowserStorage() : options.storage;
213
755
  const persistedState = readAcState(storage, storageKey);
756
+ const limits = options.limits;
214
757
  let state = normalizeAcState({
215
758
  ...defaultAcState,
216
759
  ...persistedState,
217
760
  ...options.initialState
218
- });
761
+ }, limits);
219
762
  const listeners = /* @__PURE__ */ new Set();
220
763
  return {
221
764
  getState() {
@@ -223,8 +766,8 @@ function createAirConditionerStore(options = {}) {
223
766
  },
224
767
  dispatch(action) {
225
768
  const previousState = state;
226
- const nextState = acReducer(state, action);
227
- const notification = getAcActionNotification(previousState, nextState, action);
769
+ const nextState = acReducer(state, action, limits);
770
+ const notification = getAcActionNotification(previousState, nextState, action, limits);
228
771
  const event = {
229
772
  action,
230
773
  previousState,
@@ -246,24 +789,280 @@ function createAirConditionerStore(options = {}) {
246
789
  };
247
790
  }
248
791
  //#endregion
792
+ //#region src/thermal/levels.ts
793
+ const heatLevels = [
794
+ "nominal",
795
+ "fair",
796
+ "serious",
797
+ "critical"
798
+ ];
799
+ /** Default Celsius thresholds for fair, serious and critical. */
800
+ const defaultHeatThresholds = [
801
+ 60,
802
+ 75,
803
+ 90
804
+ ];
805
+ /**
806
+ * @param level - A heat level
807
+ * @returns Its rank from 0 (nominal) to 3 (critical)
808
+ */
809
+ function heatLevelRank(level) {
810
+ return heatLevels.indexOf(level);
811
+ }
812
+ function isHeatLevel(value) {
813
+ return heatLevels.includes(value);
814
+ }
815
+ /**
816
+ * Convert a temperature to a heat level. With a previous level, the value
817
+ * must fall `hysteresis` degrees below a threshold before dropping a level,
818
+ * which keeps readings near a threshold from flickering.
819
+ *
820
+ * @param celsius - Measured temperature
821
+ * @param thresholds - Lower bounds for fair, serious and critical
822
+ * @param previous - Level from the previous reading
823
+ * @param hysteresis - Degrees required below a threshold to step down
824
+ */
825
+ function celsiusToHeatLevel(celsius, thresholds = defaultHeatThresholds, previous, hysteresis = 5) {
826
+ const raw = thresholds.filter((threshold) => celsius >= threshold).length;
827
+ if (previous === void 0) return heatLevels[raw];
828
+ const held = thresholds.filter((threshold) => celsius >= threshold - hysteresis).length;
829
+ return heatLevels[Math.max(raw, Math.min(heatLevelRank(previous), held))];
830
+ }
831
+ /**
832
+ * Create a filter that raises the level immediately but only lowers it after
833
+ * lower readings persist for `dwellMs`, measured with `reading.at`.
834
+ *
835
+ * @param options - Governor options
836
+ * @param options.dwellMs - Time a lower level must persist; defaults to 30 seconds
837
+ * @returns A function that maps each reading to the settled level
838
+ */
839
+ function createHeatGovernor(options = {}) {
840
+ const dwellMs = options.dwellMs ?? 3e4;
841
+ let current;
842
+ let lowerSince;
843
+ return (reading) => {
844
+ if (current === void 0 || heatLevelRank(reading.level) >= heatLevelRank(current)) {
845
+ current = reading.level;
846
+ lowerSince = void 0;
847
+ return current;
848
+ }
849
+ lowerSince ??= reading.at;
850
+ if (reading.at - lowerSince >= dwellMs) {
851
+ current = reading.level;
852
+ lowerSince = void 0;
853
+ }
854
+ return current;
855
+ };
856
+ }
857
+ //#endregion
858
+ //#region src/thermal/plan.ts
859
+ const coolingByLevel = {
860
+ fair: {
861
+ temperature: 26,
862
+ airflow: .4
863
+ },
864
+ serious: {
865
+ temperature: 24,
866
+ airflow: .7
867
+ },
868
+ critical: {
869
+ temperature: 22,
870
+ airflow: 1
871
+ }
872
+ };
873
+ /**
874
+ * Decide how the virtual air conditioner should respond to a heat level.
875
+ *
876
+ * @param level - Settled heat level
877
+ * @param limits - Device limits used to clamp the target temperature
878
+ * @returns A cooling plan, or `null` at `nominal` so the user keeps control
879
+ */
880
+ function planCooling(level, limits) {
881
+ if (level === "nominal") return null;
882
+ const { temperature, airflow } = coolingByLevel[level];
883
+ return {
884
+ status: true,
885
+ mode: "cold",
886
+ temperature: clampTemperature(temperature, limits),
887
+ airflow
888
+ };
889
+ }
890
+ //#endregion
891
+ //#region src/thermal/pressure.ts
892
+ function getPressureObserver() {
893
+ return globalThis.PressureObserver;
894
+ }
895
+ /**
896
+ * Heat source backed by the browser Compute Pressure API. It reports CPU
897
+ * load levels, not temperatures; browsers expose no CPU temperature.
898
+ *
899
+ * Supported in Chromium-based browsers on secure origins. The API is only
900
+ * touched when the source is started.
901
+ *
902
+ * @param options - Sampling and error handling options
903
+ */
904
+ function createComputePressureSource(options = {}) {
905
+ return {
906
+ id: "compute-pressure",
907
+ isSupported: () => typeof getPressureObserver() === "function",
908
+ start(listener) {
909
+ const Observer = getPressureObserver();
910
+ if (!Observer) return () => {};
911
+ let stopped = false;
912
+ const observer = new Observer((records) => {
913
+ const record = records.at(-1);
914
+ if (stopped || !record || !isHeatLevel(record.state)) return;
915
+ listener({
916
+ level: record.state,
917
+ source: "compute-pressure",
918
+ at: Date.now()
919
+ });
920
+ });
921
+ observer.observe("cpu", { sampleInterval: options.sampleIntervalMs ?? 2e3 }).catch((error) => {
922
+ if (!stopped) options.onError?.(error);
923
+ });
924
+ return () => {
925
+ stopped = true;
926
+ observer.disconnect();
927
+ };
928
+ }
929
+ };
930
+ }
931
+ //#endregion
932
+ //#region src/ui.ts
933
+ /**
934
+ * 各框架视图层共用的界面数据:遥控器图标与能效标签的点阵布局。
935
+ * 保证 Vue / React / Web Component 渲染出完全一致的空调外观。
936
+ */
937
+ /**
938
+ * 遥控器按钮图标(24 × 24 viewBox 的 SVG path)。
939
+ */
940
+ const acIconPaths = {
941
+ cold: "m22 11h-4.17l3.24-3.24l-1.41-1.42L15 11h-2V9l4.66-4.66l-1.42-1.41L13 6.17V2h-2v4.17L7.76 2.93L6.34 4.34L11 9v2H9L4.34 6.34L2.93 7.76L6.17 11H2v2h4.17l-3.24 3.24l1.41 1.42L9 13h2v2l-4.66 4.66l1.42 1.41L11 17.83V22h2v-4.17l3.24 3.24l1.42-1.41L13 15v-2h2l4.66 4.66l1.41-1.42L17.83 13H22z",
942
+ power: "M13 3h-2v10h2zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12a7 7 0 0 1-14 0a6.92 6.92 0 0 1 2.58-5.42L6.17 5.17A9 9 0 1 0 21 12a8.94 8.94 0 0 0-3.17-6.83",
943
+ hot: "M12 7a5 5 0 1 0 5 5a5 5 0 0 0-5-5m0-5a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0V3a1 1 0 0 0-1-1m0 17a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0v-2a1 1 0 0 0-1-1M5.64 6.05L4.22 4.64a1 1 0 0 0-1.41 1.41l1.41 1.42a1 1 0 0 0 1.42 0a1 1 0 0 0 0-1.42M21 11h-2a1 1 0 0 0 0 2h2a1 1 0 0 0 0-2M5 12a1 1 0 0 0-1-1H2a1 1 0 0 0 0 2h2a1 1 0 0 0 1-1m13.36-5.95a1 1 0 0 0 .7-.29l1.42-1.42a1 1 0 1 0-1.41-1.41l-1.42 1.41a1 1 0 0 0 0 1.42a1 1 0 0 0 .71.29m0 11.9a1 1 0 0 0-1.42 1.41l1.42 1.42a1 1 0 0 0 1.41 0a1 1 0 0 0 0-1.41ZM5.64 17.95l-1.42 1.42a1 1 0 0 0 0 1.41a1 1 0 0 0 1.41 0l1.42-1.42a1 1 0 0 0-1.41-1.41",
944
+ increase: "m12 8l6 8H6z",
945
+ decrease: "m12 16l-6-8h12z",
946
+ soundNormal: "M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1m13.5 2A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02M14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85",
947
+ soundSleep: "M11.65 3.46c.27-.71-.36-1.45-1.12-1.34c-5.52.8-9.47 6.07-8.34 11.88c.78 4.02 4.09 7.21 8.14 7.87c3.74.61 7.16-.87 9.32-3.44c.48-.57.19-1.48-.55-1.62c-6.02-1.15-9.68-7.54-7.45-13.35",
948
+ soundMute: "M3.63 3.63a.996.996 0 0 0 0 1.41L7.29 8.7L7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91c-.36.15-.58.53-.58.92c0 .72.73 1.18 1.39.91c.8-.33 1.55-.77 2.22-1.31l1.34 1.34a.996.996 0 1 0 1.41-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0M19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87c0-3.83-2.4-7.11-5.78-8.4c-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12m-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7M16.5 12A4.5 4.5 0 0 0 14 7.97v1.79l2.48 2.48c.01-.08.02-.16.02-.24"
949
+ };
950
+ /**
951
+ * 声音键在各声音模式下显示的图标。
952
+ */
953
+ const acSoundIcons = {
954
+ normal: "soundNormal",
955
+ sleep: "soundSleep",
956
+ mute: "soundMute"
957
+ };
958
+ /**
959
+ * 能效标签的点阵与等级条布局。
960
+ */
961
+ const acEnergyLabel = {
962
+ top: [{
963
+ num: 6,
964
+ size: 4,
965
+ mx: 2,
966
+ my: 0,
967
+ color: "white"
968
+ }],
969
+ detail: [
970
+ {
971
+ num: 11,
972
+ size: 2,
973
+ mx: .8,
974
+ my: 2,
975
+ color: "black"
976
+ },
977
+ {
978
+ num: 9,
979
+ size: 1.5,
980
+ mx: .8,
981
+ my: 2,
982
+ color: "black"
983
+ },
984
+ {
985
+ num: 10,
986
+ size: 1.2,
987
+ mx: .8,
988
+ my: 0,
989
+ color: "black"
990
+ }
991
+ ],
992
+ footer: {
993
+ num: 8,
994
+ size: 2,
995
+ mx: .8,
996
+ my: 0,
997
+ color: "white"
998
+ },
999
+ levels: [
1000
+ {
1001
+ background: "lightgreen",
1002
+ width: "50%"
1003
+ },
1004
+ {
1005
+ background: "#ffc107",
1006
+ width: "60%"
1007
+ },
1008
+ {
1009
+ background: "orange",
1010
+ width: "70%"
1011
+ },
1012
+ {
1013
+ background: "red",
1014
+ width: "80%"
1015
+ }
1016
+ ]
1017
+ };
1018
+ //#endregion
249
1019
  exports.acAudioFileNames = acAudioFileNames;
1020
+ exports.acEnergyLabel = acEnergyLabel;
1021
+ exports.acIconPaths = acIconPaths;
1022
+ exports.acMaxTilt = acMaxTilt;
250
1023
  exports.acModeIcons = acModeIcons;
1024
+ exports.acMotion = acMotion;
1025
+ exports.acReducedMotionDuration = acReducedMotionDuration;
1026
+ exports.acReducedMotionQuery = acReducedMotionQuery;
251
1027
  exports.acReducer = acReducer;
1028
+ exports.acShellSize = acShellSize;
1029
+ exports.acSoundIcons = acSoundIcons;
1030
+ exports.acSoundModeLabels = acSoundModeLabels;
1031
+ exports.acSoundModes = acSoundModes;
1032
+ exports.acSoundVolumes = acSoundVolumes;
252
1033
  exports.acStorageKey = acStorageKey;
1034
+ exports.acTone = acTone;
1035
+ exports.celsiusToHeatLevel = celsiusToHeatLevel;
253
1036
  exports.clampTemperature = clampTemperature;
254
1037
  exports.createAcAudioController = createAcAudioController;
255
1038
  exports.createAirConditionerStore = createAirConditionerStore;
1039
+ exports.createComputePressureSource = createComputePressureSource;
1040
+ exports.createHeatGovernor = createHeatGovernor;
256
1041
  exports.createMemoryStorage = createMemoryStorage;
1042
+ exports.defaultAcLimits = defaultAcLimits;
257
1043
  exports.defaultAcState = defaultAcState;
1044
+ exports.defaultHeatThresholds = defaultHeatThresholds;
258
1045
  exports.getAcActionNotification = getAcActionNotification;
1046
+ exports.getAcScale = getAcScale;
259
1047
  exports.getBrowserStorage = getBrowserStorage;
260
1048
  exports.getRecommendedTemperatureMessage = getRecommendedTemperatureMessage;
1049
+ exports.getSoundModeMessage = getSoundModeMessage;
1050
+ exports.heatLevelRank = heatLevelRank;
1051
+ exports.heatLevels = heatLevels;
1052
+ exports.isAcSoundMode = isAcSoundMode;
1053
+ exports.isHeatLevel = isHeatLevel;
261
1054
  exports.maxTemperature = maxTemperature;
262
1055
  exports.minTemperature = minTemperature;
1056
+ exports.nextAcSoundMode = nextAcSoundMode;
263
1057
  exports.normalizeAcState = normalizeAcState;
1058
+ exports.observeAcScale = observeAcScale;
1059
+ exports.observeAcTilt = observeAcTilt;
1060
+ exports.planCooling = planCooling;
1061
+ exports.prefersReducedMotion = prefersReducedMotion;
264
1062
  exports.readAcState = readAcState;
265
1063
  exports.recommendedColdTemperature = recommendedColdTemperature;
266
1064
  exports.recommendedHotTemperature = recommendedHotTemperature;
1065
+ exports.renderAcMotionCssVars = renderAcMotionCssVars;
267
1066
  exports.resolveAcAudioAssets = resolveAcAudioAssets;
268
1067
  exports.soundTimings = soundTimings;
269
1068
  exports.writeAcState = writeAcState;