@jjlmoya/utils-home 1.16.0 → 1.23.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.
Files changed (174) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/de.ts +10 -10
  3. package/src/category/i18n/en.ts +8 -8
  4. package/src/category/i18n/es.ts +2 -2
  5. package/src/category/i18n/fr.ts +15 -15
  6. package/src/category/i18n/id.ts +8 -8
  7. package/src/category/i18n/it.ts +7 -7
  8. package/src/category/i18n/nl.ts +8 -8
  9. package/src/category/i18n/pl.ts +10 -10
  10. package/src/category/i18n/pt.ts +8 -8
  11. package/src/category/i18n/ru.ts +10 -10
  12. package/src/category/i18n/sv.ts +8 -8
  13. package/src/category/i18n/tr.ts +4 -4
  14. package/src/category/i18n/zh.ts +8 -8
  15. package/src/entries.ts +4 -1
  16. package/src/pages/[locale]/[slug].astro +28 -12
  17. package/src/tests/locale_completeness.test.ts +4 -22
  18. package/src/tests/no_en_dash.test.ts +70 -0
  19. package/src/tests/shared-test-helpers.ts +56 -0
  20. package/src/tests/tool_exports.test.ts +34 -0
  21. package/src/tests/tool_validation.test.ts +2 -2
  22. package/src/tool/dewPointCalculator/bibliography.ts +10 -0
  23. package/src/tool/dewPointCalculator/i18n/de.ts +7 -17
  24. package/src/tool/dewPointCalculator/i18n/en.ts +8 -18
  25. package/src/tool/dewPointCalculator/i18n/es.ts +7 -17
  26. package/src/tool/dewPointCalculator/i18n/fr.ts +8 -18
  27. package/src/tool/dewPointCalculator/i18n/id.ts +7 -17
  28. package/src/tool/dewPointCalculator/i18n/it.ts +7 -17
  29. package/src/tool/dewPointCalculator/i18n/ja.ts +6 -16
  30. package/src/tool/dewPointCalculator/i18n/ko.ts +6 -16
  31. package/src/tool/dewPointCalculator/i18n/nl.ts +7 -17
  32. package/src/tool/dewPointCalculator/i18n/pl.ts +7 -17
  33. package/src/tool/dewPointCalculator/i18n/pt.ts +7 -17
  34. package/src/tool/dewPointCalculator/i18n/ru.ts +13 -23
  35. package/src/tool/dewPointCalculator/i18n/sv.ts +7 -17
  36. package/src/tool/dewPointCalculator/i18n/tr.ts +6 -16
  37. package/src/tool/dewPointCalculator/i18n/zh.ts +7 -17
  38. package/src/tool/dewPointCalculator/seo.astro +2 -1
  39. package/src/tool/heatingComparator/bibliography.ts +14 -0
  40. package/src/tool/heatingComparator/i18n/de.ts +10 -24
  41. package/src/tool/heatingComparator/i18n/en.ts +3 -13
  42. package/src/tool/heatingComparator/i18n/es.ts +3 -17
  43. package/src/tool/heatingComparator/i18n/fr.ts +9 -19
  44. package/src/tool/heatingComparator/i18n/id.ts +3 -17
  45. package/src/tool/heatingComparator/i18n/it.ts +3 -17
  46. package/src/tool/heatingComparator/i18n/ja.ts +296 -310
  47. package/src/tool/heatingComparator/i18n/ko.ts +296 -306
  48. package/src/tool/heatingComparator/i18n/nl.ts +3 -17
  49. package/src/tool/heatingComparator/i18n/pl.ts +3 -17
  50. package/src/tool/heatingComparator/i18n/pt.ts +3 -17
  51. package/src/tool/heatingComparator/i18n/ru.ts +14 -24
  52. package/src/tool/heatingComparator/i18n/sv.ts +6 -20
  53. package/src/tool/heatingComparator/i18n/tr.ts +2 -16
  54. package/src/tool/heatingComparator/i18n/zh.ts +296 -306
  55. package/src/tool/heatingComparator/seo.astro +3 -3
  56. package/src/tool/ledSavingCalculator/bibliography.ts +14 -0
  57. package/src/tool/ledSavingCalculator/i18n/de.ts +6 -16
  58. package/src/tool/ledSavingCalculator/i18n/en.ts +6 -20
  59. package/src/tool/ledSavingCalculator/i18n/es.ts +6 -20
  60. package/src/tool/ledSavingCalculator/i18n/fr.ts +10 -24
  61. package/src/tool/ledSavingCalculator/i18n/id.ts +5 -15
  62. package/src/tool/ledSavingCalculator/i18n/it.ts +6 -16
  63. package/src/tool/ledSavingCalculator/i18n/ja.ts +5 -15
  64. package/src/tool/ledSavingCalculator/i18n/ko.ts +4 -14
  65. package/src/tool/ledSavingCalculator/i18n/nl.ts +5 -15
  66. package/src/tool/ledSavingCalculator/i18n/pl.ts +5 -15
  67. package/src/tool/ledSavingCalculator/i18n/pt.ts +5 -15
  68. package/src/tool/ledSavingCalculator/i18n/ru.ts +8 -18
  69. package/src/tool/ledSavingCalculator/i18n/sv.ts +5 -15
  70. package/src/tool/ledSavingCalculator/i18n/tr.ts +5 -15
  71. package/src/tool/ledSavingCalculator/i18n/zh.ts +6 -16
  72. package/src/tool/ledSavingCalculator/seo.astro +2 -1
  73. package/src/tool/projectorCalculator/bibliography.ts +5 -0
  74. package/src/tool/projectorCalculator/i18n/de.ts +4 -8
  75. package/src/tool/projectorCalculator/i18n/en.ts +3 -8
  76. package/src/tool/projectorCalculator/i18n/es.ts +4 -9
  77. package/src/tool/projectorCalculator/i18n/fr.ts +6 -11
  78. package/src/tool/projectorCalculator/i18n/id.ts +4 -9
  79. package/src/tool/projectorCalculator/i18n/it.ts +4 -8
  80. package/src/tool/projectorCalculator/i18n/ja.ts +175 -179
  81. package/src/tool/projectorCalculator/i18n/ko.ts +175 -179
  82. package/src/tool/projectorCalculator/i18n/nl.ts +4 -8
  83. package/src/tool/projectorCalculator/i18n/pl.ts +5 -9
  84. package/src/tool/projectorCalculator/i18n/pt.ts +4 -8
  85. package/src/tool/projectorCalculator/i18n/ru.ts +7 -11
  86. package/src/tool/projectorCalculator/i18n/sv.ts +4 -8
  87. package/src/tool/projectorCalculator/i18n/tr.ts +4 -8
  88. package/src/tool/projectorCalculator/i18n/zh.ts +175 -179
  89. package/src/tool/projectorCalculator/seo.astro +2 -1
  90. package/src/tool/qrGenerator/bibliography.ts +14 -0
  91. package/src/tool/qrGenerator/i18n/de.ts +192 -202
  92. package/src/tool/qrGenerator/i18n/en.ts +3 -17
  93. package/src/tool/qrGenerator/i18n/es.ts +2 -16
  94. package/src/tool/qrGenerator/i18n/fr.ts +3 -17
  95. package/src/tool/qrGenerator/i18n/id.ts +146 -150
  96. package/src/tool/qrGenerator/i18n/it.ts +169 -173
  97. package/src/tool/qrGenerator/i18n/ja.ts +146 -150
  98. package/src/tool/qrGenerator/i18n/ko.ts +146 -150
  99. package/src/tool/qrGenerator/i18n/nl.ts +146 -150
  100. package/src/tool/qrGenerator/i18n/pl.ts +146 -150
  101. package/src/tool/qrGenerator/i18n/pt.ts +146 -150
  102. package/src/tool/qrGenerator/i18n/ru.ts +146 -150
  103. package/src/tool/qrGenerator/i18n/sv.ts +146 -150
  104. package/src/tool/qrGenerator/i18n/tr.ts +146 -150
  105. package/src/tool/qrGenerator/i18n/zh.ts +146 -150
  106. package/src/tool/qrGenerator/seo.astro +2 -1
  107. package/src/tool/solarCalculator/bibliography.ts +5 -0
  108. package/src/tool/solarCalculator/i18n/de.ts +141 -145
  109. package/src/tool/solarCalculator/i18n/en.ts +7 -12
  110. package/src/tool/solarCalculator/i18n/es.ts +5 -10
  111. package/src/tool/solarCalculator/i18n/fr.ts +8 -13
  112. package/src/tool/solarCalculator/i18n/id.ts +4 -8
  113. package/src/tool/solarCalculator/i18n/it.ts +4 -8
  114. package/src/tool/solarCalculator/i18n/ja.ts +121 -125
  115. package/src/tool/solarCalculator/i18n/ko.ts +116 -120
  116. package/src/tool/solarCalculator/i18n/nl.ts +4 -7
  117. package/src/tool/solarCalculator/i18n/pl.ts +5 -9
  118. package/src/tool/solarCalculator/i18n/pt.ts +4 -8
  119. package/src/tool/solarCalculator/i18n/ru.ts +7 -10
  120. package/src/tool/solarCalculator/i18n/sv.ts +4 -7
  121. package/src/tool/solarCalculator/i18n/tr.ts +4 -7
  122. package/src/tool/solarCalculator/i18n/zh.ts +116 -120
  123. package/src/tool/solarCalculator/seo.astro +2 -1
  124. package/src/tool/tariffComparator/bibliography.ts +7 -0
  125. package/src/tool/tariffComparator/i18n/de.ts +129 -132
  126. package/src/tool/tariffComparator/i18n/en.ts +5 -12
  127. package/src/tool/tariffComparator/i18n/es.ts +5 -12
  128. package/src/tool/tariffComparator/i18n/fr.ts +8 -15
  129. package/src/tool/tariffComparator/i18n/id.ts +2 -5
  130. package/src/tool/tariffComparator/i18n/it.ts +2 -5
  131. package/src/tool/tariffComparator/i18n/ja.ts +129 -132
  132. package/src/tool/tariffComparator/i18n/ko.ts +129 -132
  133. package/src/tool/tariffComparator/i18n/nl.ts +2 -5
  134. package/src/tool/tariffComparator/i18n/pl.ts +3 -6
  135. package/src/tool/tariffComparator/i18n/pt.ts +2 -5
  136. package/src/tool/tariffComparator/i18n/ru.ts +2 -5
  137. package/src/tool/tariffComparator/i18n/sv.ts +2 -5
  138. package/src/tool/tariffComparator/i18n/tr.ts +2 -5
  139. package/src/tool/tariffComparator/i18n/zh.ts +129 -132
  140. package/src/tool/tariffComparator/seo.astro +2 -1
  141. package/src/tool/wifiRangeSimulator/bibliography.astro +14 -0
  142. package/src/tool/wifiRangeSimulator/bibliography.ts +14 -0
  143. package/src/tool/wifiRangeSimulator/component.astro +170 -0
  144. package/src/tool/wifiRangeSimulator/entry.ts +29 -0
  145. package/src/tool/wifiRangeSimulator/i18n/de.ts +477 -0
  146. package/src/tool/wifiRangeSimulator/i18n/en.ts +477 -0
  147. package/src/tool/wifiRangeSimulator/i18n/es.ts +477 -0
  148. package/src/tool/wifiRangeSimulator/i18n/fr.ts +477 -0
  149. package/src/tool/wifiRangeSimulator/i18n/id.ts +477 -0
  150. package/src/tool/wifiRangeSimulator/i18n/it.ts +477 -0
  151. package/src/tool/wifiRangeSimulator/i18n/ja.ts +477 -0
  152. package/src/tool/wifiRangeSimulator/i18n/ko.ts +477 -0
  153. package/src/tool/wifiRangeSimulator/i18n/nl.ts +477 -0
  154. package/src/tool/wifiRangeSimulator/i18n/pl.ts +477 -0
  155. package/src/tool/wifiRangeSimulator/i18n/pt.ts +477 -0
  156. package/src/tool/wifiRangeSimulator/i18n/ru.ts +477 -0
  157. package/src/tool/wifiRangeSimulator/i18n/sv.ts +477 -0
  158. package/src/tool/wifiRangeSimulator/i18n/tr.ts +477 -0
  159. package/src/tool/wifiRangeSimulator/i18n/zh.ts +477 -0
  160. package/src/tool/wifiRangeSimulator/i18n-utils.ts +14 -0
  161. package/src/tool/wifiRangeSimulator/index.ts +8 -0
  162. package/src/tool/wifiRangeSimulator/logic.ts +220 -0
  163. package/src/tool/wifiRangeSimulator/seo.astro +15 -0
  164. package/src/tool/wifiRangeSimulator/sketch-actions.ts +168 -0
  165. package/src/tool/wifiRangeSimulator/sketch-events.ts +138 -0
  166. package/src/tool/wifiRangeSimulator/sketch-render-dash.ts +170 -0
  167. package/src/tool/wifiRangeSimulator/sketch-render-device.ts +42 -0
  168. package/src/tool/wifiRangeSimulator/sketch-render.ts +155 -0
  169. package/src/tool/wifiRangeSimulator/sketch-state.ts +186 -0
  170. package/src/tool/wifiRangeSimulator/sketch.ts +100 -0
  171. package/src/tool/wifiRangeSimulator/ui.ts +69 -0
  172. package/src/tool/wifiRangeSimulator/wifi-range-simulator.css +583 -0
  173. package/src/tools.ts +2 -0
  174. package/src/types.ts +0 -2
