@jjlmoya/utils-diy 1.32.0 → 1.33.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 (34) hide show
  1. package/package.json +2 -2
  2. package/scripts/validate-icons.mjs +57 -0
  3. package/src/category/index.ts +2 -1
  4. package/src/entries.ts +3 -1
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/ladderAngleReachCalculator/bibliography.astro +6 -0
  8. package/src/tool/ladderAngleReachCalculator/bibliography.ts +12 -0
  9. package/src/tool/ladderAngleReachCalculator/component.astro +68 -0
  10. package/src/tool/ladderAngleReachCalculator/controller.ts +163 -0
  11. package/src/tool/ladderAngleReachCalculator/entry.ts +24 -0
  12. package/src/tool/ladderAngleReachCalculator/i18n/de.ts +31 -0
  13. package/src/tool/ladderAngleReachCalculator/i18n/en.ts +98 -0
  14. package/src/tool/ladderAngleReachCalculator/i18n/es.ts +31 -0
  15. package/src/tool/ladderAngleReachCalculator/i18n/fr.ts +32 -0
  16. package/src/tool/ladderAngleReachCalculator/i18n/id.ts +32 -0
  17. package/src/tool/ladderAngleReachCalculator/i18n/it.ts +32 -0
  18. package/src/tool/ladderAngleReachCalculator/i18n/ja.ts +32 -0
  19. package/src/tool/ladderAngleReachCalculator/i18n/ko.ts +32 -0
  20. package/src/tool/ladderAngleReachCalculator/i18n/nl.ts +31 -0
  21. package/src/tool/ladderAngleReachCalculator/i18n/pl.ts +32 -0
  22. package/src/tool/ladderAngleReachCalculator/i18n/pt.ts +31 -0
  23. package/src/tool/ladderAngleReachCalculator/i18n/ru.ts +32 -0
  24. package/src/tool/ladderAngleReachCalculator/i18n/sv.ts +32 -0
  25. package/src/tool/ladderAngleReachCalculator/i18n/tr.ts +32 -0
  26. package/src/tool/ladderAngleReachCalculator/i18n/zh.ts +32 -0
  27. package/src/tool/ladderAngleReachCalculator/index.ts +11 -0
  28. package/src/tool/ladderAngleReachCalculator/ladder-angle-and-reach-calculator.css +491 -0
  29. package/src/tool/ladderAngleReachCalculator/locale.ts +57 -0
  30. package/src/tool/ladderAngleReachCalculator/logic.test.ts +44 -0
  31. package/src/tool/ladderAngleReachCalculator/logic.ts +64 -0
  32. package/src/tool/ladderAngleReachCalculator/seo.astro +15 -0
  33. package/src/tool/ladderAngleReachCalculator/ui.ts +91 -0
  34. package/src/tools.ts +2 -1
