@jjlmoya/utils-home 1.29.0 → 1.31.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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +7 -1
  3. package/src/index.ts +2 -0
  4. package/src/tests/locale_completeness.test.ts +2 -2
  5. package/src/tests/tool_validation.test.ts +2 -2
  6. package/src/tool/applianceCostCalculator/appliance-cost-calculator.css +635 -0
  7. package/src/tool/applianceCostCalculator/bibliography.astro +14 -0
  8. package/src/tool/applianceCostCalculator/bibliography.ts +14 -0
  9. package/src/tool/applianceCostCalculator/component.astro +322 -0
  10. package/src/tool/applianceCostCalculator/entry.ts +29 -0
  11. package/src/tool/applianceCostCalculator/i18n/de.ts +229 -0
  12. package/src/tool/applianceCostCalculator/i18n/en.ts +229 -0
  13. package/src/tool/applianceCostCalculator/i18n/es.ts +229 -0
  14. package/src/tool/applianceCostCalculator/i18n/fr.ts +229 -0
  15. package/src/tool/applianceCostCalculator/i18n/id.ts +229 -0
  16. package/src/tool/applianceCostCalculator/i18n/it.ts +229 -0
  17. package/src/tool/applianceCostCalculator/i18n/ja.ts +229 -0
  18. package/src/tool/applianceCostCalculator/i18n/ko.ts +229 -0
  19. package/src/tool/applianceCostCalculator/i18n/nl.ts +229 -0
  20. package/src/tool/applianceCostCalculator/i18n/pl.ts +229 -0
  21. package/src/tool/applianceCostCalculator/i18n/pt.ts +229 -0
  22. package/src/tool/applianceCostCalculator/i18n/ru.ts +229 -0
  23. package/src/tool/applianceCostCalculator/i18n/sv.ts +229 -0
  24. package/src/tool/applianceCostCalculator/i18n/tr.ts +229 -0
  25. package/src/tool/applianceCostCalculator/i18n/zh.ts +229 -0
  26. package/src/tool/applianceCostCalculator/index.ts +9 -0
  27. package/src/tool/applianceCostCalculator/logic.ts +122 -0
  28. package/src/tool/applianceCostCalculator/seo.astro +15 -0
  29. package/src/tool/applianceCostCalculator/ui.ts +41 -0
  30. package/src/tool/tileLayoutCalculator/bibliography.astro +14 -0
  31. package/src/tool/tileLayoutCalculator/bibliography.ts +10 -0
  32. package/src/tool/tileLayoutCalculator/component.astro +415 -0
  33. package/src/tool/tileLayoutCalculator/entry.ts +29 -0
  34. package/src/tool/tileLayoutCalculator/i18n/de.ts +208 -0
  35. package/src/tool/tileLayoutCalculator/i18n/en.ts +208 -0
  36. package/src/tool/tileLayoutCalculator/i18n/es.ts +208 -0
  37. package/src/tool/tileLayoutCalculator/i18n/fr.ts +208 -0
  38. package/src/tool/tileLayoutCalculator/i18n/id.ts +208 -0
  39. package/src/tool/tileLayoutCalculator/i18n/it.ts +208 -0
  40. package/src/tool/tileLayoutCalculator/i18n/ja.ts +208 -0
  41. package/src/tool/tileLayoutCalculator/i18n/ko.ts +208 -0
  42. package/src/tool/tileLayoutCalculator/i18n/nl.ts +208 -0
  43. package/src/tool/tileLayoutCalculator/i18n/pl.ts +208 -0
  44. package/src/tool/tileLayoutCalculator/i18n/pt.ts +208 -0
  45. package/src/tool/tileLayoutCalculator/i18n/ru.ts +208 -0
  46. package/src/tool/tileLayoutCalculator/i18n/sv.ts +208 -0
  47. package/src/tool/tileLayoutCalculator/i18n/tr.ts +208 -0
  48. package/src/tool/tileLayoutCalculator/i18n/zh.ts +208 -0
  49. package/src/tool/tileLayoutCalculator/index.ts +9 -0
  50. package/src/tool/tileLayoutCalculator/logic.ts +55 -0
  51. package/src/tool/tileLayoutCalculator/seo.astro +15 -0
  52. package/src/tool/tileLayoutCalculator/tile-layout-calculator.css +404 -0
  53. package/src/tool/tileLayoutCalculator/ui.ts +37 -0
  54. package/src/tools.ts +4 -0
