@jjlmoya/utils-home 1.17.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 (136) 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/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/dewPointCalculator/bibliography.ts +2 -2
  20. package/src/tool/dewPointCalculator/i18n/de.ts +5 -5
  21. package/src/tool/dewPointCalculator/i18n/en.ts +6 -6
  22. package/src/tool/dewPointCalculator/i18n/es.ts +5 -5
  23. package/src/tool/dewPointCalculator/i18n/fr.ts +6 -6
  24. package/src/tool/dewPointCalculator/i18n/id.ts +5 -5
  25. package/src/tool/dewPointCalculator/i18n/it.ts +5 -5
  26. package/src/tool/dewPointCalculator/i18n/ja.ts +4 -4
  27. package/src/tool/dewPointCalculator/i18n/ko.ts +4 -4
  28. package/src/tool/dewPointCalculator/i18n/nl.ts +5 -5
  29. package/src/tool/dewPointCalculator/i18n/pl.ts +5 -5
  30. package/src/tool/dewPointCalculator/i18n/pt.ts +5 -5
  31. package/src/tool/dewPointCalculator/i18n/ru.ts +11 -11
  32. package/src/tool/dewPointCalculator/i18n/sv.ts +5 -5
  33. package/src/tool/dewPointCalculator/i18n/tr.ts +4 -4
  34. package/src/tool/dewPointCalculator/i18n/zh.ts +5 -5
  35. package/src/tool/heatingComparator/i18n/de.ts +8 -8
  36. package/src/tool/heatingComparator/i18n/en.ts +1 -1
  37. package/src/tool/heatingComparator/i18n/es.ts +1 -1
  38. package/src/tool/heatingComparator/i18n/fr.ts +7 -7
  39. package/src/tool/heatingComparator/i18n/id.ts +1 -1
  40. package/src/tool/heatingComparator/i18n/it.ts +1 -1
  41. package/src/tool/heatingComparator/i18n/nl.ts +1 -1
  42. package/src/tool/heatingComparator/i18n/pl.ts +1 -1
  43. package/src/tool/heatingComparator/i18n/pt.ts +1 -1
  44. package/src/tool/heatingComparator/i18n/ru.ts +12 -12
  45. package/src/tool/heatingComparator/i18n/sv.ts +4 -4
  46. package/src/tool/heatingComparator/i18n/zh.ts +6 -6
  47. package/src/tool/ledSavingCalculator/bibliography.ts +3 -3
  48. package/src/tool/ledSavingCalculator/i18n/de.ts +4 -4
  49. package/src/tool/ledSavingCalculator/i18n/en.ts +4 -4
  50. package/src/tool/ledSavingCalculator/i18n/es.ts +4 -4
  51. package/src/tool/ledSavingCalculator/i18n/fr.ts +8 -8
  52. package/src/tool/ledSavingCalculator/i18n/id.ts +3 -3
  53. package/src/tool/ledSavingCalculator/i18n/it.ts +4 -4
  54. package/src/tool/ledSavingCalculator/i18n/ja.ts +3 -3
  55. package/src/tool/ledSavingCalculator/i18n/ko.ts +2 -2
  56. package/src/tool/ledSavingCalculator/i18n/nl.ts +3 -3
  57. package/src/tool/ledSavingCalculator/i18n/pl.ts +3 -3
  58. package/src/tool/ledSavingCalculator/i18n/pt.ts +3 -3
  59. package/src/tool/ledSavingCalculator/i18n/ru.ts +6 -6
  60. package/src/tool/ledSavingCalculator/i18n/sv.ts +3 -3
  61. package/src/tool/ledSavingCalculator/i18n/tr.ts +3 -3
  62. package/src/tool/ledSavingCalculator/i18n/zh.ts +4 -4
  63. package/src/tool/projectorCalculator/bibliography.ts +3 -3
  64. package/src/tool/projectorCalculator/i18n/de.ts +2 -2
  65. package/src/tool/projectorCalculator/i18n/en.ts +1 -1
  66. package/src/tool/projectorCalculator/i18n/es.ts +2 -2
  67. package/src/tool/projectorCalculator/i18n/fr.ts +4 -4
  68. package/src/tool/projectorCalculator/i18n/id.ts +2 -2
  69. package/src/tool/projectorCalculator/i18n/it.ts +2 -2
  70. package/src/tool/projectorCalculator/i18n/ja.ts +2 -2
  71. package/src/tool/projectorCalculator/i18n/ko.ts +2 -2
  72. package/src/tool/projectorCalculator/i18n/nl.ts +2 -2
  73. package/src/tool/projectorCalculator/i18n/pl.ts +3 -3
  74. package/src/tool/projectorCalculator/i18n/pt.ts +2 -2
  75. package/src/tool/projectorCalculator/i18n/ru.ts +5 -5
  76. package/src/tool/projectorCalculator/i18n/sv.ts +2 -2
  77. package/src/tool/projectorCalculator/i18n/tr.ts +2 -2
  78. package/src/tool/projectorCalculator/i18n/zh.ts +4 -4
  79. package/src/tool/qrGenerator/bibliography.ts +1 -1
  80. package/src/tool/qrGenerator/i18n/en.ts +1 -1
  81. package/src/tool/qrGenerator/i18n/fr.ts +1 -1
  82. package/src/tool/solarCalculator/bibliography.ts +2 -2
  83. package/src/tool/solarCalculator/i18n/de.ts +2 -2
  84. package/src/tool/solarCalculator/i18n/en.ts +5 -5
  85. package/src/tool/solarCalculator/i18n/es.ts +3 -3
  86. package/src/tool/solarCalculator/i18n/fr.ts +6 -6
  87. package/src/tool/solarCalculator/i18n/id.ts +2 -2
  88. package/src/tool/solarCalculator/i18n/it.ts +2 -2
  89. package/src/tool/solarCalculator/i18n/ja.ts +2 -2
  90. package/src/tool/solarCalculator/i18n/ko.ts +2 -2
  91. package/src/tool/solarCalculator/i18n/nl.ts +2 -2
  92. package/src/tool/solarCalculator/i18n/pl.ts +3 -3
  93. package/src/tool/solarCalculator/i18n/pt.ts +2 -2
  94. package/src/tool/solarCalculator/i18n/ru.ts +5 -5
  95. package/src/tool/solarCalculator/i18n/sv.ts +2 -2
  96. package/src/tool/solarCalculator/i18n/tr.ts +2 -2
  97. package/src/tool/solarCalculator/i18n/zh.ts +3 -3
  98. package/src/tool/tariffComparator/bibliography.ts +1 -1
  99. package/src/tool/tariffComparator/i18n/en.ts +3 -3
  100. package/src/tool/tariffComparator/i18n/es.ts +3 -3
  101. package/src/tool/tariffComparator/i18n/fr.ts +6 -6
  102. package/src/tool/tariffComparator/i18n/pl.ts +1 -1
  103. package/src/tool/tariffComparator/i18n/zh.ts +1 -1
  104. package/src/tool/wifiRangeSimulator/bibliography.astro +14 -0
  105. package/src/tool/wifiRangeSimulator/bibliography.ts +14 -0
  106. package/src/tool/wifiRangeSimulator/component.astro +170 -0
  107. package/src/tool/wifiRangeSimulator/entry.ts +29 -0
  108. package/src/tool/wifiRangeSimulator/i18n/de.ts +477 -0
  109. package/src/tool/wifiRangeSimulator/i18n/en.ts +477 -0
  110. package/src/tool/wifiRangeSimulator/i18n/es.ts +477 -0
  111. package/src/tool/wifiRangeSimulator/i18n/fr.ts +477 -0
  112. package/src/tool/wifiRangeSimulator/i18n/id.ts +477 -0
  113. package/src/tool/wifiRangeSimulator/i18n/it.ts +477 -0
  114. package/src/tool/wifiRangeSimulator/i18n/ja.ts +477 -0
  115. package/src/tool/wifiRangeSimulator/i18n/ko.ts +477 -0
  116. package/src/tool/wifiRangeSimulator/i18n/nl.ts +477 -0
  117. package/src/tool/wifiRangeSimulator/i18n/pl.ts +477 -0
  118. package/src/tool/wifiRangeSimulator/i18n/pt.ts +477 -0
  119. package/src/tool/wifiRangeSimulator/i18n/ru.ts +477 -0
  120. package/src/tool/wifiRangeSimulator/i18n/sv.ts +477 -0
  121. package/src/tool/wifiRangeSimulator/i18n/tr.ts +477 -0
  122. package/src/tool/wifiRangeSimulator/i18n/zh.ts +477 -0
  123. package/src/tool/wifiRangeSimulator/i18n-utils.ts +14 -0
  124. package/src/tool/wifiRangeSimulator/index.ts +8 -0
  125. package/src/tool/wifiRangeSimulator/logic.ts +220 -0
  126. package/src/tool/wifiRangeSimulator/seo.astro +15 -0
  127. package/src/tool/wifiRangeSimulator/sketch-actions.ts +168 -0
  128. package/src/tool/wifiRangeSimulator/sketch-events.ts +138 -0
  129. package/src/tool/wifiRangeSimulator/sketch-render-dash.ts +170 -0
  130. package/src/tool/wifiRangeSimulator/sketch-render-device.ts +42 -0
  131. package/src/tool/wifiRangeSimulator/sketch-render.ts +155 -0
  132. package/src/tool/wifiRangeSimulator/sketch-state.ts +186 -0
  133. package/src/tool/wifiRangeSimulator/sketch.ts +100 -0
  134. package/src/tool/wifiRangeSimulator/ui.ts +69 -0
  135. package/src/tool/wifiRangeSimulator/wifi-range-simulator.css +583 -0
  136. package/src/tools.ts +2 -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 };