@jjlmoya/utils-home 1.17.0 → 1.24.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 (162) 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 +7 -1
  16. package/src/tests/locale_completeness.test.ts +2 -2
  17. package/src/tests/no_en_dash.test.ts +70 -0
  18. package/src/tests/tool_validation.test.ts +2 -2
  19. package/src/tool/acTonnageCalculator/ac-tonnage-calculator.css +467 -0
  20. package/src/tool/acTonnageCalculator/bibliography.astro +14 -0
  21. package/src/tool/acTonnageCalculator/bibliography.ts +5 -0
  22. package/src/tool/acTonnageCalculator/client-animations.ts +80 -0
  23. package/src/tool/acTonnageCalculator/client.ts +171 -0
  24. package/src/tool/acTonnageCalculator/component.astro +186 -0
  25. package/src/tool/acTonnageCalculator/entry.ts +29 -0
  26. package/src/tool/acTonnageCalculator/i18n/de.ts +63 -0
  27. package/src/tool/acTonnageCalculator/i18n/en.ts +136 -0
  28. package/src/tool/acTonnageCalculator/i18n/es.ts +136 -0
  29. package/src/tool/acTonnageCalculator/i18n/fr.ts +61 -0
  30. package/src/tool/acTonnageCalculator/i18n/id.ts +61 -0
  31. package/src/tool/acTonnageCalculator/i18n/it.ts +61 -0
  32. package/src/tool/acTonnageCalculator/i18n/ja.ts +61 -0
  33. package/src/tool/acTonnageCalculator/i18n/ko.ts +61 -0
  34. package/src/tool/acTonnageCalculator/i18n/nl.ts +61 -0
  35. package/src/tool/acTonnageCalculator/i18n/pl.ts +61 -0
  36. package/src/tool/acTonnageCalculator/i18n/pt.ts +61 -0
  37. package/src/tool/acTonnageCalculator/i18n/ru.ts +61 -0
  38. package/src/tool/acTonnageCalculator/i18n/sv.ts +61 -0
  39. package/src/tool/acTonnageCalculator/i18n/tr.ts +61 -0
  40. package/src/tool/acTonnageCalculator/i18n/zh.ts +61 -0
  41. package/src/tool/acTonnageCalculator/index.ts +8 -0
  42. package/src/tool/acTonnageCalculator/logic.ts +56 -0
  43. package/src/tool/acTonnageCalculator/seo.astro +15 -0
  44. package/src/tool/acTonnageCalculator/ui.ts +39 -0
  45. package/src/tool/dewPointCalculator/bibliography.ts +2 -2
  46. package/src/tool/dewPointCalculator/i18n/de.ts +5 -5
  47. package/src/tool/dewPointCalculator/i18n/en.ts +6 -6
  48. package/src/tool/dewPointCalculator/i18n/es.ts +5 -5
  49. package/src/tool/dewPointCalculator/i18n/fr.ts +6 -6
  50. package/src/tool/dewPointCalculator/i18n/id.ts +5 -5
  51. package/src/tool/dewPointCalculator/i18n/it.ts +5 -5
  52. package/src/tool/dewPointCalculator/i18n/ja.ts +4 -4
  53. package/src/tool/dewPointCalculator/i18n/ko.ts +4 -4
  54. package/src/tool/dewPointCalculator/i18n/nl.ts +5 -5
  55. package/src/tool/dewPointCalculator/i18n/pl.ts +5 -5
  56. package/src/tool/dewPointCalculator/i18n/pt.ts +5 -5
  57. package/src/tool/dewPointCalculator/i18n/ru.ts +11 -11
  58. package/src/tool/dewPointCalculator/i18n/sv.ts +5 -5
  59. package/src/tool/dewPointCalculator/i18n/tr.ts +4 -4
  60. package/src/tool/dewPointCalculator/i18n/zh.ts +5 -5
  61. package/src/tool/heatingComparator/i18n/de.ts +8 -8
  62. package/src/tool/heatingComparator/i18n/en.ts +1 -1
  63. package/src/tool/heatingComparator/i18n/es.ts +1 -1
  64. package/src/tool/heatingComparator/i18n/fr.ts +7 -7
  65. package/src/tool/heatingComparator/i18n/id.ts +1 -1
  66. package/src/tool/heatingComparator/i18n/it.ts +1 -1
  67. package/src/tool/heatingComparator/i18n/nl.ts +1 -1
  68. package/src/tool/heatingComparator/i18n/pl.ts +1 -1
  69. package/src/tool/heatingComparator/i18n/pt.ts +1 -1
  70. package/src/tool/heatingComparator/i18n/ru.ts +12 -12
  71. package/src/tool/heatingComparator/i18n/sv.ts +4 -4
  72. package/src/tool/heatingComparator/i18n/zh.ts +6 -6
  73. package/src/tool/ledSavingCalculator/bibliography.ts +3 -3
  74. package/src/tool/ledSavingCalculator/i18n/de.ts +4 -4
  75. package/src/tool/ledSavingCalculator/i18n/en.ts +4 -4
  76. package/src/tool/ledSavingCalculator/i18n/es.ts +4 -4
  77. package/src/tool/ledSavingCalculator/i18n/fr.ts +8 -8
  78. package/src/tool/ledSavingCalculator/i18n/id.ts +3 -3
  79. package/src/tool/ledSavingCalculator/i18n/it.ts +4 -4
  80. package/src/tool/ledSavingCalculator/i18n/ja.ts +3 -3
  81. package/src/tool/ledSavingCalculator/i18n/ko.ts +2 -2
  82. package/src/tool/ledSavingCalculator/i18n/nl.ts +3 -3
  83. package/src/tool/ledSavingCalculator/i18n/pl.ts +3 -3
  84. package/src/tool/ledSavingCalculator/i18n/pt.ts +3 -3
  85. package/src/tool/ledSavingCalculator/i18n/ru.ts +6 -6
  86. package/src/tool/ledSavingCalculator/i18n/sv.ts +3 -3
  87. package/src/tool/ledSavingCalculator/i18n/tr.ts +3 -3
  88. package/src/tool/ledSavingCalculator/i18n/zh.ts +4 -4
  89. package/src/tool/projectorCalculator/bibliography.ts +3 -3
  90. package/src/tool/projectorCalculator/i18n/de.ts +2 -2
  91. package/src/tool/projectorCalculator/i18n/en.ts +1 -1
  92. package/src/tool/projectorCalculator/i18n/es.ts +2 -2
  93. package/src/tool/projectorCalculator/i18n/fr.ts +4 -4
  94. package/src/tool/projectorCalculator/i18n/id.ts +2 -2
  95. package/src/tool/projectorCalculator/i18n/it.ts +2 -2
  96. package/src/tool/projectorCalculator/i18n/ja.ts +2 -2
  97. package/src/tool/projectorCalculator/i18n/ko.ts +2 -2
  98. package/src/tool/projectorCalculator/i18n/nl.ts +2 -2
  99. package/src/tool/projectorCalculator/i18n/pl.ts +3 -3
  100. package/src/tool/projectorCalculator/i18n/pt.ts +2 -2
  101. package/src/tool/projectorCalculator/i18n/ru.ts +5 -5
  102. package/src/tool/projectorCalculator/i18n/sv.ts +2 -2
  103. package/src/tool/projectorCalculator/i18n/tr.ts +2 -2
  104. package/src/tool/projectorCalculator/i18n/zh.ts +4 -4
  105. package/src/tool/qrGenerator/bibliography.ts +1 -1
  106. package/src/tool/qrGenerator/i18n/en.ts +1 -1
  107. package/src/tool/qrGenerator/i18n/fr.ts +1 -1
  108. package/src/tool/solarCalculator/bibliography.ts +2 -2
  109. package/src/tool/solarCalculator/i18n/de.ts +2 -2
  110. package/src/tool/solarCalculator/i18n/en.ts +5 -5
  111. package/src/tool/solarCalculator/i18n/es.ts +3 -3
  112. package/src/tool/solarCalculator/i18n/fr.ts +6 -6
  113. package/src/tool/solarCalculator/i18n/id.ts +2 -2
  114. package/src/tool/solarCalculator/i18n/it.ts +2 -2
  115. package/src/tool/solarCalculator/i18n/ja.ts +2 -2
  116. package/src/tool/solarCalculator/i18n/ko.ts +2 -2
  117. package/src/tool/solarCalculator/i18n/nl.ts +2 -2
  118. package/src/tool/solarCalculator/i18n/pl.ts +3 -3
  119. package/src/tool/solarCalculator/i18n/pt.ts +2 -2
  120. package/src/tool/solarCalculator/i18n/ru.ts +5 -5
  121. package/src/tool/solarCalculator/i18n/sv.ts +2 -2
  122. package/src/tool/solarCalculator/i18n/tr.ts +2 -2
  123. package/src/tool/solarCalculator/i18n/zh.ts +3 -3
  124. package/src/tool/tariffComparator/bibliography.ts +1 -1
  125. package/src/tool/tariffComparator/i18n/en.ts +3 -3
  126. package/src/tool/tariffComparator/i18n/es.ts +3 -3
  127. package/src/tool/tariffComparator/i18n/fr.ts +6 -6
  128. package/src/tool/tariffComparator/i18n/pl.ts +1 -1
  129. package/src/tool/tariffComparator/i18n/zh.ts +1 -1
  130. package/src/tool/wifiRangeSimulator/bibliography.astro +14 -0
  131. package/src/tool/wifiRangeSimulator/bibliography.ts +14 -0
  132. package/src/tool/wifiRangeSimulator/component.astro +170 -0
  133. package/src/tool/wifiRangeSimulator/entry.ts +29 -0
  134. package/src/tool/wifiRangeSimulator/i18n/de.ts +477 -0
  135. package/src/tool/wifiRangeSimulator/i18n/en.ts +477 -0
  136. package/src/tool/wifiRangeSimulator/i18n/es.ts +477 -0
  137. package/src/tool/wifiRangeSimulator/i18n/fr.ts +477 -0
  138. package/src/tool/wifiRangeSimulator/i18n/id.ts +477 -0
  139. package/src/tool/wifiRangeSimulator/i18n/it.ts +477 -0
  140. package/src/tool/wifiRangeSimulator/i18n/ja.ts +477 -0
  141. package/src/tool/wifiRangeSimulator/i18n/ko.ts +477 -0
  142. package/src/tool/wifiRangeSimulator/i18n/nl.ts +477 -0
  143. package/src/tool/wifiRangeSimulator/i18n/pl.ts +477 -0
  144. package/src/tool/wifiRangeSimulator/i18n/pt.ts +477 -0
  145. package/src/tool/wifiRangeSimulator/i18n/ru.ts +477 -0
  146. package/src/tool/wifiRangeSimulator/i18n/sv.ts +477 -0
  147. package/src/tool/wifiRangeSimulator/i18n/tr.ts +477 -0
  148. package/src/tool/wifiRangeSimulator/i18n/zh.ts +477 -0
  149. package/src/tool/wifiRangeSimulator/i18n-utils.ts +14 -0
  150. package/src/tool/wifiRangeSimulator/index.ts +8 -0
  151. package/src/tool/wifiRangeSimulator/logic.ts +220 -0
  152. package/src/tool/wifiRangeSimulator/seo.astro +15 -0
  153. package/src/tool/wifiRangeSimulator/sketch-actions.ts +168 -0
  154. package/src/tool/wifiRangeSimulator/sketch-events.ts +138 -0
  155. package/src/tool/wifiRangeSimulator/sketch-render-dash.ts +170 -0
  156. package/src/tool/wifiRangeSimulator/sketch-render-device.ts +42 -0
  157. package/src/tool/wifiRangeSimulator/sketch-render.ts +155 -0
  158. package/src/tool/wifiRangeSimulator/sketch-state.ts +186 -0
  159. package/src/tool/wifiRangeSimulator/sketch.ts +100 -0
  160. package/src/tool/wifiRangeSimulator/ui.ts +69 -0
  161. package/src/tool/wifiRangeSimulator/wifi-range-simulator.css +583 -0
  162. package/src/tools.ts +4 -0