@@ -0,0 +1,220 @@
1
+ export interface Point {
2
+ x: number;
3
+ y: number;
4
+ }
5
+
6
+ export interface Segment {
7
+ a: Point;
8
+ b: Point;
9
+ material: string;
10
+ attenuation: number;
11
+ }
12
+
13
+ export interface PlacedObject {
14
+ id: string;
15
+ x: number;
16
+ y: number;
17
+ material: string;
18
+ attenuation: number;
19
+ width: number;
20
+ height: number;
21
+ }
22
+
23
+ export interface PlacedDevice {
24
+ id: string;
25
+ x: number;
26
+ y: number;
27
+ name: string;
28
+ }
29
+
30
+ export interface SignalResult {
31
+ strengthPercent: number;
32
+ effectiveRange: number;
33
+ verdict: 'perfect' | 'good' | 'fair' | 'poor' | 'dead';
34
+ streamingVerdict: Record<string, string>;
35
+ rayCount: number;
36
+ }
37
+
38
+ export const MATERIALS: Record<string, { attenuation: number; color: string }> = {
39
+ drywall: { attenuation: 3, color: '#e7e5e4' },
40
+ brick: { attenuation: 8, color: '#a16207' },
41
+ concrete: { attenuation: 15, color: '#57534e' },
42
+ stoneWall: { attenuation: 22, color: '#44403c' },
43
+ woodDoor: { attenuation: 4, color: '#92400e' },
44
+ metalDoor: { attenuation: 18, color: '#334155' },
45
+ window: { attenuation: 2, color: '#bfdbfe' },
46
+ fridge: { attenuation: 10, color: '#94a3b8' },
47
+ aquarium: { attenuation: 12, color: '#0ea5e9' },
48
+ microwave: { attenuation: 5, color: '#475569' },
49
+ mirror: { attenuation: 6, color: '#c0c0c0' },
50
+ furniture: { attenuation: 5, color: '#78350f' },
51
+ };
52
+
53
+ function dist(a: Point, b: Point): number {
54
+ const dx = b.x - a.x;
55
+ const dy = b.y - a.y;
56
+ return Math.sqrt(dx * dx + dy * dy);
57
+ }
58
+
59
+ function sub(a: Point, b: Point): Point {
60
+ return { x: a.x - b.x, y: a.y - b.y };
61
+ }
62
+
63
+ function normalize(v: Point): Point {
64
+ const len = Math.sqrt(v.x * v.x + v.y * v.y);
65
+ if (len < 1e-9) return { x: 0, y: 0 };
66
+ return { x: v.x / len, y: v.y / len };
67
+ }
68
+
69
+ function intersectRayWithSegment(origin: Point, dir: Point, seg: Segment): { point: Point; d: number } | null {
70
+ const segDir = sub(seg.b, seg.a);
71
+ const rxs = dir.x * segDir.y - dir.y * segDir.x;
72
+ if (Math.abs(rxs) < 1e-9) return null;
73
+ const qp = sub(seg.a, origin);
74
+ const t = (qp.x * segDir.y - qp.y * segDir.x) / rxs;
75
+ const u = (qp.x * dir.y - qp.y * dir.x) / rxs;
76
+ if (t > 0 && u >= 0 && u <= 1) {
77
+ return { point: { x: origin.x + t * dir.x, y: origin.y + t * dir.y }, d: t };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function getObjectSegments(obj: PlacedObject): Segment[] {
83
+ const hw = obj.width / 2;
84
+ const hh = obj.height / 2;
85
+ const tl = { x: obj.x - hw, y: obj.y - hh };
86
+ const tr = { x: obj.x + hw, y: obj.y - hh };
87
+ const br = { x: obj.x + hw, y: obj.y + hh };
88
+ const bl = { x: obj.x - hw, y: obj.y + hh };
89
+ return [
90
+ { a: tl, b: tr, material: obj.material, attenuation: obj.attenuation },
91
+ { a: tr, b: br, material: obj.material, attenuation: obj.attenuation },
92
+ { a: br, b: bl, material: obj.material, attenuation: obj.attenuation },
93
+ { a: bl, b: tl, material: obj.material, attenuation: obj.attenuation },
94
+ ];
95
+ }
96
+
97
+ function findAllHits(origin: Point, dir: Point, segments: Segment[], maxDist: number) {
98
+ const hits: { point: Point; seg: Segment; d: number }[] = [];
99
+ for (const seg of segments) {
100
+ const hit = intersectRayWithSegment(origin, dir, seg);
101
+ if (hit && hit.d > 1 && hit.d < maxDist) {
102
+ hits.push({ point: hit.point, seg, d: hit.d });
103
+ }
104
+ }
105
+ hits.sort((a, b) => a.d - b.d);
106
+ return hits;
107
+ }
108
+
109
+ function castDirectRay(router: Point, device: Point, segments: Segment[]): number {
110
+ const directDist = dist(router, device);
111
+ const dir = normalize(sub(device, router));
112
+ const hits = findAllHits(router, dir, segments, directDist);
113
+ return hits.reduce((sum, h) => sum + h.seg.attenuation, 0);
114
+ }
115
+
116
+ export function buildSegments(walls: Segment[], objects: PlacedObject[]): Segment[] {
117
+ const segs: Segment[] = [...walls];
118
+ for (const obj of objects) {
119
+ segs.push(...getObjectSegments(obj));
120
+ }
121
+ return segs;
122
+ }
123
+
124
+ function computeVerdict(percent: number): SignalResult['verdict'] {
125
+ if (percent >= 80) return 'perfect';
126
+ if (percent >= 60) return 'good';
127
+ if (percent >= 40) return 'fair';
128
+ if (percent >= 20) return 'poor';
129
+ return 'dead';
130
+ }
131
+
132
+ function streamStatus(pct: number, thresholds: number[], labels: string[]): string {
133
+ for (let i = 0; i < thresholds.length; i++) {
134
+ if (pct >= thresholds[i]) return labels[i];
135
+ }
136
+ return labels[labels.length - 1];
137
+ }
138
+
139
+ function streaming4K(pct: number, labels: string[]): string {
140
+ return streamStatus(pct, [70, 40], labels);
141
+ }
142
+
143
+ function streamingGaming(pct: number, labels: string[]): string {
144
+ return streamStatus(pct, [60, 30], labels);
145
+ }
146
+
147
+ function streamingCalls(pct: number, labels: string[]): string {
148
+ return streamStatus(pct, [50, 25], labels);
149
+ }
150
+
151
+ function streamingBrowsing(pct: number, labels: string[]): string {
152
+ return streamStatus(pct, [20], labels);
153
+ }
154
+
155
+ const DEFAULT_STATUS_LABELS = {
156
+ statusPerfect: 'Perfect',
157
+ statusGood: 'Good',
158
+ statusFair: 'Fair',
159
+ statusPoor: 'Poor',
160
+ statusImpossible: 'Impossible',
161
+ statusLowLatency: 'Low Latency',
162
+ statusLagWarning: 'Lag Warning',
163
+ statusDisconnect: 'Disconnect',
164
+ statusStable: 'Stable',
165
+ statusPixelated: 'Pixelated',
166
+ statusDropped: 'Dropped',
167
+ statusPass: 'Pass',
168
+ statusUnusable: 'Unusable',
169
+ };
170
+
171
+ export function computeStreamingVerdict(pct: number, labels?: typeof DEFAULT_STATUS_LABELS): Record<string, string> {
172
+ const l = labels || DEFAULT_STATUS_LABELS;
173
+ return {
174
+ '4kStreaming': streaming4K(pct, [l.statusPerfect, l.statusGood, l.statusImpossible]),
175
+ onlineGaming: streamingGaming(pct, [l.statusLowLatency, l.statusLagWarning, l.statusDisconnect]),
176
+ videoCalls: streamingCalls(pct, [l.statusStable, l.statusPixelated, l.statusDropped]),
177
+ basicBrowsing: streamingBrowsing(pct, [l.statusPass, l.statusUnusable]),
178
+ };
179
+ }
180
+
181
+ export function calculateSignalFromSketch(
182
+ router: Point,
183
+ device: Point,
184
+ walls: Segment[],
185
+ objects: PlacedObject[]
186
+ ): SignalResult {
187
+ const segments = buildSegments(walls, objects);
188
+ const directDist = dist(router, device);
189
+
190
+ const distanceLoss = Math.min(70, directDist / 8);
191
+ const obstacleLoss = castDirectRay(router, device, segments);
192
+ const totalLoss = Math.min(100, distanceLoss + obstacleLoss);
193
+ const strengthPercent = Math.max(0, Math.round(100 - totalLoss));
194
+
195
+ const effectiveRange = Math.max(0, Math.round(directDist / 10));
196
+ const verdict = computeVerdict(strengthPercent);
197
+
198
+ return {
199
+ strengthPercent,
200
+ effectiveRange,
201
+ verdict,
202
+ streamingVerdict: computeStreamingVerdict(strengthPercent),
203
+ rayCount: 1,
204
+ };
205
+ }
206
+
207
+ export function getVerdictColor(verdict: SignalResult['verdict']): string {
208
+ switch (verdict) {
209
+ case 'perfect':
210
+ return '#22c55e';
211
+ case 'good':
212
+ return '#84cc16';
213
+ case 'fair':
214
+ return '#f59e0b';
215
+ case 'poor':
216
+ return '#f97316';
217
+ case 'dead':
218
+ return '#ef4444';
219
+ }
220
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { wifiRangeSimulator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await wifiRangeSimulator.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,168 @@
1
+ import type { PlacedDevice } from './logic';
2
+ import type { State, Snapshot, History } from './sketch-state';
3
+ import { saveState, renderDeviceSelection } from './sketch-state';
4
+ import { renderObjects, renderWalls, renderRouter, renderDevices, spawnParticle } from './sketch-render';
5
+ import { updateDashboard } from './sketch-render-dash';
6
+ import { getUI } from './i18n-utils';
7
+ export { saveState };
8
+
9
+ export function addToHistory(h: History, s: State) {
10
+ if (h.pos < h.stack.length - 1) {
11
+ h.stack = h.stack.slice(0, h.pos + 1);
12
+ }
13
+ const snap: Snapshot = {
14
+ router: { ...s.router },
15
+ devices: s.devices.map((d) => ({ ...d })),
16
+ walls: s.walls.map((w) => ({ ...w, a: { ...w.a }, b: { ...w.b } })),
17
+ objects: s.objects.map((o) => ({ ...o })),
18
+ nextId: s.nextId,
19
+ zoom: s.zoom,
20
+ panX: s.panX,
21
+ panY: s.panY,
22
+ };
23
+ h.stack.push(snap);
24
+ if (h.stack.length > 50) h.stack.shift();
25
+ h.pos = h.stack.length - 1;
26
+ }
27
+
28
+ export function applySnapshot(s: State, snap: Snapshot) {
29
+ s.router = { ...snap.router };
30
+ s.devices = snap.devices.map((d) => ({ ...d }));
31
+ s.walls = snap.walls.map((w) => ({ ...w, a: { ...w.a }, b: { ...w.b } }));
32
+ s.objects = snap.objects.map((o) => ({ ...o }));
33
+ s.nextId = snap.nextId;
34
+ s.zoom = typeof snap.zoom === 'number' ? snap.zoom : 1;
35
+ s.panX = typeof snap.panX === 'number' ? snap.panX : 0;
36
+ s.panY = typeof snap.panY === 'number' ? snap.panY : 0;
37
+ }
38
+
39
+ export function renderZoom(s: State) {
40
+ const world = document.getElementById('sketch-world');
41
+ if (world) {
42
+ world.style.transform = `translate(${s.panX}px, ${s.panY}px) scale(${s.zoom})`;
43
+ }
44
+ const track = document.getElementById('sketch-scale-track');
45
+ if (track) {
46
+ const segPx = Math.max(6, 10 * s.zoom);
47
+ track.style.width = (segPx * 5) + 'px';
48
+ track.querySelectorAll('.sketch-scale-segment').forEach((el) => {
49
+ (el as HTMLElement).style.width = segPx + 'px';
50
+ });
51
+ }
52
+ }
53
+
54
+ export function getNextDeviceId(s: State): number {
55
+ const used = new Set(s.devices.map((d) => d.id));
56
+ let n = 1;
57
+ while (used.has(`dev-${n}`)) n++;
58
+ return n;
59
+ }
60
+
61
+ export function addDevice(s: State, h: History) {
62
+ const ui = getUI();
63
+ if (s.devices.length >= 4) {
64
+ spawnParticle(400, 250, ui.labelMaxDevices);
65
+ return;
66
+ }
67
+ const id = getNextDeviceId(s);
68
+ s.nextId = id + 1;
69
+ const dev: PlacedDevice = {
70
+ id: `dev-${id}`,
71
+ x: 680,
72
+ y: 160 + (s.devices.length * 80),
73
+ name: `${ui.labelDevicePrefix} ${s.devices.length + 1}`,
74
+ };
75
+ s.devices.push(dev);
76
+ saveState(s);
77
+ addToHistory(h, s);
78
+ renderDevices(s.devices, s.router, s.walls, s.objects);
79
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
80
+ renderDeviceSelection(s);
81
+ }
82
+
83
+ function doUndo(s: State, h: History) {
84
+ if (h.pos <= 0) return;
85
+ h.pos--;
86
+ applySnapshot(s, h.stack[h.pos]);
87
+ renderWalls(s.walls);
88
+ renderObjects(s.objects);
89
+ renderRouter(s.router);
90
+ renderDevices(s.devices, s.router, s.walls, s.objects);
91
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
92
+ renderDeviceSelection(s);
93
+ renderZoom(s);
94
+ saveState(s);
95
+ }
96
+
97
+ function doClear(s: State, h: History) {
98
+ const ui = getUI();
99
+ s.walls = [];
100
+ s.objects = [];
101
+ s.devices = [{ id: 'dev-1', x: 680, y: 250, name: `${ui.labelDevicePrefix} 1` }];
102
+ s.nextId = 2;
103
+ s.selectedDeviceId = null;
104
+ saveState(s);
105
+ addToHistory(h, s);
106
+ renderWalls(s.walls);
107
+ renderObjects(s.objects);
108
+ renderDevices(s.devices, s.router, s.walls, s.objects);
109
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
110
+ renderDeviceSelection(s);
111
+ }
112
+
113
+ function doClearWalls(s: State, h: History) {
114
+ s.walls = [];
115
+ saveState(s);
116
+ addToHistory(h, s);
117
+ renderWalls(s.walls);
118
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
119
+ }
120
+
121
+ function doClearObjects(s: State, h: History) {
122
+ s.objects = [];
123
+ saveState(s);
124
+ addToHistory(h, s);
125
+ renderObjects(s.objects);
126
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
127
+ }
128
+
129
+ function setTool(s: State, tool: string) {
130
+ s.tool = tool;
131
+ document.querySelectorAll('[data-tool]').forEach((b) => b.classList.remove('active'));
132
+ const active = document.querySelector(`[data-tool="${tool}"]`);
133
+ if (active) active.classList.add('active');
134
+ const wrap = document.getElementById('sketch-canvas-wrap');
135
+ if (wrap) wrap.style.cursor = tool === 'select' ? 'default' : 'crosshair';
136
+ }
137
+
138
+ const toolMap: Record<string, (s: State, h: History) => void> = {
139
+ zoomIn: (s) => {
140
+ s.zoom = Math.min(3, Math.round((s.zoom + 0.25) * 100) / 100);
141
+ renderZoom(s);
142
+ saveState(s);
143
+ },
144
+ zoomOut: (s) => {
145
+ s.zoom = Math.max(0.5, Math.round((s.zoom - 0.25) * 100) / 100);
146
+ renderZoom(s);
147
+ saveState(s);
148
+ },
149
+ undo: doUndo,
150
+ addDevice,
151
+ clearWalls: doClearWalls,
152
+ clearObjects: doClearObjects,
153
+ clear: doClear,
154
+ };
155
+
156
+ export function initToolButtons(s: State, h: History) {
157
+ document.querySelectorAll('[data-tool]').forEach((btn) => {
158
+ btn.addEventListener('click', () => {
159
+ const tool = (btn as HTMLElement).dataset.tool;
160
+ if (!tool) return;
161
+ if (toolMap[tool]) {
162
+ toolMap[tool](s, h);
163
+ } else {
164
+ setTool(s, tool);
165
+ }
166
+ });
167
+ });
168
+ }
@@ -0,0 +1,138 @@
1
+ import type { Point } from './logic';
2
+ import { MATERIALS } from './logic';
3
+ import type { State, History } from './sketch-state';
4
+ import {
5
+ ptToSvg,
6
+ isWallTool,
7
+ hitRouter,
8
+ nearestDevice,
9
+ renderDeviceSelection,
10
+ removePreviewWall,
11
+ updatePreviewWall,
12
+ } from './sketch-state';
13
+ import { renderObjects, renderWalls, renderRouter, renderDevices } from './sketch-render';
14
+ import { updateDashboard } from './sketch-render-dash';
15
+ import { addToHistory, saveState, renderZoom } from './sketch-actions';
16
+ import { spawnParticle } from './sketch-render';
17
+
18
+ function startSelect(s: State, p: Point, e: MouseEvent) {
19
+ if (hitRouter(s, p)) {
20
+ s.dragging = 'router';
21
+ s.selectedDeviceId = null;
22
+ renderDeviceSelection(s);
23
+ s.dragOffset = { x: p.x - s.router.x, y: p.y - s.router.y };
24
+ return;
25
+ }
26
+ const dev = nearestDevice(s.devices, p);
27
+ if (dev) {
28
+ s.selectedDeviceId = dev.id;
29
+ s.dragging = `device:${dev.id}`;
30
+ s.dragOffset = { x: p.x - dev.x, y: p.y - dev.y };
31
+ renderDeviceSelection(s);
32
+ return;
33
+ }
34
+ s.selectedDeviceId = null;
35
+ renderDeviceSelection(s);
36
+ s.dragging = 'pan';
37
+ s.dragOffset = { x: e.clientX, y: e.clientY };
38
+ }
39
+
40
+ function placeObject(s: State, p: Point) {
41
+ const m = MATERIALS[s.tool];
42
+ if (!m) return;
43
+ const id = `obj-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
44
+ s.objects.push({
45
+ id,
46
+ x: p.x,
47
+ y: p.y,
48
+ material: s.tool,
49
+ attenuation: m.attenuation,
50
+ width: 32,
51
+ height: 32,
52
+ });
53
+ saveState(s);
54
+ renderObjects(s.objects);
55
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
56
+ spawnParticle(p.x, p.y, `+${s.tool}`);
57
+ }
58
+
59
+ export function handleMouseDown(s: State, svg: SVGSVGElement, e: MouseEvent) {
60
+ const p = ptToSvg(svg, e.clientX, e.clientY, s.zoom);
61
+ if (s.tool === 'select') {
62
+ startSelect(s, p, e);
63
+ return;
64
+ }
65
+ if (isWallTool(s.tool)) {
66
+ s.drawing = true;
67
+ s.drawStart = p;
68
+ return;
69
+ }
70
+ placeObject(s, p);
71
+ }
72
+
73
+ export function handleMouseMove(s: State, svg: SVGSVGElement, e: MouseEvent) {
74
+ if (!s.dragging && !s.drawing) return;
75
+ if (s.dragging === 'pan') {
76
+ const dx = e.clientX - s.dragOffset.x;
77
+ const dy = e.clientY - s.dragOffset.y;
78
+ s.panX += dx;
79
+ s.panY += dy;
80
+ s.dragOffset = { x: e.clientX, y: e.clientY };
81
+ renderZoom(s);
82
+ return;
83
+ }
84
+ const p = ptToSvg(svg, e.clientX, e.clientY, s.zoom);
85
+ updateDrag(s, p);
86
+ }
87
+
88
+ export function handleMouseUp(s: State, svg: SVGSVGElement, h: History, e: MouseEvent) {
89
+ if (s.drawing && isWallTool(s.tool)) {
90
+ const p = ptToSvg(svg, e.clientX, e.clientY, s.zoom);
91
+ const dx = p.x - s.drawStart.x;
92
+ const dy = p.y - s.drawStart.y;
93
+ if (Math.sqrt(dx * dx + dy * dy) > 5) {
94
+ const m = MATERIALS[s.tool];
95
+ s.walls.push({
96
+ a: { ...s.drawStart },
97
+ b: { ...p },
98
+ material: s.tool,
99
+ attenuation: m.attenuation,
100
+ });
101
+ saveState(s);
102
+ addToHistory(h, s);
103
+ renderWalls(s.walls);
104
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
105
+ }
106
+ removePreviewWall();
107
+ }
108
+ if (s.dragging) {
109
+ s.dragging = null;
110
+ saveState(s);
111
+ addToHistory(h, s);
112
+ }
113
+ s.drawing = false;
114
+ }
115
+
116
+ export function updateDrag(s: State, p: Point) {
117
+ if (s.dragging === 'router') {
118
+ s.router.x = p.x - s.dragOffset.x;
119
+ s.router.y = p.y - s.dragOffset.y;
120
+ renderRouter(s.router);
121
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
122
+ return;
123
+ }
124
+ if (s.dragging?.startsWith('device:')) {
125
+ const id = s.dragging.slice(7);
126
+ const dev = s.devices.find((d) => d.id === id);
127
+ if (!dev) return;
128
+ dev.x = p.x - s.dragOffset.x;
129
+ dev.y = p.y - s.dragOffset.y;
130
+ renderDevices(s.devices, s.router, s.walls, s.objects);
131
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
132
+ renderDeviceSelection(s);
133
+ return;
134
+ }
135
+ if (s.drawing && isWallTool(s.tool)) {
136
+ updatePreviewWall(s.drawStart, p, MATERIALS[s.tool].color);
137
+ }
138
+ }