@@ -0,0 +1,491 @@
1
+ :root {
2
+ --la-ink: #202a27;
3
+ --la-muted: #68756e;
4
+ --la-paper: #f5f0e6;
5
+ --la-surface: #fffdf8;
6
+ --la-line: #d9d0c1;
7
+ --la-accent: #2e705a;
8
+ --la-accent-soft: #dceadd;
9
+ --la-ochre: #c9903d;
10
+ --la-danger: #a34d35;
11
+ }
12
+
13
+ .theme-dark {
14
+ --la-ink: #f4f0e7;
15
+ --la-muted: #bcc6bf;
16
+ --la-paper: #263630;
17
+ --la-surface: #31453a;
18
+ --la-line: #65786b;
19
+ --la-accent: #9bd0a7;
20
+ --la-accent-soft: #315340;
21
+ --la-ochre: #e0b76f;
22
+ --la-danger: #f0a07d;
23
+ }
24
+
25
+ .la-root {
26
+ max-width: 1120px;
27
+ margin: 0 auto;
28
+ color: var(--la-ink);
29
+ font: 1rem/1.4 Georgia, serif;
30
+ }
31
+
32
+ .la-card {
33
+ overflow: hidden;
34
+ border: 1px solid var(--la-line);
35
+ border-radius: 1.4rem;
36
+ padding: clamp(0.85rem, 2vw, 1.35rem);
37
+ background: var(--la-paper);
38
+ }
39
+
40
+ .la-intro {
41
+ display: flex;
42
+ align-items: start;
43
+ gap: 1rem;
44
+ margin-bottom: 1rem;
45
+ }
46
+
47
+ .la-intro-mark {
48
+ display: grid;
49
+ flex: 0 0 3.2rem;
50
+ place-items: center;
51
+ height: 3.2rem;
52
+ border-radius: 0.8rem;
53
+ background: var(--la-accent);
54
+ color: var(--la-paper);
55
+ font: 800 0.9rem system-ui, sans-serif;
56
+ }
57
+
58
+ .la-intro p {
59
+ max-width: 64ch;
60
+ margin: 0;
61
+ color: var(--la-muted);
62
+ line-height: 1.55;
63
+ }
64
+
65
+ .la-controls,
66
+ .la-result {
67
+ display: grid;
68
+ gap: 0.85rem;
69
+ }
70
+
71
+ .la-controls {
72
+ border-bottom: 1px solid var(--la-line);
73
+ padding-bottom: 1rem;
74
+ }
75
+
76
+ .la-controls-head,
77
+ .la-diagram-head,
78
+ .la-primary,
79
+ .la-verdict,
80
+ .la-reference {
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: space-between;
84
+ gap: 0.8rem;
85
+ }
86
+
87
+ .la-kicker {
88
+ display: block;
89
+ color: var(--la-accent);
90
+ font: 700 0.72rem/1.2 system-ui, sans-serif;
91
+ letter-spacing: 0.12em;
92
+ text-transform: uppercase;
93
+ }
94
+
95
+ .la-segmented {
96
+ display: inline-flex;
97
+ gap: 0.2rem;
98
+ border: 1px solid var(--la-line);
99
+ border-radius: 999px;
100
+ padding: 0.2rem;
101
+ background: var(--la-surface);
102
+ }
103
+
104
+ .la-segmented button,
105
+ .la-mode {
106
+ cursor: pointer;
107
+ border: 1px solid transparent;
108
+ color: var(--la-muted);
109
+ background: transparent;
110
+ font: 700 0.75rem system-ui, sans-serif;
111
+ }
112
+
113
+ .la-segmented button {
114
+ border-radius: 999px;
115
+ padding: 0.48rem 0.7rem;
116
+ }
117
+
118
+ .la-segmented button:hover,
119
+ .la-segmented button:focus-visible,
120
+ .la-mode:hover,
121
+ .la-mode:focus-visible {
122
+ border-color: var(--la-accent);
123
+ color: var(--la-accent);
124
+ }
125
+
126
+ .la-segmented button.la-active {
127
+ background: var(--la-accent);
128
+ color: var(--la-paper);
129
+ }
130
+
131
+ .la-modes {
132
+ display: grid;
133
+ grid-template-columns: repeat(3, 1fr);
134
+ gap: 0.5rem;
135
+ }
136
+
137
+ .la-mode {
138
+ display: grid;
139
+ gap: 0.25rem;
140
+ min-height: 4.2rem;
141
+ border-color: var(--la-line);
142
+ border-radius: 0.8rem;
143
+ padding: 0.7rem;
144
+ text-align: left;
145
+ }
146
+
147
+ .la-mode strong {
148
+ color: var(--la-ink);
149
+ font-size: 0.8rem;
150
+ }
151
+
152
+ .la-mode span {
153
+ color: var(--la-muted);
154
+ font-size: 0.7rem;
155
+ font-weight: 400;
156
+ line-height: 1.35;
157
+ }
158
+
159
+ .la-mode.la-active {
160
+ border-color: var(--la-accent);
161
+ background: var(--la-accent-soft);
162
+ }
163
+
164
+ .la-fields {
165
+ display: grid;
166
+ grid-template-columns: repeat(2, 1fr);
167
+ gap: 0.7rem;
168
+ }
169
+
170
+ .la-field {
171
+ display: grid;
172
+ gap: 0.3rem;
173
+ color: var(--la-muted);
174
+ }
175
+
176
+ .la-field label {
177
+ color: var(--la-ink);
178
+ font: 700 0.82rem system-ui, sans-serif;
179
+ }
180
+
181
+ .la-field small {
182
+ font: 0.7rem/1.35 system-ui, sans-serif;
183
+ }
184
+
185
+ .la-input {
186
+ display: flex;
187
+ align-items: center;
188
+ border: 1px solid var(--la-line);
189
+ border-radius: 0.6rem;
190
+ background: var(--la-surface);
191
+ }
192
+
193
+ .la-input:focus-within {
194
+ border-color: var(--la-accent);
195
+ box-shadow: 0 0 0 3px var(--la-accent-soft);
196
+ }
197
+
198
+ .la-input input {
199
+ min-width: 0;
200
+ width: 100%;
201
+ border: 0;
202
+ outline: 0;
203
+ padding: 0.75rem;
204
+ color: var(--la-ink);
205
+ background: transparent;
206
+ font: 700 1.15rem system-ui, sans-serif;
207
+ }
208
+
209
+ .la-input span {
210
+ padding-right: 0.75rem;
211
+ color: var(--la-muted);
212
+ font: 700 0.8rem system-ui, sans-serif;
213
+ }
214
+
215
+ .la-result {
216
+ padding-top: 1rem;
217
+ }
218
+
219
+ .la-primary {
220
+ align-items: end;
221
+ border-radius: 0.9rem;
222
+ padding: 0.9rem 1rem;
223
+ background: var(--la-accent);
224
+ color: var(--la-paper);
225
+ }
226
+
227
+ .la-primary .la-kicker {
228
+ color: var(--la-paper);
229
+ opacity: 0.8;
230
+ }
231
+
232
+ .la-primary strong:not(.la-derived-value) {
233
+ display: block;
234
+ margin-top: 0.2rem;
235
+ font: 700 1.2rem system-ui, sans-serif;
236
+ }
237
+
238
+ .la-primary em {
239
+ display: block;
240
+ margin-top: 0.2rem;
241
+ color: var(--la-paper);
242
+ font: normal 0.75rem/1.35 system-ui, sans-serif;
243
+ opacity: 0.85;
244
+ }
245
+
246
+ .la-derived-value {
247
+ white-space: nowrap;
248
+ font: 800 clamp(1.8rem, 5vw, 3.1rem)/1 system-ui, sans-serif;
249
+ }
250
+
251
+ .la-derived-value small {
252
+ font-size: 0.45em;
253
+ }
254
+
255
+ .la-status {
256
+ border-radius: 999px;
257
+ padding: 0.38rem 0.6rem;
258
+ background: var(--la-accent);
259
+ color: var(--la-paper);
260
+ font: 700 0.68rem system-ui, sans-serif;
261
+ }
262
+
263
+ .la-status[data-la-status='too-flat'],
264
+ .la-status[data-la-status='too-steep'] {
265
+ background: var(--la-ochre);
266
+ }
267
+
268
+ .la-scene {
269
+ overflow: hidden;
270
+ width: min(100%, 560px);
271
+ aspect-ratio: 1.55;
272
+ min-height: 0;
273
+ margin-inline: auto;
274
+ border: 1px solid var(--la-line);
275
+ border-radius: 0.9rem;
276
+ background: var(--la-surface);
277
+ }
278
+
279
+ .la-scene-svg {
280
+ display: block;
281
+ width: 100%;
282
+ height: 100%;
283
+ }
284
+
285
+ .la-wall {
286
+ stroke: var(--la-ochre);
287
+ stroke-width: 10;
288
+ opacity: 0.65;
289
+ }
290
+
291
+ .la-ground {
292
+ stroke: var(--la-ink);
293
+ stroke-width: 3;
294
+ }
295
+
296
+ .la-ladder,
297
+ .la-rail {
298
+ stroke: var(--la-accent);
299
+ stroke-width: 8;
300
+ }
301
+
302
+ .la-rail {
303
+ stroke-width: 3;
304
+ }
305
+
306
+ .la-target,
307
+ .la-dimension {
308
+ stroke: var(--la-danger);
309
+ stroke-width: 2;
310
+ stroke-dasharray: 5 4;
311
+ }
312
+
313
+ .la-target-dot {
314
+ fill: var(--la-danger);
315
+ }
316
+
317
+ .la-arc,
318
+ .la-guide {
319
+ fill: none;
320
+ stroke: var(--la-ochre);
321
+ stroke-width: 3;
322
+ }
323
+
324
+ .la-guide {
325
+ stroke-dasharray: 6 5;
326
+ opacity: 0.65;
327
+ }
328
+
329
+ .la-derived-box {
330
+ fill: var(--la-accent-soft);
331
+ stroke: var(--la-accent);
332
+ stroke-width: 1;
333
+ }
334
+
335
+ .la-svg-label {
336
+ fill: var(--la-ink);
337
+ font: 700 13px system-ui, sans-serif;
338
+ }
339
+
340
+ .la-svg-kicker {
341
+ fill: var(--la-accent);
342
+ font: 700 10px system-ui, sans-serif;
343
+ letter-spacing: 1px;
344
+ text-transform: uppercase;
345
+ }
346
+
347
+ .la-derived-label {
348
+ fill: var(--la-ink);
349
+ font: 700 13px system-ui, sans-serif;
350
+ }
351
+
352
+ .la-svg-derived-value {
353
+ fill: var(--la-ink);
354
+ font: 800 23px system-ui, sans-serif;
355
+ }
356
+
357
+ .la-metrics {
358
+ display: grid;
359
+ grid-template-columns: repeat(3, 1fr);
360
+ gap: 0.6rem;
361
+ }
362
+
363
+ .la-metric {
364
+ display: grid;
365
+ gap: 0.25rem;
366
+ border: 1px solid var(--la-line);
367
+ border-radius: 0.75rem;
368
+ padding: 0.75rem;
369
+ }
370
+
371
+ .la-metric-main {
372
+ border-color: var(--la-accent);
373
+ background: var(--la-accent);
374
+ color: var(--la-paper);
375
+ }
376
+
377
+ .la-metric > span {
378
+ font: 700 0.72rem system-ui, sans-serif;
379
+ }
380
+
381
+ .la-metric strong {
382
+ font: 700 clamp(1.25rem, 3vw, 2rem)/1 system-ui, sans-serif;
383
+ }
384
+
385
+ .la-metric small {
386
+ font-size: 0.55em;
387
+ }
388
+
389
+ .la-metric em {
390
+ color: var(--la-muted);
391
+ font: normal 0.72rem/1.35 system-ui, sans-serif;
392
+ }
393
+
394
+ .la-metric-main em {
395
+ color: var(--la-paper);
396
+ opacity: 0.85;
397
+ }
398
+
399
+ .la-verdict {
400
+ align-items: baseline;
401
+ flex-wrap: wrap;
402
+ border-top: 1px solid var(--la-line);
403
+ padding-top: 0.7rem;
404
+ font: 700 0.85rem system-ui, sans-serif;
405
+ }
406
+
407
+ .la-verdict span {
408
+ color: var(--la-muted);
409
+ font-weight: 400;
410
+ }
411
+
412
+ .la-reference {
413
+ justify-content: flex-start;
414
+ flex-wrap: wrap;
415
+ border-top: 1px solid var(--la-line);
416
+ padding-top: 0.65rem;
417
+ font: 700 0.8rem system-ui, sans-serif;
418
+ }
419
+
420
+ .la-reference strong {
421
+ color: var(--la-accent);
422
+ }
423
+
424
+ .la-reference em {
425
+ color: var(--la-muted);
426
+ font: normal 0.75rem system-ui, sans-serif;
427
+ }
428
+
429
+ .la-error {
430
+ margin: 0;
431
+ border-left: 3px solid var(--la-danger);
432
+ padding: 0.55rem 0.7rem;
433
+ color: var(--la-danger);
434
+ background: color-mix(in srgb, var(--la-danger) 12%, transparent);
435
+ font: 700 0.8rem system-ui, sans-serif;
436
+ }
437
+
438
+ .la-hidden {
439
+ visibility: hidden;
440
+ }
441
+
442
+ .la-notes {
443
+ display: grid;
444
+ gap: 0.45rem;
445
+ margin-top: 0.95rem;
446
+ color: var(--la-muted);
447
+ font: 0.78rem/1.5 system-ui, sans-serif;
448
+ }
449
+
450
+ .la-notes p {
451
+ margin: 0;
452
+ }
453
+
454
+ .la-warning {
455
+ color: var(--la-danger);
456
+ }
457
+
458
+ @media (max-width: 680px) {
459
+ .la-controls-head,
460
+ .la-diagram-head {
461
+ align-items: start;
462
+ flex-direction: column;
463
+ }
464
+
465
+ .la-segmented {
466
+ align-self: stretch;
467
+ }
468
+
469
+ .la-segmented button {
470
+ flex: 1;
471
+ }
472
+
473
+ .la-modes,
474
+ .la-fields,
475
+ .la-metrics {
476
+ grid-template-columns: 1fr;
477
+ }
478
+
479
+ .la-mode {
480
+ min-height: auto;
481
+ }
482
+
483
+ .la-primary {
484
+ align-items: start;
485
+ flex-direction: column;
486
+ }
487
+
488
+ .la-scene {
489
+ width: 100%;
490
+ }
491
+ }
@@ -0,0 +1,57 @@
1
+ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../types';
3
+ import type { LadderAngleReachUI } from './ui';
4
+ import { ladderAngleReachCalculatorBibliography } from './bibliography';
5
+ import { content as englishContent } from './i18n/en';
6
+
7
+ export interface LadderLocaleData {
8
+ language: string;
9
+ slug: string;
10
+ title: string;
11
+ description: string;
12
+ faqTitle: string;
13
+ faq: Array<{ question: string; answer: string }>;
14
+ howTo: Array<{ name: string; text: string }>;
15
+ seo: ToolLocaleContent['seo'];
16
+ ui: Partial<LadderAngleReachUI>;
17
+ }
18
+
19
+ // eslint-disable-next-line max-lines-per-function
20
+ export function createLadderLocale(data: LadderLocaleData): ToolLocaleContent<LadderAngleReachUI> {
21
+ const faqSchema: WithContext<FAQPage> = {
22
+ '@context': 'https://schema.org',
23
+ '@type': 'FAQPage',
24
+ mainEntity: data.faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
25
+ };
26
+ const howToSchema: WithContext<HowTo> = {
27
+ '@context': 'https://schema.org',
28
+ '@type': 'HowTo',
29
+ name: data.title,
30
+ description: data.description,
31
+ step: data.howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })),
32
+ };
33
+ const appSchema: WithContext<SoftwareApplication> = {
34
+ '@context': 'https://schema.org',
35
+ '@type': 'SoftwareApplication',
36
+ name: data.title,
37
+ description: data.description,
38
+ applicationCategory: 'UtilityApplication',
39
+ operatingSystem: 'All',
40
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
41
+ inLanguage: data.language,
42
+ };
43
+ return {
44
+ ...englishContent,
45
+ slug: data.slug,
46
+ title: data.title,
47
+ description: data.description,
48
+ faqTitle: data.faqTitle,
49
+ faq: data.faq,
50
+ bibliographyTitle: englishContent.bibliographyTitle,
51
+ bibliography: ladderAngleReachCalculatorBibliography,
52
+ howTo: data.howTo,
53
+ seo: data.seo,
54
+ schemas: [faqSchema, howToSchema, appSchema],
55
+ ui: { ...englishContent.ui, ...data.ui } as LadderAngleReachUI,
56
+ };
57
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateLadder, convertLength, GUIDE_ANGLE_DEGREES } from './logic';
3
+
4
+ describe('calculateLadder', () => {
5
+ it('calculates the base distance from target height and ladder length', () => {
6
+ const result = calculateLadder({ unitSystem: 'metric', mode: 'height-and-length', knownOne: 3, knownTwo: 4 });
7
+ expect(result.valid).toBe(true);
8
+ if (!result.valid) return;
9
+ expect(result.output.derivedField).toBe('base-distance');
10
+ expect(result.output.derivedValue).toBeCloseTo(Math.sqrt(4 ** 2 - 3 ** 2), 2);
11
+ expect(result.output.guideBaseDistance).toBeCloseTo(0.75, 2);
12
+ expect(result.output.requiredLengthAtGuideAngle).toBeCloseTo(Math.sqrt(3 ** 2 + 0.75 ** 2), 2);
13
+ expect(result.output.angle).toBeCloseTo(48.6, 1);
14
+ expect(result.output.reachesTarget).toBe(true);
15
+ });
16
+
17
+ it('calculates the minimum ladder length from height and base distance', () => {
18
+ const result = calculateLadder({ unitSystem: 'metric', mode: 'height-and-base', knownOne: 3, knownTwo: 0.78 });
19
+ expect(result.valid).toBe(true);
20
+ if (result.valid) {
21
+ expect(result.output.derivedField).toBe('ladder-length');
22
+ expect(result.output.derivedValue).toBeCloseTo(3.1, 1);
23
+ expect(result.output.angle).toBeCloseTo(GUIDE_ANGLE_DEGREES, 0);
24
+ }
25
+ });
26
+
27
+ it('calculates the reachable height from ladder length and base distance', () => {
28
+ const result = calculateLadder({ unitSystem: 'metric', mode: 'length-and-base', knownOne: 4, knownTwo: 1 });
29
+ expect(result.valid).toBe(true);
30
+ if (result.valid) expect(result.output.derivedValue).toBeCloseTo(Math.sqrt(15), 5);
31
+ });
32
+
33
+ it('rejects empty measurements and an impossible triangle', () => {
34
+ expect(calculateLadder({ unitSystem: 'metric', mode: 'height-and-length', knownOne: 0, knownTwo: 4 })).toEqual({ valid: false, error: 'positive-values' });
35
+ expect(calculateLadder({ unitSystem: 'metric', mode: 'height-and-length', knownOne: 4, knownTwo: 3 })).toEqual({ valid: false, error: 'target-too-high' });
36
+ expect(calculateLadder({ unitSystem: 'metric', mode: 'length-and-base', knownOne: 2, knownTwo: 2 })).toEqual({ valid: false, error: 'base-too-far' });
37
+ });
38
+ });
39
+
40
+ describe('length conversion', () => {
41
+ it('round-trips metric and imperial lengths', () => {
42
+ expect(convertLength(convertLength(4, 'metric', 'imperial'), 'imperial', 'metric')).toBeCloseTo(4);
43
+ });
44
+ });
@@ -0,0 +1,64 @@
1
+ import type { LadderCalculation, LadderInput, LadderOutput } from './ui';
2
+
3
+ export const GUIDE_ANGLE_DEGREES = 75.5;
4
+ export const GUIDE_ANGLE_TOLERANCE_DEGREES = 5.5;
5
+
6
+ export function convertLength(value: number, from: 'metric' | 'imperial', to: 'metric' | 'imperial'): number {
7
+ if (from === to) return value;
8
+ return from === 'metric' ? value * 3.280839895 : value / 3.280839895;
9
+ }
10
+
11
+ export function calculateLadder(input: LadderInput): LadderCalculation {
12
+ if (!arePositiveMeasurements(input.knownOne, input.knownTwo)) return { valid: false, error: 'positive-values' };
13
+ if (input.mode === 'height-and-length' && input.knownOne >= input.knownTwo) return { valid: false, error: 'target-too-high' };
14
+ const values = resolveKnownValues(input);
15
+ if (values.baseDistance >= values.ladderLength) return { valid: false, error: 'base-too-far' };
16
+
17
+ const angle = Math.acos(values.baseDistance / values.ladderLength) * (180 / Math.PI);
18
+ const verticalReach = Math.sqrt(Math.max(0, values.ladderLength ** 2 - values.baseDistance ** 2));
19
+ const guideBaseDistance = values.targetHeight / 4;
20
+ const requiredLengthAtGuideAngle = Math.sqrt(values.targetHeight ** 2 + guideBaseDistance ** 2);
21
+ return {
22
+ valid: true,
23
+ output: {
24
+ ...values,
25
+ angle,
26
+ verticalReach,
27
+ guideBaseDistance,
28
+ requiredLengthAtGuideAngle,
29
+ extensionAboveTarget: Math.max(0, verticalReach - values.targetHeight),
30
+ angleStatus: getAngleStatus(angle),
31
+ reachesTarget: verticalReach >= values.targetHeight,
32
+ },
33
+ };
34
+ }
35
+
36
+ function resolveKnownValues(input: LadderInput): Pick<LadderOutput, 'ladderLength' | 'targetHeight' | 'baseDistance' | 'derivedValue' | 'derivedField'> {
37
+ if (input.mode === 'height-and-length') {
38
+ const targetHeight = input.knownOne;
39
+ const ladderLength = input.knownTwo;
40
+ const baseDistance = Math.sqrt(ladderLength ** 2 - targetHeight ** 2);
41
+ return { ladderLength, targetHeight, baseDistance, derivedValue: baseDistance, derivedField: 'base-distance' };
42
+ }
43
+ if (input.mode === 'height-and-base') {
44
+ const targetHeight = input.knownOne;
45
+ const baseDistance = input.knownTwo;
46
+ const ladderLength = Math.sqrt(targetHeight ** 2 + baseDistance ** 2);
47
+ return { ladderLength, targetHeight, baseDistance, derivedValue: ladderLength, derivedField: 'ladder-length' };
48
+ }
49
+ const ladderLength = input.knownOne;
50
+ const baseDistance = input.knownTwo;
51
+ const targetHeight = Math.sqrt(Math.max(0, ladderLength ** 2 - baseDistance ** 2));
52
+ return { ladderLength, targetHeight, baseDistance, derivedValue: targetHeight, derivedField: 'target-height' };
53
+ }
54
+
55
+ function arePositiveMeasurements(...values: number[]): boolean {
56
+ return values.every((value) => Number.isFinite(value) && value > 0);
57
+ }
58
+
59
+ function getAngleStatus(angle: number): LadderOutput['angleStatus'] {
60
+ const difference = angle - GUIDE_ANGLE_DEGREES;
61
+ if (difference < -GUIDE_ANGLE_TOLERANCE_DEGREES) return 'too-flat';
62
+ if (difference > GUIDE_ANGLE_TOLERANCE_DEGREES) return 'too-steep';
63
+ return 'within-guide';
64
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { ladderAngleReachCalculator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const loader = ladderAngleReachCalculator.i18n[locale as KnownLocale] ?? ladderAngleReachCalculator.i18n.en;
12
+ const content = loader ? await loader() : null;
13
+ ---
14
+
15
+ {content?.seo && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}