@@ -0,0 +1,170 @@
1
+ import type { Point, Segment, PlacedObject, PlacedDevice, SignalResult } from './logic';
2
+ import { calculateSignalFromSketch, getVerdictColor, computeStreamingVerdict } from './logic';
3
+ import { renderRouter, renderRays } from './sketch-render';
4
+ import { getUI } from './i18n-utils';
5
+
6
+ const CIRCUMFERENCE = 2 * Math.PI * 70;
7
+
8
+ function setText(id: string, val: string) {
9
+ const el = document.getElementById(id);
10
+ if (el) el.textContent = val;
11
+ }
12
+
13
+ function setAttr(id: string, attr: string, val: string) {
14
+ const el = document.getElementById(id);
15
+ if (el) el.setAttribute(attr, val);
16
+ }
17
+
18
+ function setStyle(id: string, prop: string, val: string) {
19
+ const el = document.getElementById(id);
20
+ if (el) (el as HTMLElement).style.setProperty(prop, val);
21
+ }
22
+
23
+ function updateRing(percent: number, color: string) {
24
+ const offset = CIRCUMFERENCE - (CIRCUMFERENCE * percent) / 100;
25
+ setAttr('sketch-ring-fill', 'stroke-dashoffset', String(offset));
26
+ setAttr('sketch-ring-fill', 'stroke', color);
27
+ setStyle('sketch-ring-pct', 'color', color);
28
+ setText('sketch-ring-pct', `${percent}%`);
29
+ }
30
+
31
+ function getStatusCol(status: string, pct: number): string {
32
+ if (pct >= 60) return '#22c55e';
33
+ if (pct >= 30) return '#f59e0b';
34
+ return '#ef4444';
35
+ }
36
+
37
+ function updateStreaming(result: ReturnType<typeof calculateSignalFromSketch>) {
38
+ const keys = ['4kStreaming', 'onlineGaming', 'videoCalls', 'basicBrowsing'];
39
+ const thresholds: Record<string, number> = { '4kStreaming': result.strengthPercent, onlineGaming: result.strengthPercent, videoCalls: result.strengthPercent, basicBrowsing: result.strengthPercent };
40
+ keys.forEach((k) => {
41
+ const el = document.getElementById(`sketch-badge-${k}`);
42
+ if (!el) return;
43
+ el.textContent = result.streamingVerdict[k];
44
+ const col = getStatusCol(result.streamingVerdict[k], thresholds[k]);
45
+ (el as HTMLElement).style.color = col;
46
+ (el as HTMLElement).style.background = col + '14';
47
+ });
48
+ }
49
+
50
+ function buildMiniBar(percent: number, color: string): string {
51
+ const w = Math.max(4, Math.round((percent / 100) * 60));
52
+ return `<svg width="64" height="6" style="flex-shrink:0"><rect width="64" height="6" rx="3" fill="var(--bg-muted)"/><rect width="${w}" height="6" rx="3" fill="${color}"/></svg>`;
53
+ }
54
+
55
+ function buildDeviceCard(dev: PlacedDevice, result: ReturnType<typeof calculateSignalFromSketch>): string {
56
+ const color = getVerdictColor(result.verdict);
57
+ const ui = getUI();
58
+ return `
59
+ <div class="sketch-device-card">
60
+ <div class="sketch-device-header">
61
+ <span class="sketch-device-dot" style="background:${color}"></span>
62
+ <span class="sketch-device-name">${dev.name}</span>
63
+ <span class="sketch-device-pct" style="color:${color}">${result.strengthPercent}%</span>
64
+ </div>
65
+ <div class="sketch-device-bar-row">
66
+ ${buildMiniBar(result.strengthPercent, color)}
67
+ <span class="sketch-device-range">${result.effectiveRange}${ui.labelMeters}</span>
68
+ </div>
69
+ </div>
70
+ `;
71
+ }
72
+
73
+ function renderDevicePanel(devices: PlacedDevice[], router: Point, walls: Segment[], objects: PlacedObject[]) {
74
+ const panel = document.getElementById('sketch-device-panel');
75
+ if (!panel) return;
76
+ const ui = getUI();
77
+ panel.innerHTML = `<div class="sketch-device-section-title">${ui.labelPerDevice}</div>`;
78
+ devices.forEach((dev) => {
79
+ const result = calculateSignalFromSketch(router, dev, walls, objects);
80
+ const div = document.createElement('div');
81
+ div.innerHTML = buildDeviceCard(dev, result);
82
+ panel.appendChild(div.firstElementChild as HTMLElement);
83
+ });
84
+ }
85
+
86
+ function buildTips(walls: Segment[], objects: PlacedObject[], result: ReturnType<typeof calculateSignalFromSketch>): string[] {
87
+ const ui = getUI();
88
+ const tips: string[] = [];
89
+ const concreteCount = walls.filter((w) => w.material === 'concrete').length;
90
+ const metalCount = walls.filter((w) => w.material === 'metalDoor').length;
91
+ const total = walls.length + objects.length;
92
+
93
+ if (concreteCount > 0 || metalCount > 0) {
94
+ tips.push(ui.tipMoveRouter);
95
+ }
96
+ if (total >= 3) {
97
+ tips.push(ui.tipReduceObstacles);
98
+ }
99
+ if (result.verdict === 'dead' || result.verdict === 'poor') {
100
+ tips.push(ui.tipElevateRouter);
101
+ }
102
+ if (objects.some((o) => o.material === 'aquarium')) {
103
+ tips.push(ui.tipFishTank);
104
+ }
105
+ if (objects.some((o) => o.material === 'microwave')) {
106
+ tips.push(ui.tipMicrowave);
107
+ }
108
+ return tips;
109
+ }
110
+
111
+ function renderTips(tips: string[]) {
112
+ const area = document.getElementById('sketch-tip-area');
113
+ if (!area) return;
114
+ area.innerHTML = '';
115
+ tips.forEach((text) => {
116
+ const card = document.createElement('div');
117
+ card.className = 'sketch-tip-card';
118
+ card.innerHTML = `<div class="sketch-tip-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></div><div class="sketch-tip-text">${text}</div>`;
119
+ area.appendChild(card);
120
+ });
121
+ }
122
+
123
+ function averageResults(results: ReturnType<typeof calculateSignalFromSketch>[]): SignalResult {
124
+ const ui = getUI();
125
+ const avgPct = Math.round(results.reduce((s, r) => s + r.strengthPercent, 0) / results.length);
126
+ const avgRange = Math.round(results.reduce((s, r) => s + r.effectiveRange, 0) / results.length);
127
+ let verdict: SignalResult['verdict'];
128
+ if (avgPct >= 80) verdict = 'perfect';
129
+ else if (avgPct >= 60) verdict = 'good';
130
+ else if (avgPct >= 40) verdict = 'fair';
131
+ else if (avgPct >= 20) verdict = 'poor';
132
+ else verdict = 'dead';
133
+ return {
134
+ strengthPercent: avgPct,
135
+ effectiveRange: avgRange,
136
+ verdict,
137
+ streamingVerdict: computeStreamingVerdict(avgPct, ui),
138
+ rayCount: 1,
139
+ };
140
+ }
141
+
142
+ export function updateDashboard(
143
+ router: Point,
144
+ devices: PlacedDevice[],
145
+ walls: Segment[],
146
+ objects: PlacedObject[]
147
+ ) {
148
+ if (devices.length === 0) return;
149
+
150
+ const ui = getUI();
151
+ const allResults = devices.map((dev) => calculateSignalFromSketch(router, dev, walls, objects));
152
+ const avgResult = averageResults(allResults);
153
+ const color = getVerdictColor(avgResult.verdict);
154
+
155
+ updateRing(avgResult.strengthPercent, color);
156
+ setText('sketch-range-val', `${avgResult.effectiveRange} ${ui.labelMeters}`);
157
+ updateStreaming(avgResult);
158
+ renderTips(buildTips(walls, objects, avgResult));
159
+ renderDevicePanel(devices, router, walls, objects);
160
+ renderRays(router, devices, walls, objects);
161
+ renderRouter(router, color);
162
+
163
+ const ring = document.getElementById('sketch-ring');
164
+ if (ring) {
165
+ ring.classList.remove('sketch-peak-anim', 'sketch-danger-anim');
166
+ void ring.offsetWidth;
167
+ if (avgResult.verdict === 'perfect') ring.classList.add('sketch-peak-anim');
168
+ if (avgResult.verdict === 'dead') ring.classList.add('sketch-danger-anim');
169
+ }
170
+ }
@@ -0,0 +1,42 @@
1
+ import type { PlacedDevice } from './logic';
2
+
3
+ function setAttrs(el: Element, attrs: Record<string, string>) {
4
+ Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
5
+ }
6
+
7
+ function makeGlow(color: string) {
8
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
9
+ setAttrs(el, { cx: '0', cy: '0', r: '24', fill: 'none', stroke: color, 'stroke-width': '2', opacity: '0.3' });
10
+ return el;
11
+ }
12
+
13
+ function makeRect() {
14
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
15
+ setAttrs(el, { x: '-14', y: '-14', width: '28', height: '28', rx: '6', fill: 'var(--bg-surface)', stroke: 'var(--border-color)', 'stroke-width': '1' });
16
+ return el;
17
+ }
18
+
19
+ function makeIcon() {
20
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
21
+ setAttrs(el, { x: '-8', y: '-8', width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', stroke: 'var(--text-main)', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' });
22
+ el.innerHTML = '<rect x="2" y="4" width="20" height="14" rx="2"/><line x1="8" y1="22" x2="16" y2="22"/><line x1="12" y1="18" x2="12" y2="22"/>';
23
+ return el;
24
+ }
25
+
26
+ function makeLabel(name: string) {
27
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'text');
28
+ setAttrs(el, { x: '0', y: '26', 'text-anchor': 'middle', fill: 'var(--text-muted)', 'font-size': '8', 'font-weight': '700' });
29
+ el.textContent = name;
30
+ return el;
31
+ }
32
+
33
+ export function buildDeviceSVG(dev: PlacedDevice, color: string) {
34
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
35
+ setAttrs(g, { id: `sketch-device-${dev.id}`, transform: `translate(${dev.x}, ${dev.y})`, style: 'cursor:grab' });
36
+ g.dataset.deviceId = dev.id;
37
+ g.appendChild(makeGlow(color));
38
+ g.appendChild(makeRect());
39
+ g.appendChild(makeIcon());
40
+ g.appendChild(makeLabel(dev.name));
41
+ return g;
42
+ }
@@ -0,0 +1,155 @@
1
+ import type { Point, Segment, PlacedObject, PlacedDevice } from './logic';
2
+ import { calculateSignalFromSketch, getVerdictColor, MATERIALS } from './logic';
3
+ import { buildDeviceSVG } from './sketch-render-device';
4
+ import { getUI } from './i18n-utils';
5
+
6
+ export function renderWalls(walls: Segment[]) {
7
+ const layer = document.getElementById('sketch-walls-layer');
8
+ if (!layer) return;
9
+ layer.innerHTML = '';
10
+ walls.forEach((wall) => {
11
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
12
+ line.setAttribute('x1', String(wall.a.x));
13
+ line.setAttribute('y1', String(wall.a.y));
14
+ line.setAttribute('x2', String(wall.b.x));
15
+ line.setAttribute('y2', String(wall.b.y));
16
+ line.setAttribute('stroke', MATERIALS[wall.material].color);
17
+ line.setAttribute('stroke-width', '6');
18
+ line.setAttribute('stroke-linecap', 'round');
19
+ line.setAttribute('class', 'sketch-wall-line');
20
+ layer.appendChild(line);
21
+ });
22
+ }
23
+
24
+ function buildObjRect(g: SVGGElement, obj: PlacedObject) {
25
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
26
+ rect.setAttribute('x', String(-obj.width / 2));
27
+ rect.setAttribute('y', String(-obj.height / 2));
28
+ rect.setAttribute('width', String(obj.width));
29
+ rect.setAttribute('height', String(obj.height));
30
+ rect.setAttribute('rx', '4');
31
+ rect.setAttribute('fill', MATERIALS[obj.material].color);
32
+ rect.setAttribute('stroke', 'rgba(0,0,0,0.15)');
33
+ rect.setAttribute('stroke-width', '1');
34
+ g.appendChild(rect);
35
+ }
36
+
37
+ function buildObjLabel(g: SVGGElement, obj: PlacedObject) {
38
+ const ui = getUI();
39
+ const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
40
+ txt.setAttribute('y', '4');
41
+ txt.setAttribute('text-anchor', 'middle');
42
+ txt.setAttribute('fill', '#fff');
43
+ txt.setAttribute('font-size', '7');
44
+ txt.setAttribute('font-weight', '700');
45
+ txt.setAttribute('style', 'text-shadow:0 1px 2px rgba(0,0,0,0.4);pointer-events:none');
46
+ txt.textContent = `${obj.attenuation}${ui.labelDb}`;
47
+ g.appendChild(txt);
48
+ }
49
+
50
+ export function renderObjects(objects: PlacedObject[]) {
51
+ const layer = document.getElementById('sketch-objs-layer');
52
+ if (!layer) return;
53
+ const existing = layer.querySelectorAll('[data-obj-id]');
54
+ existing.forEach((el) => el.remove());
55
+ objects.forEach((obj) => {
56
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
57
+ g.setAttribute('transform', `translate(${obj.x}, ${obj.y})`);
58
+ g.setAttribute('class', 'sketch-obj-group');
59
+ g.dataset.objId = obj.id;
60
+ buildObjRect(g, obj);
61
+ buildObjLabel(g, obj);
62
+ layer.appendChild(g);
63
+ });
64
+ }
65
+
66
+ export function renderRouter(router: Point, color?: string) {
67
+ const g = document.getElementById('sketch-router-group');
68
+ if (g) g.setAttribute('transform', `translate(${router.x}, ${router.y})`);
69
+ if (color) {
70
+ const glow = document.getElementById('sketch-router-glow');
71
+ if (glow) glow.setAttribute('stroke', color);
72
+ }
73
+ }
74
+
75
+ export function renderDevices(devices: PlacedDevice[], router: Point, walls: Segment[], objects: PlacedObject[]) {
76
+ const layer = document.getElementById('sketch-objs-layer');
77
+ if (!layer) return;
78
+ const existing = layer.querySelectorAll('[data-device-id]');
79
+ existing.forEach((el) => el.remove());
80
+
81
+ devices.forEach((dev) => {
82
+ const result = calculateSignalFromSketch(router, dev, walls, objects);
83
+ const color = getVerdictColor(result.verdict);
84
+ const svgEl = buildDeviceSVG(dev, color);
85
+ layer.appendChild(svgEl);
86
+ });
87
+ }
88
+
89
+ export function renderRays(router: Point, devices: PlacedDevice[], walls: Segment[], objects: PlacedObject[]) {
90
+ const layer = document.getElementById('sketch-rays-layer');
91
+ if (!layer) return;
92
+ layer.innerHTML = '';
93
+
94
+ devices.forEach((dev) => {
95
+ const result = calculateSignalFromSketch(router, dev, walls, objects);
96
+ const color = getVerdictColor(result.verdict);
97
+ const dx = dev.x - router.x;
98
+ const dy = dev.y - router.y;
99
+ const len = Math.sqrt(dx * dx + dy * dy);
100
+ const angle = Math.atan2(dy, dx);
101
+ const spread = 0.06;
102
+
103
+ [ -1, 0, 1 ].forEach((offset) => {
104
+ const a = angle + offset * spread;
105
+ const ex = router.x + Math.cos(a) * len;
106
+ const ey = router.y + Math.sin(a) * len;
107
+ const d = `M ${router.x} ${router.y} L ${ex} ${ey}`;
108
+
109
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
110
+ path.setAttribute('d', d);
111
+ path.setAttribute('fill', 'none');
112
+ path.setAttribute('stroke', color);
113
+ path.setAttribute('stroke-width', '1.5');
114
+ path.setAttribute('stroke-linecap', 'round');
115
+ path.setAttribute('opacity', String(0.25 + Math.abs(offset) * 0.15));
116
+ layer.appendChild(path);
117
+ });
118
+ });
119
+ }
120
+
121
+ export function spawnParticle(x: number, y: number, text: string) {
122
+ const wrap = document.getElementById('sketch-canvas-wrap');
123
+ if (!wrap) return;
124
+ const p = document.createElement('span');
125
+ p.className = 'sketch-particle';
126
+ p.textContent = text;
127
+ const rect = wrap.getBoundingClientRect();
128
+ p.style.left = `${x - rect.left}px`;
129
+ p.style.top = `${y - rect.top}px`;
130
+ wrap.appendChild(p);
131
+ setTimeout(() => p.remove(), 900);
132
+ }
133
+
134
+ export function hitObject(obj: PlacedObject, p: Point): boolean {
135
+ const hw = obj.width / 2;
136
+ const hh = obj.height / 2;
137
+ return p.x >= obj.x - hw && p.x <= obj.x + hw && p.y >= obj.y - hh && p.y <= obj.y + hh;
138
+ }
139
+
140
+ export function hitWall(wall: Segment, p: Point): boolean {
141
+ const d = dist(wall.a, wall.b);
142
+ const d1 = dist(p, wall.a);
143
+ const d2 = dist(p, wall.b);
144
+ return Math.abs(d - (d1 + d2)) < 8;
145
+ }
146
+
147
+ export function hitDevice(dev: PlacedDevice, p: Point): boolean {
148
+ return dist(p, dev) < 35;
149
+ }
150
+
151
+ function dist(a: Point, b: Point): number {
152
+ const dx = b.x - a.x;
153
+ const dy = b.y - a.y;
154
+ return Math.sqrt(dx * dx + dy * dy);
155
+ }
@@ -0,0 +1,186 @@
1
+ import type { Point, Segment, PlacedDevice, PlacedObject } from './logic';
2
+ import { getUI } from './i18n-utils';
3
+
4
+ export interface State {
5
+ tool: string;
6
+ drawing: boolean;
7
+ drawStart: Point;
8
+ router: Point;
9
+ devices: PlacedDevice[];
10
+ selectedDeviceId: string | null;
11
+ walls: Segment[];
12
+ objects: PlacedObject[];
13
+ dragging: string | null;
14
+ dragOffset: Point;
15
+ nextId: number;
16
+ zoom: number;
17
+ panX: number;
18
+ panY: number;
19
+ }
20
+
21
+ export interface Snapshot {
22
+ router: Point;
23
+ devices: PlacedDevice[];
24
+ walls: Segment[];
25
+ objects: PlacedObject[];
26
+ nextId: number;
27
+ zoom: number;
28
+ panX: number;
29
+ panY: number;
30
+ }
31
+
32
+ export interface History {
33
+ stack: Snapshot[];
34
+ pos: number;
35
+ }
36
+
37
+ export function defaultState(): State {
38
+ const ui = getUI();
39
+ return {
40
+ tool: 'select',
41
+ drawing: false,
42
+ drawStart: { x: 0, y: 0 },
43
+ router: { x: 150, y: 250 },
44
+ devices: [{ id: 'dev-1', x: 400, y: 250, name: `${ui.labelDevicePrefix} 1` }],
45
+ selectedDeviceId: null,
46
+ walls: [],
47
+ objects: [],
48
+ dragging: null,
49
+ dragOffset: { x: 0, y: 0 },
50
+ nextId: 2,
51
+ zoom: 1.5,
52
+ panX: 0,
53
+ panY: 0,
54
+ };
55
+ }
56
+
57
+ export function saveState(s: State): void {
58
+ const payload: Snapshot = {
59
+ router: { ...s.router },
60
+ devices: s.devices.map((d) => ({ ...d })),
61
+ walls: s.walls.map((w) => ({ ...w, a: { ...w.a }, b: { ...w.b } })),
62
+ objects: s.objects.map((o) => ({ ...o })),
63
+ nextId: s.nextId,
64
+ zoom: s.zoom,
65
+ panX: s.panX,
66
+ panY: s.panY,
67
+ };
68
+ localStorage.setItem('wifi-sketch-v1', JSON.stringify(payload));
69
+ }
70
+
71
+ function dedupeDevices(devices: PlacedDevice[]): PlacedDevice[] {
72
+ const seen = new Set<string>();
73
+ let next = 1;
74
+ return devices.map((d) => {
75
+ if (!seen.has(d.id)) {
76
+ seen.add(d.id);
77
+ return d;
78
+ }
79
+ while (seen.has(`dev-${next}`)) next++;
80
+ const newId = `dev-${next}`;
81
+ seen.add(newId);
82
+ return { ...d, id: newId };
83
+ });
84
+ }
85
+
86
+ export function loadState(): State | null {
87
+ const raw = localStorage.getItem('wifi-sketch-v1');
88
+ if (!raw) return null;
89
+ try {
90
+ const snap = JSON.parse(raw) as Snapshot;
91
+ const devices = dedupeDevices(snap.devices);
92
+ return {
93
+ tool: 'select',
94
+ drawing: false,
95
+ drawStart: { x: 0, y: 0 },
96
+ router: snap.router,
97
+ devices,
98
+ selectedDeviceId: null,
99
+ walls: snap.walls,
100
+ objects: snap.objects,
101
+ dragging: null,
102
+ dragOffset: { x: 0, y: 0 },
103
+ nextId: snap.nextId,
104
+ zoom: typeof snap.zoom === 'number' ? snap.zoom : 1,
105
+ panX: typeof snap.panX === 'number' ? snap.panX : 0,
106
+ panY: typeof snap.panY === 'number' ? snap.panY : 0,
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ export function ptToSvg(svg: SVGSVGElement, clientX: number, clientY: number, zoom: number): Point {
114
+ const rect = svg.getBoundingClientRect();
115
+ return {
116
+ x: (clientX - rect.left) / zoom,
117
+ y: (clientY - rect.top) / zoom,
118
+ };
119
+ }
120
+
121
+ export function isWallTool(tool: string): boolean {
122
+ return ['drywall', 'brick', 'concrete', 'stoneWall'].includes(tool);
123
+ }
124
+
125
+ export function dist(a: Point, b: Point): number {
126
+ const dx = b.x - a.x;
127
+ const dy = b.y - a.y;
128
+ return Math.sqrt(dx * dx + dy * dy);
129
+ }
130
+
131
+ export function hitRouter(s: State, p: Point): boolean {
132
+ return dist(p, s.router) < 22;
133
+ }
134
+
135
+ export function nearestDevice(devices: PlacedDevice[], p: Point): PlacedDevice | null {
136
+ let nearest: PlacedDevice | null = null;
137
+ let nearestDist = Infinity;
138
+ for (const dev of devices) {
139
+ const d = dist(p, dev);
140
+ if (d < nearestDist) {
141
+ nearestDist = d;
142
+ nearest = dev;
143
+ }
144
+ }
145
+ if (nearestDist > 50) return null;
146
+ return nearest;
147
+ }
148
+
149
+ export function renderDeviceSelection(s: State) {
150
+ document.querySelectorAll('.sketch-device-selected').forEach((el) => {
151
+ el.remove();
152
+ });
153
+ if (!s.selectedDeviceId) return;
154
+ const dev = s.devices.find((d) => d.id === s.selectedDeviceId);
155
+ if (!dev) return;
156
+ const layer = document.getElementById('sketch-objs-layer');
157
+ if (!layer) return;
158
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
159
+ circle.setAttribute('cx', String(dev.x));
160
+ circle.setAttribute('cy', String(dev.y));
161
+ circle.setAttribute('r', '30');
162
+ circle.setAttribute('fill', 'none');
163
+ circle.setAttribute('stroke', '#0ea5e9');
164
+ circle.setAttribute('stroke-width', '3');
165
+ circle.setAttribute('stroke-dasharray', '4 2');
166
+ circle.setAttribute('opacity', '0.8');
167
+ circle.setAttribute('class', 'sketch-device-selected');
168
+ circle.setAttribute('pointer-events', 'none');
169
+ layer.appendChild(circle);
170
+ }
171
+
172
+ export function removePreviewWall() {
173
+ const preview = document.getElementById('sketch-draw-line');
174
+ if (preview) preview.setAttribute('opacity', '0');
175
+ }
176
+
177
+ export function updatePreviewWall(start: Point, end: Point, material: string) {
178
+ const preview = document.getElementById('sketch-draw-line');
179
+ if (!preview) return;
180
+ preview.setAttribute('x1', String(start.x));
181
+ preview.setAttribute('y1', String(start.y));
182
+ preview.setAttribute('x2', String(end.x));
183
+ preview.setAttribute('y2', String(end.y));
184
+ preview.setAttribute('stroke', material);
185
+ preview.setAttribute('opacity', '0.6');
186
+ }
@@ -0,0 +1,100 @@
1
+ import type { State, Snapshot } from './sketch-state';
2
+ import {
3
+ defaultState,
4
+ loadState,
5
+ ptToSvg,
6
+ renderDeviceSelection,
7
+ removePreviewWall,
8
+ } from './sketch-state';
9
+ import { renderWalls, renderObjects, renderRouter, renderDevices, hitObject, hitWall } from './sketch-render';
10
+ import { updateDashboard } from './sketch-render-dash';
11
+ import { initToolButtons, addToHistory, applySnapshot, renderZoom, saveState } from './sketch-actions';
12
+ import { handleMouseDown, handleMouseMove, handleMouseUp } from './sketch-events';
13
+
14
+ function initRender(s: State) {
15
+ renderWalls(s.walls);
16
+ renderObjects(s.objects);
17
+ renderRouter(s.router);
18
+ renderDevices(s.devices, s.router, s.walls, s.objects);
19
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
20
+ renderDeviceSelection(s);
21
+ renderZoom(s);
22
+ }
23
+
24
+ function removeObj(s: State, h: { stack: Snapshot[]; pos: number }, p: Point) {
25
+ const objIdx = s.objects.findIndex((o) => hitObject(o, p));
26
+ if (objIdx < 0) return false;
27
+ s.objects.splice(objIdx, 1);
28
+ saveState(s);
29
+ addToHistory(h, s);
30
+ renderObjects(s.objects);
31
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
32
+ return true;
33
+ }
34
+
35
+ function removeWall(s: State, h: { stack: Snapshot[]; pos: number }, p: Point) {
36
+ const wallIdx = s.walls.findIndex((w) => hitWall(w, p));
37
+ if (wallIdx < 0) return false;
38
+ s.walls.splice(wallIdx, 1);
39
+ saveState(s);
40
+ addToHistory(h, s);
41
+ renderWalls(s.walls);
42
+ updateDashboard(s.router, s.devices, s.walls, s.objects);
43
+ return true;
44
+ }
45
+
46
+ function bindCanvas(s: State, svg: SVGSVGElement, h: { stack: Snapshot[]; pos: number }, wrap: HTMLElement) {
47
+ wrap.addEventListener('mousedown', (e) => {
48
+ handleMouseDown(s, svg, e);
49
+ });
50
+ wrap.addEventListener('mousemove', (e) => {
51
+ handleMouseMove(s, svg, e);
52
+ });
53
+ wrap.addEventListener('mouseup', (e) => {
54
+ handleMouseUp(s, svg, h, e);
55
+ });
56
+ wrap.addEventListener('mouseleave', () => {
57
+ if (s.dragging || s.drawing) {
58
+ removePreviewWall();
59
+ s.dragging = null;
60
+ s.drawing = false;
61
+ saveState(s);
62
+ }
63
+ });
64
+ wrap.addEventListener('dblclick', (e) => {
65
+ const p = ptToSvg(svg, e.clientX, e.clientY, s.zoom);
66
+ if (removeObj(s, h, p)) return;
67
+ removeWall(s, h, p);
68
+ });
69
+ }
70
+
71
+ function bindKeyboard(s: State, h: { stack: Snapshot[]; pos: number }) {
72
+ document.addEventListener('keydown', (e) => {
73
+ if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
74
+ e.preventDefault();
75
+ if (h.pos <= 0) return;
76
+ h.pos--;
77
+ applySnapshot(s, h.stack[h.pos]);
78
+ initRender(s);
79
+ saveState(s);
80
+ }
81
+ });
82
+ }
83
+
84
+ export function initSketch() {
85
+ const wrap = document.getElementById('sketch-canvas-wrap');
86
+ if (!wrap) return;
87
+ const svg = wrap.querySelector('svg') as SVGSVGElement;
88
+ if (!svg) return;
89
+
90
+ const state: State = loadState() || defaultState();
91
+ const history = { stack: [] as Snapshot[], pos: -1 };
92
+ addToHistory(history, state);
93
+
94
+ initToolButtons(state, history);
95
+ initRender(state);
96
+ bindCanvas(state, svg, history, wrap);
97
+ bindKeyboard(state, history);
98
+ }
99
+
100
+ export type { State };