@@ -0,0 +1,415 @@
1
+ ---
2
+ import type { TileLayoutCalculatorUI } from './ui';
3
+ import { fmt1 } from './logic';
4
+
5
+ interface Props {
6
+ ui?: Record<string, unknown>;
7
+ }
8
+
9
+ const { ui = {} } = Astro.props;
10
+ const tUI = ui as TileLayoutCalculatorUI;
11
+
12
+ const INIT_R_W = 4, INIT_R_L = 5, INIT_T_W = 30, INIT_T_L = 30, INIT_G = 3, INIT_W = 10, INIT_TPB = 8, INIT_P = 25;
13
+ ---
14
+
15
+ <div class="tile-wrapper">
16
+ <div
17
+ class="tile-card"
18
+ data-u-mr={tUI.unitMetricRoom}
19
+ data-u-ir={tUI.unitImperialRoom}
20
+ data-u-mt={tUI.unitMetricTile}
21
+ data-u-it={tUI.unitImperialTile}
22
+ data-u-mg={tUI.unitGroutMetric}
23
+ data-u-ig={tUI.unitGroutImperial}
24
+ data-u-pct={tUI.unitPercent}
25
+ data-u-box={tUI.unitBoxes}
26
+ data-currency={tUI.currency}
27
+ >
28
+ <div class="tile-left">
29
+ <p class="tile-section-title">{tUI.sectionTitle}</p>
30
+ <div class="tile-unit-toggle">
31
+ <button class="tile-unit-btn tile-unit-active" data-unit="metric">{tUI.btnMetric}</button>
32
+ <button class="tile-unit-btn" data-unit="imperial">{tUI.btnImperial}</button>
33
+ </div>
34
+ <div class="tile-field">
35
+ <label class="tile-label">{tUI.labelPattern}</label>
36
+ <div class="tile-pattern-toggle">
37
+ <button class="tile-pattern-btn tile-pattern-active" data-pattern="straight">{tUI.btnPatternStraight}</button>
38
+ <button class="tile-pattern-btn" data-pattern="brick">{tUI.btnPatternBrick}</button>
39
+ <button class="tile-pattern-btn" data-pattern="diagonal">{tUI.btnPatternDiagonal}</button>
40
+ </div>
41
+ </div>
42
+ <div class="tile-field">
43
+ <label class="tile-label" for="tl-rw">{tUI.labelRoomWidth}</label>
44
+ <div class="tile-number-row">
45
+ <input type="number" id="tl-rw" value={INIT_R_W} min="0.1" max="50" step="0.1" class="tile-number-input" />
46
+ <span class="tile-number-unit" id="tl-rw-u">{tUI.unitMetricRoom}</span>
47
+ </div>
48
+ </div>
49
+ <div class="tile-field">
50
+ <label class="tile-label" for="tl-rl">{tUI.labelRoomLength}</label>
51
+ <div class="tile-number-row">
52
+ <input type="number" id="tl-rl" value={INIT_R_L} min="0.1" max="50" step="0.1" class="tile-number-input" />
53
+ <span class="tile-number-unit" id="tl-rl-u">{tUI.unitMetricRoom}</span>
54
+ </div>
55
+ </div>
56
+ <div class="tile-field">
57
+ <label class="tile-label" for="tl-tw">{tUI.labelTileWidth}</label>
58
+ <div class="tile-number-row">
59
+ <input type="number" id="tl-tw" value={INIT_T_W} min="5" max="200" step="1" class="tile-number-input" />
60
+ <span class="tile-number-unit" id="tl-tw-u">{tUI.unitMetricTile}</span>
61
+ </div>
62
+ </div>
63
+ <div class="tile-field">
64
+ <label class="tile-label" for="tl-tl">{tUI.labelTileLength}</label>
65
+ <div class="tile-number-row">
66
+ <input type="number" id="tl-tl" value={INIT_T_L} min="5" max="200" step="1" class="tile-number-input" />
67
+ <span class="tile-number-unit" id="tl-tl-u">{tUI.unitMetricTile}</span>
68
+ </div>
69
+ </div>
70
+ <div class="tile-field">
71
+ <label class="tile-label" for="tl-g">{tUI.labelGrout}</label>
72
+ <div class="tile-number-row">
73
+ <input type="number" id="tl-g" value={INIT_G} min="0" step="0.5" class="tile-number-input" />
74
+ <span class="tile-number-unit" id="tl-g-u">{tUI.unitGroutMetric}</span>
75
+ </div>
76
+ </div>
77
+ <div class="tile-field">
78
+ <label class="tile-label" for="tl-w">{tUI.labelWaste}</label>
79
+ <div class="tile-number-row">
80
+ <input type="number" id="tl-w" value={INIT_W} min="0" max="50" step="1" class="tile-number-input" />
81
+ <span class="tile-number-unit">{tUI.unitPercent}</span>
82
+ </div>
83
+ </div>
84
+ <div class="tile-field">
85
+ <label class="tile-label" for="tl-tpb">{tUI.labelTilesPerBox}</label>
86
+ <div class="tile-number-row">
87
+ <input type="number" id="tl-tpb" value={INIT_TPB} min="1" step="1" class="tile-number-input" />
88
+ <span class="tile-number-unit">{tUI.unitBoxes}</span>
89
+ </div>
90
+ </div>
91
+ <div class="tile-field">
92
+ <label class="tile-label" for="tl-p">{tUI.labelPrice}</label>
93
+ <div class="tile-number-row">
94
+ <input type="number" id="tl-p" value={INIT_P} min="0" step="0.01" class="tile-price-input" />
95
+ <span class="tile-number-unit" id="tl-p-u">{tUI.unitPrice}</span>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ <div class="tile-right">
100
+ <div class="tile-visual-title">{tUI.visualTitle}</div>
101
+ <div class="tile-svg-wrap" id="tl-sw"><div class="tile-dim-label" id="tl-dl"></div></div>
102
+ <div class="tile-result-badge" id="tl-rb">{tUI.resultBadge}</div>
103
+ <div class="tile-stats">
104
+ <div class="tile-stat">
105
+ <p class="tile-stat-label">{tUI.labelArea}</p>
106
+ <p id="tl-a" class="tile-stat-value">{fmt1(INIT_R_W * INIT_R_L)} m²</p>
107
+ </div>
108
+ <div class="tile-stat-divider"></div>
109
+ <div class="tile-stat">
110
+ <p class="tile-stat-label">{tUI.labelTiles}</p>
111
+ <p id="tl-t" class="tile-stat-value">0</p>
112
+ </div>
113
+ <div class="tile-stat-divider"></div>
114
+ <div class="tile-stat">
115
+ <p class="tile-stat-label">{tUI.labelBoxes}</p>
116
+ <p id="tl-b" class="tile-stat-value">0</p>
117
+ </div>
118
+ <div class="tile-stat-divider"></div>
119
+ <div class="tile-stat">
120
+ <p class="tile-stat-label">{tUI.labelCost}</p>
121
+ <p id="tl-c" class="tile-stat-value">0</p>
122
+ </div>
123
+ </div>
124
+ <div class="tile-waste-row">
125
+ <p class="tile-waste-label">{tUI.labelWasteCount}</p>
126
+ <p id="tl-wc" class="tile-waste-value">0</p>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <script>
133
+ import { calculateTileLayout, fmt0, fmt2 } from './logic';
134
+ import type { TileLayoutResult } from './logic';
135
+
136
+ const LS_U = 'tile-layout-unit';
137
+ const LS_P = 'tile-layout-pattern';
138
+ const LS_V = 'tile-layout-values';
139
+ let unitSys: 'metric' | 'imperial' = 'metric';
140
+ let pattern: 'straight' | 'brick' | 'diagonal' = 'straight';
141
+
142
+ function el(id: string) { return document.getElementById(id); }
143
+ function setTxt(id: string, v: string) { const e = el(id); if (e) e.textContent = v; }
144
+ function getNum(id: string): number { const e = el(id) as HTMLInputElement | null; return e ? parseFloat(e.value) || 0 : 0; }
145
+ function ds(c: HTMLElement, k: string): string { return c.dataset[k] ?? ''; }
146
+
147
+ function factors() {
148
+ return unitSys === 'metric'
149
+ ? { rm: 1, tm: 0.01, gm: 0.001 }
150
+ : { rm: 0.3048, tm: 0.0254, gm: 0.0254 };
151
+ }
152
+
153
+ function makeSVG(ns: string, r: TileLayoutResult): SVGElement {
154
+ const svg = document.createElementNS(ns, 'svg');
155
+ svg.setAttribute('viewBox', `0 0 ${r.roomWidthM} ${r.roomLengthM}`); svg.setAttribute('class', 'tile-svg');
156
+ svg.style.width = '100%'; svg.style.height = '100%';
157
+ return svg;
158
+ }
159
+
160
+ function drawSimple(ns: string, svg: SVGElement, r: TileLayoutResult) {
161
+ const bg = document.createElementNS(ns, 'rect');
162
+ bg.setAttribute('x', '0'); bg.setAttribute('y', '0');
163
+ bg.setAttribute('width', String(r.roomWidthM)); bg.setAttribute('height', String(r.roomLengthM));
164
+ bg.setAttribute('class', 'tile-rect-primary'); svg.appendChild(bg);
165
+ const step = Math.max(r.tileWidthM, r.tileLengthM);
166
+ for (let x = 0; x < r.roomWidthM; x += step) {
167
+ const l = document.createElementNS(ns, 'line');
168
+ l.setAttribute('x1', String(x)); l.setAttribute('y1', '0');
169
+ l.setAttribute('x2', String(x)); l.setAttribute('y2', String(r.roomLengthM));
170
+ l.setAttribute('class', 'tile-grid-bg'); svg.appendChild(l);
171
+ }
172
+ for (let y = 0; y < r.roomLengthM; y += step) {
173
+ const l = document.createElementNS(ns, 'line');
174
+ l.setAttribute('x1', '0'); l.setAttribute('y1', String(y));
175
+ l.setAttribute('x2', String(r.roomWidthM)); l.setAttribute('y2', String(y));
176
+ l.setAttribute('class', 'tile-grid-bg'); svg.appendChild(l);
177
+ }
178
+ }
179
+
180
+ function drawTile(ns: string, r: TileLayoutResult, parent: SVGElement, cell: { c: number; ro: number; x: number; y: number }) {
181
+ const w = Math.max(0, Math.min(r.tileWidthM, r.roomWidthM - cell.x));
182
+ const h = Math.max(0, Math.min(r.tileLengthM, r.roomLengthM - cell.y));
183
+ if (w <= 0 || h <= 0) return;
184
+ const rect = document.createElementNS(ns, 'rect');
185
+ rect.setAttribute('x', String(cell.x)); rect.setAttribute('y', String(cell.y));
186
+ rect.setAttribute('width', String(w)); rect.setAttribute('height', String(h));
187
+ rect.setAttribute('rx', String(Math.max(r.groutM * 0.3, 0.002)));
188
+ rect.setAttribute('class', (cell.c + cell.ro) % 2 === 0 ? 'tile-rect-primary' : 'tile-rect-secondary');
189
+ parent.appendChild(rect);
190
+ }
191
+
192
+ function drawStraight(ns: string, svg: SVGElement, r: TileLayoutResult) {
193
+ for (let c = 0; c < r.cols; c++) {
194
+ for (let ro = 0; ro < r.rows; ro++) {
195
+ const x = c * (r.tileWidthM + r.groutM);
196
+ const y = ro * (r.tileLengthM + r.groutM);
197
+ drawTile(ns, r, svg, { c, ro, x, y });
198
+ }
199
+ }
200
+ }
201
+
202
+ function drawBrick(ns: string, svg: SVGElement, r: TileLayoutResult) {
203
+ const offset = (r.tileWidthM + r.groutM) / 2;
204
+ for (let ro = 0; ro < r.rows; ro++) {
205
+ const rowOffset = ro % 2 === 1 ? offset : 0;
206
+ if (rowOffset > 0) {
207
+ const halfW = r.tileWidthM - rowOffset;
208
+ const y = ro * (r.tileLengthM + r.groutM);
209
+ const h = Math.max(0, Math.min(r.tileLengthM, r.roomLengthM - y));
210
+ if (halfW > 0 && h > 0) {
211
+ const rect = document.createElementNS(ns, 'rect');
212
+ rect.setAttribute('x', '0');
213
+ rect.setAttribute('y', String(y));
214
+ rect.setAttribute('width', String(Math.min(halfW, r.roomWidthM)));
215
+ rect.setAttribute('height', String(h));
216
+ rect.setAttribute('rx', String(Math.max(r.groutM * 0.3, 0.002)));
217
+ rect.setAttribute('class', (-1 + ro) % 2 === 0 ? 'tile-rect-primary' : 'tile-rect-secondary');
218
+ svg.appendChild(rect);
219
+ }
220
+ }
221
+ for (let c = 0; c < r.cols; c++) {
222
+ const x = c * (r.tileWidthM + r.groutM) + rowOffset;
223
+ const y = ro * (r.tileLengthM + r.groutM);
224
+ drawTile(ns, r, svg, { c, ro, x, y });
225
+ }
226
+ }
227
+ }
228
+
229
+ function drawDiagonal(ns: string, svg: SVGElement, r: TileLayoutResult) {
230
+ const g = document.createElementNS(ns, 'g');
231
+ g.setAttribute('transform', `rotate(45 ${r.roomWidthM / 2} ${r.roomLengthM / 2})`);
232
+ for (let c = 0; c < r.cols; c++) {
233
+ for (let ro = 0; ro < r.rows; ro++) {
234
+ const x = c * (r.tileWidthM + r.groutM);
235
+ const y = ro * (r.tileLengthM + r.groutM);
236
+ drawTile(ns, r, g, { c, ro, x, y });
237
+ }
238
+ }
239
+ svg.appendChild(g);
240
+ }
241
+
242
+ function buildSVG(r: TileLayoutResult) {
243
+ const wrap = el('tl-sw');
244
+ if (!wrap) return;
245
+ const ns = 'http://www.w3.org/2000/svg';
246
+ const svg = makeSVG(ns, r);
247
+ const totalTiles = r.cols * r.rows;
248
+ if (totalTiles > 2000) {
249
+ drawSimple(ns, svg, r);
250
+ } else if (pattern === 'brick') {
251
+ drawBrick(ns, svg, r);
252
+ } else if (pattern === 'diagonal') {
253
+ drawDiagonal(ns, svg, r);
254
+ } else {
255
+ drawStraight(ns, svg, r);
256
+ }
257
+ const oldSvg = wrap.querySelector('svg');
258
+ if (oldSvg) oldSvg.remove();
259
+ wrap.appendChild(svg);
260
+ }
261
+
262
+ function setState(r: TileLayoutResult) {
263
+ const wrap = el('tl-sw');
264
+ if (!wrap) return;
265
+ wrap.classList.remove('tile-state-optimal', 'tile-state-warning');
266
+ const cutW = r.cols * r.tileWidthM + (r.cols - 1) * r.groutM;
267
+ const cutL = r.rows * r.tileLengthM + (r.rows - 1) * r.groutM;
268
+ const hasCut = cutW > r.roomWidthM + 0.01 || cutL > r.roomLengthM + 0.01;
269
+ if (r.wastePercent <= 5 && !hasCut) {
270
+ wrap.classList.add('tile-state-optimal');
271
+ } else if (r.wastePercent >= 20 || hasCut) {
272
+ wrap.classList.add('tile-state-warning');
273
+ }
274
+ }
275
+
276
+ function calculate(card: HTMLElement) {
277
+ const f = factors();
278
+ const r = calculateTileLayout({
279
+ roomWidthM: getNum('tl-rw') * f.rm,
280
+ roomLengthM: getNum('tl-rl') * f.rm,
281
+ tileWidthM: getNum('tl-tw') * f.tm,
282
+ tileLengthM: getNum('tl-tl') * f.tm,
283
+ groutM: getNum('tl-g') * f.gm,
284
+ wastePercent: getNum('tl-w'),
285
+ tilesPerBox: getNum('tl-tpb'),
286
+ pricePerBox: getNum('tl-p'),
287
+ });
288
+ const areaUnit = unitSys === 'metric' ? 'm²' : 'ft²';
289
+ const currencyCode = ds(card, 'currency') || 'USD';
290
+ const costFormatted = new Intl.NumberFormat(navigator.language, { style: 'currency', currency: currencyCode }).format(r.totalCost);
291
+ setTxt('tl-a', `${fmt2(r.roomArea)} ${areaUnit}`);
292
+ setTxt('tl-t', fmt0(r.totalTiles));
293
+ setTxt('tl-b', fmt0(r.boxes));
294
+ setTxt('tl-c', costFormatted);
295
+ setTxt('tl-wc', fmt0(r.wasteTiles));
296
+ buildSVG(r);
297
+ setTxt('tl-dl', `${r.cols} x ${r.rows}`);
298
+ setState(r);
299
+ }
300
+
301
+ function saveValues() {
302
+ const v = {
303
+ rw: getNum('tl-rw'),
304
+ rl: getNum('tl-rl'),
305
+ tw: getNum('tl-tw'),
306
+ tl: getNum('tl-tl'),
307
+ g: getNum('tl-g'),
308
+ w: getNum('tl-w'),
309
+ tpb: getNum('tl-tpb'),
310
+ p: getNum('tl-p'),
311
+ };
312
+ localStorage.setItem(LS_V, JSON.stringify(v));
313
+ }
314
+
315
+ function restoreValues() {
316
+ const s = localStorage.getItem(LS_V);
317
+ if (!s) return;
318
+ try {
319
+ const v = JSON.parse(s) as Record<string, number>;
320
+ const ids = ['tl-rw', 'tl-rl', 'tl-tw', 'tl-tl', 'tl-g', 'tl-w', 'tl-tpb', 'tl-p'];
321
+ ids.forEach((id) => {
322
+ const e = el(id) as HTMLInputElement | null;
323
+ if (e && v[id.replace('tl-', '')] !== undefined) e.value = String(v[id.replace('tl-', '')]);
324
+ });
325
+ } catch {}
326
+ }
327
+
328
+ function updateLabels(card: HTMLElement) {
329
+ const i = unitSys === 'imperial';
330
+ setTxt('tl-rw-u', i ? ds(card, 'uIr') : ds(card, 'uMr'));
331
+ setTxt('tl-rl-u', i ? ds(card, 'uIr') : ds(card, 'uMr'));
332
+ setTxt('tl-tw-u', i ? ds(card, 'uIt') : ds(card, 'uMt'));
333
+ setTxt('tl-tl-u', i ? ds(card, 'uIt') : ds(card, 'uMt'));
334
+ setTxt('tl-g-u', i ? ds(card, 'uIg') : ds(card, 'uMg'));
335
+ }
336
+
337
+ function applyUnit(s: 'metric' | 'imperial', card: HTMLElement) {
338
+ unitSys = s;
339
+ localStorage.setItem(LS_U, s);
340
+ document.querySelectorAll('.tile-unit-btn').forEach((b) => b.classList.toggle('tile-unit-active', (b as HTMLElement).dataset.unit === s));
341
+ const isM = s === 'metric';
342
+ function upd(id: string, factor: number) {
343
+ const e = el(id) as HTMLInputElement | null;
344
+ if (e) e.value = String(Math.round((getNum(id) * factor) * 10) / 10);
345
+ }
346
+ upd('tl-rw', isM ? 0.3048 : 1 / 0.3048);
347
+ upd('tl-rl', isM ? 0.3048 : 1 / 0.3048);
348
+ upd('tl-tw', isM ? 2.54 : 1 / 2.54);
349
+ upd('tl-tl', isM ? 2.54 : 1 / 2.54);
350
+ upd('tl-g', isM ? 25.4 : 1 / 25.4);
351
+ updateLabels(card);
352
+ calculate(card);
353
+ }
354
+
355
+ function applyPattern(p: 'straight' | 'brick' | 'diagonal', card: HTMLElement) {
356
+ pattern = p;
357
+ localStorage.setItem(LS_P, p);
358
+ document.querySelectorAll('.tile-pattern-btn').forEach((b) => b.classList.toggle('tile-pattern-active', (b as HTMLElement).dataset.pattern === p));
359
+ calculate(card);
360
+ }
361
+
362
+ function onClickParticle(ev: MouseEvent) {
363
+ for (let i = 0; i < 5; i++) {
364
+ const p = document.createElement('span');
365
+ p.className = 'tile-particle';
366
+ p.textContent = ['+', '~', '*'][Math.floor(Math.random() * 3)];
367
+ const a = Math.random() * 6.28;
368
+ const d = 20 + Math.random() * 30;
369
+ p.style.left = `${ev.clientX}px`;
370
+ p.style.top = `${ev.clientY}px`;
371
+ p.style.setProperty('--tx', `${Math.cos(a) * d}px`);
372
+ p.style.setProperty('--ty', `${Math.sin(a) * d}px`);
373
+ document.body.appendChild(p);
374
+ setTimeout(() => p.remove(), 800);
375
+ }
376
+ }
377
+
378
+ function bindInputs(card: HTMLElement) {
379
+ ['tl-rw', 'tl-rl', 'tl-tw', 'tl-tl', 'tl-g', 'tl-w', 'tl-tpb', 'tl-p'].forEach((id) => {
380
+ const e = el(id);
381
+ if (e) e.addEventListener('input', () => { saveValues(); calculate(card); });
382
+ });
383
+ document.querySelectorAll('.tile-unit-btn').forEach((btn) => {
384
+ btn.addEventListener('click', () => {
385
+ const s = (btn as HTMLElement).dataset.unit as 'metric' | 'imperial';
386
+ if (s) applyUnit(s, card);
387
+ });
388
+ });
389
+ document.querySelectorAll('.tile-pattern-btn').forEach((btn) => {
390
+ btn.addEventListener('click', () => {
391
+ const p = (btn as HTMLElement).dataset.pattern as 'straight' | 'brick' | 'diagonal';
392
+ if (p) applyPattern(p, card);
393
+ });
394
+ });
395
+ document.querySelectorAll('input, button').forEach((b) => b.addEventListener('click', onClickParticle as EventListener));
396
+ }
397
+
398
+ function init() {
399
+ const card = document.querySelector('.tile-card') as HTMLElement | null;
400
+ if (!card) return;
401
+ const saved = localStorage.getItem(LS_U) as 'metric' | 'imperial' | null;
402
+ if (saved) unitSys = saved;
403
+ const savedP = localStorage.getItem(LS_P) as 'straight' | 'brick' | 'diagonal' | null;
404
+ if (savedP) pattern = savedP;
405
+ document.querySelectorAll('.tile-unit-btn').forEach((b) => b.classList.toggle('tile-unit-active', (b as HTMLElement).dataset.unit === unitSys));
406
+ document.querySelectorAll('.tile-pattern-btn').forEach((b) => b.classList.toggle('tile-pattern-active', (b as HTMLElement).dataset.pattern === pattern));
407
+ restoreValues();
408
+ updateLabels(card);
409
+ bindInputs(card);
410
+ calculate(card);
411
+ }
412
+
413
+ document.addEventListener('astro:page-load', init);
414
+ init();
415
+ </script>
@@ -0,0 +1,29 @@
1
+ import type { HomeToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { TileLayoutCalculatorUI } from './ui';
3
+
4
+ export type TileLayoutCalculatorLocaleContent = ToolLocaleContent<TileLayoutCalculatorUI>;
5
+
6
+ export const tileLayoutCalculator: HomeToolEntry<TileLayoutCalculatorUI> = {
7
+ id: 'tile-layout-calculator',
8
+ icons: {
9
+ bg: 'mdi:floor-plan',
10
+ fg: 'mdi:checkerboard',
11
+ },
12
+ i18n: {
13
+ de: async () => (await import('./i18n/de')).content,
14
+ en: async () => (await import('./i18n/en')).content,
15
+ es: async () => (await import('./i18n/es')).content,
16
+ fr: async () => (await import('./i18n/fr')).content,
17
+ id: async () => (await import('./i18n/id')).content,
18
+ it: async () => (await import('./i18n/it')).content,
19
+ ja: async () => (await import('./i18n/ja')).content,
20
+ ko: async () => (await import('./i18n/ko')).content,
21
+ nl: async () => (await import('./i18n/nl')).content,
22
+ pl: async () => (await import('./i18n/pl')).content,
23
+ pt: async () => (await import('./i18n/pt')).content,
24
+ ru: async () => (await import('./i18n/ru')).content,
25
+ sv: async () => (await import('./i18n/sv')).content,
26
+ tr: async () => (await import('./i18n/tr')).content,
27
+ zh: async () => (await import('./i18n/zh')).content,
28
+ },
29
+ };
@@ -0,0 +1,208 @@
1
+ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import type { TileLayoutCalculatorUI } from '../ui';
4
+ import { bibliography } from '../bibliography';
5
+
6
+ const slug = 'fliesen-layout-und-verschnitt-rechner';
7
+ const title = 'Fliesenlayout und Verschnittrechner';
8
+ const description =
9
+ 'Plane deinen Raumboden millimetergenau. Berechne die exakte Fliesenmenge, die benötigte Kartonanzahl, die Gesamtkosten und visualisiere das Layout in Echtzeit mit einer interaktiven Raster-Vorschau.';
10
+
11
+ const faqData = [
12
+ {
13
+ question: 'Wie berechne ich, wie viele Fliesen ich für einen Raum brauche?',
14
+ answer:
15
+ 'Messe Raumbreite und -länge, wähle die Fliesengröße und gib die Fugenbreite ein. Der Rechner teilt die Raummaße durch Fliese plus Fuge und liefert die exakte Anzahl je Reihe und Spalte.',
16
+ },
17
+ {
18
+ question: 'Welchen Verschnittanteil sollte ich beim Fliesenkauf einplanen?',
19
+ answer:
20
+ 'Für standardmäßige rechteckige Räume rechnen Sie 10% Verschnitt. Bei Räumen mit vielen Ecken, diagonalen Verlegemustern oder komplexen Schnitten erhöhen Sie auf 15%. Das deckt Bruch und Schnittfehler ab.',
21
+ },
22
+ {
23
+ question: 'Beeinflusst die Fugenbreite wirklich die Fliesenanzahl?',
24
+ answer:
25
+ 'Ja. Breitere Fugen verringern die effektive Abdeckung jeder Fliese und erhöhen leicht den Gesamtbedarf. Der Rechner berücksichtigt das automatisch in jeder Reihe und Spalte.',
26
+ },
27
+ {
28
+ question: 'Sollte ich zusätzliche Kartons über den Verschnitt hinaus kaufen?',
29
+ answer:
30
+ 'Es ist ratsam, eine zusätzliche Karton zu erwerben, sofern verfügbar. Die Farbchargen können zwischen Produktionen variieren; passende Ersatzfliesen erleichtern spätere Reparaturen.',
31
+ },
32
+ {
33
+ question: 'Wie verlege ich Fliesen in einem unregelmäßig geformten Raum?',
34
+ answer:
35
+ 'Teilen Sie den Raum in kleinere Rechtecke. Berechnen Sie jeden Bereich einzeln und addieren Sie die Ergebnisse. Runden Sie Teilkartons immer auf, um vollständige Abdeckung zu gewährleisten.',
36
+ },
37
+ {
38
+ question: 'Was ist der Unterschied im Verschnitt zwischen Keramik und Feinsteinzeug?',
39
+ answer:
40
+ 'Feinsteinzeug ist dichter und schwerer zu schneiden, was den Verschnitt beim Schneiden um 2% bis 3% gegenüber Standardkeramik erhöhen kann. Beide Materialien sollten nach demselben Messverfahren geplant werden.',
41
+ },
42
+ {
43
+ question: 'Kann ich ungebrauchte Fliesenkartons zurückgeben?',
44
+ answer:
45
+ 'Die meisten Händler nehmen ungeöffnete Kartons innerhalb von 30 Tagen zurück. Bewahren Sie den Kassenbon auf und öffnen Sie Überschusskartons erst, wenn die Verlegung vollständig abgeschlossen ist.',
46
+ },
47
+ ];
48
+
49
+ const howToData = [
50
+ {
51
+ name: 'Raum vermessen',
52
+ text: 'Erfassen Sie Breite und Länge des Bodenbereichs in Ihrem bevorzugten Maßsystem.',
53
+ },
54
+ {
55
+ name: 'Fliese und Fuge wählen',
56
+ text: 'Wählen Sie Ihre Fliesenabmessungen und die gewünschte Fugenbreite.',
57
+ },
58
+ {
59
+ name: 'Berechnen und visualisieren',
60
+ text: 'Geben Sie die Werte in den Rechner ein, passen Sie den Verschnittanteil an und prüfen Sie die Live-Layout-Vorschau.',
61
+ },
62
+ ];
63
+
64
+ const faqSchema: WithContext<FAQPage> = {
65
+ '@context': 'https://schema.org',
66
+ '@type': 'FAQPage',
67
+ mainEntity: faqData.map((item) => ({
68
+ '@type': 'Question',
69
+ name: item.question,
70
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
71
+ })),
72
+ };
73
+
74
+ const howToSchema: WithContext<HowTo> = {
75
+ '@context': 'https://schema.org',
76
+ '@type': 'HowTo',
77
+ name: title,
78
+ description,
79
+ step: howToData.map((step) => ({
80
+ '@type': 'HowToStep',
81
+ name: step.name,
82
+ text: step.text,
83
+ })),
84
+ };
85
+
86
+ const appSchema: WithContext<SoftwareApplication> = {
87
+ '@context': 'https://schema.org',
88
+ '@type': 'SoftwareApplication',
89
+ name: title,
90
+ description,
91
+ applicationCategory: 'UtilityApplication',
92
+ operatingSystem: 'All',
93
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
94
+ inLanguage: 'de',
95
+ };
96
+
97
+ export const content: ToolLocaleContent<TileLayoutCalculatorUI> = {
98
+ slug,
99
+ title,
100
+ description,
101
+ faq: faqData,
102
+ bibliography,
103
+ howTo: howToData,
104
+ schemas: [faqSchema, howToSchema, appSchema],
105
+ seo: [
106
+ {
107
+ type: 'title',
108
+ text: 'Komplette Anleitung zur Fliesenplanung und Verschnittkontrolle',
109
+ level: 2,
110
+ },
111
+ {
112
+ type: 'paragraph',
113
+ html: 'Ein erfolgreiches Fliesenprojekt beginnt lange bevor der erste Kleber aufgetragen wird. Präzises Vermessen, intelligente Layoutplanung und genaue Verschnittberechnung entscheiden, ob Ihre Renovierung im Budget und Termin bleibt. <strong>Unser interaktiver Fliesenlayout-Rechner</strong> wandelt Rohmaße in einen visuellen Grundriss um und zeigt exakt, wie viele Fliesen pro Reihe passen, wie viele Kartons Sie benötigen und wo Ihr Geld hingeht.',
114
+ },
115
+ {
116
+ type: 'stats',
117
+ items: [
118
+ { value: '10-15%', label: 'Standardverschnitt', icon: 'mdi:alert-circle' },
119
+ { value: '30+ Jahre', label: 'Fliesenlebensdauer', icon: 'mdi:clock-outline' },
120
+ { value: '48h', label: 'Ø Verlegedauer', icon: 'mdi:calendar-check' },
121
+ ],
122
+ columns: 3,
123
+ },
124
+ {
125
+ type: 'comparative',
126
+ items: [
127
+ {
128
+ title: 'Großformatige Fliesen',
129
+ description: 'Fliesen über 60 cm sorgen für ein nahtloses, modernes Erscheinungsbild, erfordern aber perfekt ebene Untergründe und erzeugen in kleinen Räumen höheren Schnittverschnitt.',
130
+ icon: 'mdi:arrow-expand',
131
+ points: ['Weniger Fugen', 'Premium-Optik', 'Höherer Verschnitt in engen Räumen'],
132
+ },
133
+ {
134
+ title: 'Kleine Mosaikfliesen',
135
+ description: 'Ideal für Bäder und filigrane Muster. Sie passen sich unregelmäßigen Formen an, benötigen aber mehr Fugen und Arbeitszeit.',
136
+ icon: 'mdi:apps',
137
+ points: ['Flexibel bei Hindernissen', 'Mehr Fugen erforderlich', 'Längere Verlegezeit'],
138
+ },
139
+ ],
140
+ columns: 2,
141
+ },
142
+ {
143
+ type: 'title',
144
+ text: 'Warum die Layout-Visualisierung wichtig ist',
145
+ level: 3,
146
+ },
147
+ {
148
+ type: 'paragraph',
149
+ html: 'Die Fliesenansicht vor dem Kauf verhindert kostspielige Überraschungen. Die Live-Vorschau zeigt, ob Ihre gewählte Fliesengröße am Rand unangenehm schmale Streifen hinterlässt oder einen ausgewogenen, symmetrischen Boden ergibt. Durch Anpassung von Fugenbreite und Fliesenausrichtung im Rechner können Sie das Layout vor der Bestellung auf Ästhetik und Effizienz optimieren.',
150
+ },
151
+ {
152
+ type: 'diagnostic',
153
+ variant: 'info',
154
+ title: 'Schnelle Referenztabelle zur Abdeckung',
155
+ icon: 'mdi:table',
156
+ badge: 'Referenz',
157
+ html: '<ul style="margin:0;padding-left:1.2em"><li><strong>30 x 30 cm</strong> → 11 Fliesen pro m²</li><li><strong>45 x 45 cm</strong> → 5 Fliesen pro m²</li><li><strong>60 x 60 cm</strong> → 3 Fliesen pro m²</li><li><strong>60 x 120 cm</strong> → 1,5 Fliesen pro m²</li></ul>',
158
+ },
159
+ {
160
+ type: 'summary',
161
+ title: 'ProfTipps zur Verschnittminimierung',
162
+ items: [
163
+ 'Bestellen Sie 10% Extra für gerade Verlegemuster und 15% für Diagonal- oder Fischgrätenmuster.',
164
+ 'Bewahren Sie alle Kartons aus derselben Produktionscharge auf, um Farbgleichheit zu sichern.',
165
+ 'Messen Sie den Raum diagonal, um die Rechtwinkligkeit vor der Reihenplanung zu prüfen.',
166
+ 'Verwenden Sie Großformatfliesen nur in Räumen, die breiter sind als die Fliesenlänge, um übermäßige Schnitte zu vermeiden.',
167
+ 'Runden Sie Kartonmengen immer auf; das ist günstiger als eine zweite Lieferung.',
168
+ ],
169
+ },
170
+ ],
171
+ ui: {
172
+ sectionTitle: 'Raumkonfiguration',
173
+ labelRoomWidth: 'Raumbreite',
174
+ labelRoomLength: 'Raumlänge',
175
+ labelTileWidth: 'Fliesenbreite',
176
+ labelTileLength: 'Fliesenlänge',
177
+ labelGrout: 'Fugenbreite',
178
+ labelWaste: 'Verschnittanteil',
179
+ labelTilesPerBox: 'Fliesen pro Karton',
180
+ labelPrice: 'Preis pro Karton',
181
+ labelPattern: 'Verlegemuster',
182
+ unitMetricRoom: 'm',
183
+ unitImperialRoom: 'ft',
184
+ unitMetricTile: 'cm',
185
+ unitImperialTile: 'in',
186
+ unitGroutMetric: 'mm',
187
+ unitGroutImperial: 'in',
188
+ unitPercent: '%',
189
+ unitBoxes: 'Stk',
190
+ unitPrice: '/Karton',
191
+ resultBadge: 'Live-Layout-Vorschau',
192
+ labelArea: 'Bodenfläche',
193
+ labelTiles: 'Fliesen gesamt',
194
+ labelBoxes: 'Kartons benötigt',
195
+ labelCost: 'Gesamtkosten',
196
+ labelWasteCount: 'Verschnittfliesen',
197
+ labelCuts: 'Teilschnitte',
198
+ currency: 'EUR',
199
+ btnMetric: 'Metrisch',
200
+ btnImperial: 'Imperial',
201
+ btnPatternStraight: 'Gerade',
202
+ btnPatternBrick: 'Laufend',
203
+ btnPatternDiagonal: 'Diagonal',
204
+ badgeOptimal: 'Optimales Layout',
205
+ badgeWarning: 'Hoher Verschnitt',
206
+ visualTitle: 'Layout Vorschau',
207
+ },
208
+ };