@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-diy",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "postinstall": "node scripts/postinstall.mjs",
37
37
  "predev": "node scripts/postinstall.mjs",
38
38
  "prestart": "node scripts/postinstall.mjs",
39
- "prebuild": "node scripts/postinstall.mjs",
39
+ "prebuild": "node scripts/postinstall.mjs && node scripts/validate-icons.mjs",
40
40
  "qa": "npm run lint && npm run test && npm run build",
41
41
  "cf:dry-run": "npm run build && wrangler deploy --dry-run",
42
42
  "cf:preview": "npm run build && wrangler deploy --config wrangler.staging.jsonc",
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from 'node:module';
4
+ import { readFileSync, readdirSync } from 'node:fs';
5
+ import { dirname, join, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const packageJsonPath = require.resolve('@iconify-json/mdi/package.json');
10
+ const packageRoot = dirname(packageJsonPath);
11
+ const iconSet = JSON.parse(readFileSync(join(packageRoot, 'icons.json'), 'utf8'));
12
+ const availableIcons = new Set([
13
+ ...Object.keys(iconSet.icons ?? {}),
14
+ ...Object.keys(iconSet.aliases ?? {}),
15
+ ]);
16
+
17
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const sourceRoot = resolve(repoRoot, 'src');
19
+ const extensions = new Set(['.astro', '.js', '.mjs', '.ts', '.tsx']);
20
+ const iconPattern = /\bmdi:([a-z0-9-]+)\b/g;
21
+ const failures = [];
22
+ let references = 0;
23
+
24
+ function walk(directory) {
25
+ const entries = readdirSync(directory, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const path = join(directory, entry.name);
28
+ if (entry.isDirectory()) {
29
+ if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== 'tests') {
30
+ walk(path);
31
+ }
32
+ continue;
33
+ }
34
+ if (!extensions.has(path.slice(path.lastIndexOf('.')))) continue;
35
+
36
+ const source = readFileSync(path, 'utf8');
37
+ for (const match of source.matchAll(iconPattern)) {
38
+ references += 1;
39
+ const iconName = match[1];
40
+ if (availableIcons.has(iconName)) continue;
41
+
42
+ const line = source.slice(0, match.index).split('\n').length;
43
+ failures.push(`${relative(repoRoot, path)}:${line} — mdi:${iconName}`);
44
+ }
45
+ }
46
+ }
47
+
48
+ walk(sourceRoot);
49
+
50
+ if (failures.length > 0) {
51
+ console.error('Invalid MDI icons found:');
52
+ for (const failure of failures) console.error(`- ${failure}`);
53
+ console.error(`Checked ${references} MDI icon references against @iconify-json/mdi.`);
54
+ process.exitCode = 1;
55
+ } else {
56
+ console.log(`MDI icon validation passed: ${references} references checked.`);
57
+ }
@@ -14,10 +14,11 @@ import { stairCalculator } from '../tool/stairCalculator/entry';
14
14
  import { drillSharpener } from '../tool/drillSharpener/entry';
15
15
  import { workshopFractionConverter } from '../tool/workshopFractionConverter/entry';
16
16
  import { excavationVolumeCalculator } from '../tool/excavationVolumeCalculator/entry';
17
+ import { ladderAngleReachCalculator } from '../tool/ladderAngleReachCalculator/entry';
17
18
 
18
19
  export const diyCategory: DiyCategoryEntry = {
19
20
  icon: 'mdi:hand',
20
- tools: [clayCalculator, epoxyCalculator, balusterCalculator, mortarCalculator, passepartoutCalculator, concreteCalculator, cutOptimizer, voltageDropCalculator, furnitureFit, thermalExpansionCalculator, drillCalculator, stairCalculator, drillSharpener, workshopFractionConverter, excavationVolumeCalculator],
21
+ tools: [clayCalculator, epoxyCalculator, balusterCalculator, mortarCalculator, passepartoutCalculator, concreteCalculator, cutOptimizer, voltageDropCalculator, furnitureFit, thermalExpansionCalculator, drillCalculator, stairCalculator, drillSharpener, workshopFractionConverter, excavationVolumeCalculator, ladderAngleReachCalculator],
21
22
  i18n: {
22
23
  de: () => import('./i18n/de').then((m) => m.content),
23
24
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -21,6 +21,7 @@ export { drywallCalculatorEntry } from './tool/drywallCalculator/entry';
21
21
  export type { DrywallCalculatorLocaleContent } from './tool/drywallCalculator/entry';
22
22
  export { excavationVolumeCalculator } from './tool/excavationVolumeCalculator/entry';
23
23
  export type { ExcavationVolumeCalculatorLocaleContent } from './tool/excavationVolumeCalculator/entry';
24
+ export { ladderAngleReachCalculator } from './tool/ladderAngleReachCalculator/entry';
24
25
  export { diyCategory } from './category';
25
26
  import { balusterCalculator } from './tool/balusterCalculator/entry';
26
27
  import { clayCalculator } from './tool/clayCalculator/entry';
@@ -30,6 +31,7 @@ import { drillCalculator } from './tool/drillCalculator/entry';
30
31
  import { drillSharpener } from './tool/drillSharpener/entry';
31
32
  import { drywallCalculatorEntry } from './tool/drywallCalculator/entry';
32
33
  import { excavationVolumeCalculator } from './tool/excavationVolumeCalculator/entry';
34
+ import { ladderAngleReachCalculator } from './tool/ladderAngleReachCalculator/entry';
33
35
  import { epoxyCalculator } from './tool/epoxyCalculator/entry';
34
36
  import { furnitureFit } from './tool/furnitureFit/entry';
35
37
  import { mortarCalculator } from './tool/mortarCalculator/entry';
@@ -40,4 +42,4 @@ import { voltageDropCalculator } from './tool/voltageDropCalculator/entry';
40
42
  import { workshopFractionConverter } from './tool/workshopFractionConverter/entry';
41
43
  import { pythagoreanRightAngleCalculator } from './tool/pythagoreanRightAngleCalculator/entry';
42
44
  import { twoStrokeMixtureCalculator } from './tool/twoStrokeMixtureCalculator/entry';
43
- export const ALL_ENTRIES = [balusterCalculator, clayCalculator, concreteCalculator, cutOptimizer, drillCalculator, drillSharpener, drywallCalculatorEntry, epoxyCalculator, furnitureFit, mortarCalculator, passepartoutCalculator, stairCalculator, thermalExpansionCalculator, voltageDropCalculator, workshopFractionConverter, pythagoreanRightAngleCalculator, twoStrokeMixtureCalculator, excavationVolumeCalculator];
45
+ export const ALL_ENTRIES = [balusterCalculator, clayCalculator, concreteCalculator, cutOptimizer, drillCalculator, drillSharpener, drywallCalculatorEntry, epoxyCalculator, furnitureFit, mortarCalculator, passepartoutCalculator, stairCalculator, thermalExpansionCalculator, voltageDropCalculator, workshopFractionConverter, pythagoreanRightAngleCalculator, twoStrokeMixtureCalculator, excavationVolumeCalculator, ladderAngleReachCalculator];
@@ -33,7 +33,7 @@ describe('Locale Completeness Validation', () => {
33
33
  });
34
34
  });
35
35
 
36
- it('all 18 tools registered', () => {
37
- expect(ALL_TOOLS.length).toBe(18);
36
+ it('all 19 tools registered', () => {
37
+ expect(ALL_TOOLS.length).toBe(19);
38
38
  });
39
39
  });
@@ -4,8 +4,8 @@ import { diyCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 18 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(18);
7
+ it('should have 19 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(19);
9
9
  });
10
10
 
11
11
  it('diyCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { ladderAngleReachCalculatorBibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={ladderAngleReachCalculatorBibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const ladderAngleReachCalculatorBibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'HSE: Safe use of ladders and stepladders',
6
+ url: 'https://www.hse.gov.uk/work-at-height/ladders/types-of-ladder.htm',
7
+ },
8
+ {
9
+ name: 'OSHA: Ladders, 29 CFR 1926.1053',
10
+ url: 'https://www.osha.gov/laws-regs/regulations/standardnumber/1926/1926.1053',
11
+ },
12
+ ];
@@ -0,0 +1,68 @@
1
+ ---
2
+ import './ladder-angle-and-reach-calculator.css';
3
+ import type { LadderAngleReachUI } from './ui';
4
+
5
+ interface Props {
6
+ ui?: Record<string, unknown>;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ const t = (ui ?? {}) as LadderAngleReachUI;
11
+ ---
12
+
13
+ <div class="la-root" data-la-root data-la-ui={JSON.stringify(t)}>
14
+ <div class="la-card">
15
+ <div class="la-intro">
16
+ <span class="la-intro-mark" aria-hidden="true">2→1</span>
17
+ <p>{t.intro}</p>
18
+ </div>
19
+
20
+ <section class="la-controls" aria-labelledby="la-solve-title">
21
+ <div class="la-controls-head">
22
+ <span class="la-kicker" id="la-solve-title">{t.solveTitle}</span>
23
+ <div class="la-segmented" role="group" aria-label={t.unitSystemLabel}>
24
+ <button type="button" class="la-active" data-la-unit="metric" aria-pressed="true">{t.unitMetric}</button>
25
+ <button type="button" data-la-unit="imperial" aria-pressed="false">{t.unitImperial}</button>
26
+ </div>
27
+ </div>
28
+ <div class="la-modes" role="group" aria-label={t.solveTitle}>
29
+ <button type="button" class="la-mode la-active" data-la-mode="height-and-length" aria-pressed="true"><strong>{t.modeHeightLength}</strong><span>{t.modeHeightLengthHint}</span></button>
30
+ <button type="button" class="la-mode" data-la-mode="height-and-base" aria-pressed="false"><strong>{t.modeHeightBase}</strong><span>{t.modeHeightBaseHint}</span></button>
31
+ <button type="button" class="la-mode" data-la-mode="length-and-base" aria-pressed="false"><strong>{t.modeLengthBase}</strong><span>{t.modeLengthBaseHint}</span></button>
32
+ </div>
33
+ <div class="la-fields">
34
+ <div class="la-field"><label for="la-known-one" data-la-field-one-label>{t.fieldHeight}</label><div class="la-input"><input id="la-known-one" data-la-input="knownOne" type="number" min="0.01" step="0.01" value="3" inputmode="decimal" /><span data-la-length-unit>m</span></div><small data-la-field-one-help>{t.fieldHeightHelp}</small></div>
35
+ <div class="la-field"><label for="la-known-two" data-la-field-two-label>{t.fieldLength}</label><div class="la-input"><input id="la-known-two" data-la-input="knownTwo" type="number" min="0.01" step="0.01" value="4" inputmode="decimal" /><span data-la-length-unit>m</span></div><small data-la-field-two-help>{t.fieldLengthHelp}</small></div>
36
+ </div>
37
+ <p class="la-error" data-la-error role="alert" hidden></p>
38
+ </section>
39
+
40
+ <section class="la-result" aria-labelledby="la-calculated-title">
41
+ <div class="la-primary">
42
+ <div><span class="la-kicker" id="la-calculated-title">{t.calculatedTitle}</span><strong><span data-la-derived-label>{t.calculatedBase}</span></strong><em data-la-derived-help>{t.calculatedBaseHelp}</em></div>
43
+ <strong class="la-derived-value"><span data-la-derived>0.78</span> <small data-la-length-unit>m</small></strong>
44
+ </div>
45
+ <div class="la-diagram-head"><span class="la-kicker">{t.diagramTitle}</span><span class="la-status" data-la-angle-status>{t.angleWithinGuide}</span></div>
46
+ <div class="la-scene" data-la-scene aria-label={t.angleGuide}></div>
47
+ <div class="la-metrics">
48
+ <div class="la-metric la-metric-main"><span>{t.angleTitle}</span><strong><span data-la-angle>75.5</span>{t.angleUnit}</strong><em data-la-angle-help>{t.angleWithinGuide}</em></div>
49
+ <div class="la-metric"><span>{t.reachTitle}</span><strong><span data-la-reach>3.87</span> <small data-la-length-unit>m</small></strong><em>{t.reachHelp}</em></div>
50
+ <div class="la-metric"><span>{t.targetTitle}</span><strong><span data-la-target>3.00</span> <small data-la-length-unit>m</small></strong><em>{t.targetHelp}</em></div>
51
+ </div>
52
+ <div class="la-verdict" data-la-verdict-wrap aria-live="polite"><strong data-la-verdict>{t.verdictReach}</strong><span data-la-verdict-help>{t.verdictReachHelp}</span></div>
53
+ <div class="la-reference"><span>{t.guideDistanceTitle}</span><strong><span data-la-guide-base>0.78</span> <small data-la-length-unit>m</small></strong><em>{t.guideDistanceHelp}</em></div>
54
+ <div class="la-reference"><span>{t.requiredLengthTitle}</span><strong><span data-la-required>3.10</span> <small data-la-length-unit>m</small></strong><em>{t.requiredLengthHelp}</em></div>
55
+ </section>
56
+
57
+ <p class="la-warning">{t.warning}</p>
58
+ </div>
59
+ </div>
60
+
61
+ <script>
62
+ import { mountLadderAngleReachCalculator } from './controller';
63
+ import type { LadderAngleReachUI } from './ui';
64
+
65
+ const root = document.querySelector('[data-la-root]');
66
+ const ui = JSON.parse(root?.getAttribute('data-la-ui') || '{}') as LadderAngleReachUI;
67
+ if (root) mountLadderAngleReachCalculator(root, ui);
68
+ </script>
@@ -0,0 +1,163 @@
1
+ import { calculateLadder, convertLength } from './logic';
2
+ import type { LadderAngleReachUI, LadderInput, LadderOutput, LadderSolveMode, LadderUnitSystem } from './ui';
3
+
4
+ const initialState: LadderInput = { unitSystem: 'metric', mode: 'height-and-length', knownOne: 3, knownTwo: 4 };
5
+
6
+ export function mountLadderAngleReachCalculator(root: Element, ui: LadderAngleReachUI): void {
7
+ const state = { ...initialState };
8
+ bindModes(root, state, ui);
9
+ bindUnits(root, state, ui);
10
+ bindInputs(root, state, ui);
11
+ updateControls(root, state, ui);
12
+ render(root, state, ui);
13
+ }
14
+
15
+ function bindModes(root: Element, state: LadderInput, ui: LadderAngleReachUI): void {
16
+ root.querySelectorAll('[data-la-mode]').forEach((button) => button.addEventListener('click', () => {
17
+ const next = (button as HTMLElement).dataset.laMode as LadderSolveMode;
18
+ if (next === state.mode) return;
19
+ const current = calculateLadder(state);
20
+ if (current.valid) Object.assign(state, applyOutputToMode(state, current.output, next));
21
+ else state.mode = next;
22
+ updateControls(root, state, ui);
23
+ render(root, state, ui);
24
+ }));
25
+ }
26
+
27
+ function applyOutputToMode(state: LadderInput, output: LadderOutput, mode: LadderSolveMode): LadderInput {
28
+ if (mode === 'height-and-length') return { ...state, mode, knownOne: output.targetHeight, knownTwo: output.ladderLength };
29
+ if (mode === 'height-and-base') return { ...state, mode, knownOne: output.targetHeight, knownTwo: output.baseDistance };
30
+ return { ...state, mode, knownOne: output.ladderLength, knownTwo: output.baseDistance };
31
+ }
32
+
33
+ function bindUnits(root: Element, state: LadderInput, ui: LadderAngleReachUI): void {
34
+ root.querySelectorAll('[data-la-unit]').forEach((button) => button.addEventListener('click', () => {
35
+ const next = (button as HTMLElement).dataset.laUnit as LadderUnitSystem;
36
+ if (next === state.unitSystem) return;
37
+ state.knownOne = convertLength(state.knownOne, state.unitSystem, next);
38
+ state.knownTwo = convertLength(state.knownTwo, state.unitSystem, next);
39
+ state.unitSystem = next;
40
+ updateControls(root, state, ui);
41
+ render(root, state, ui);
42
+ }));
43
+ }
44
+
45
+ function bindInputs(root: Element, state: LadderInput, ui: LadderAngleReachUI): void {
46
+ root.querySelectorAll<HTMLInputElement>('[data-la-input]').forEach((input) => input.addEventListener('input', () => {
47
+ const key = input.dataset.laInput as 'knownOne' | 'knownTwo';
48
+ state[key] = Number(input.value);
49
+ render(root, state, ui);
50
+ }));
51
+ }
52
+
53
+ function updateControls(root: Element, state: LadderInput, ui: LadderAngleReachUI): void {
54
+ root.querySelectorAll('[data-la-mode]').forEach((button) => {
55
+ const element = button as HTMLElement;
56
+ const active = element.dataset.laMode === state.mode;
57
+ element.classList.toggle('la-active', active);
58
+ element.setAttribute('aria-pressed', String(active));
59
+ });
60
+ root.querySelectorAll('[data-la-unit]').forEach((button) => {
61
+ const element = button as HTMLElement;
62
+ const active = element.dataset.laUnit === state.unitSystem;
63
+ element.classList.toggle('la-active', active);
64
+ element.setAttribute('aria-pressed', String(active));
65
+ });
66
+ const labels = getModeLabels(state.mode, ui);
67
+ setText(root, '[data-la-field-one-label]', labels.one.label);
68
+ setText(root, '[data-la-field-two-label]', labels.two.label);
69
+ setText(root, '[data-la-field-one-help]', labels.one.help);
70
+ setText(root, '[data-la-field-two-help]', labels.two.help);
71
+ root.querySelectorAll<HTMLInputElement>('[data-la-input]').forEach((input) => {
72
+ const key = input.dataset.laInput as 'knownOne' | 'knownTwo';
73
+ input.value = Number.isFinite(state[key]) ? formatNumber(state[key]) : '';
74
+ });
75
+ root.querySelectorAll('[data-la-length-unit]').forEach((element) => { element.textContent = state.unitSystem === 'metric' ? ui.unitLengthMetric : ui.unitLengthImperial; });
76
+ }
77
+
78
+ function getModeLabels(mode: LadderSolveMode, ui: LadderAngleReachUI): { one: { label: string; help: string }; two: { label: string; help: string } } {
79
+ if (mode === 'height-and-length') return { one: { label: ui.fieldHeight, help: ui.fieldHeightHelp }, two: { label: ui.fieldLength, help: ui.fieldLengthHelp } };
80
+ if (mode === 'height-and-base') return { one: { label: ui.fieldHeight, help: ui.fieldHeightHelp }, two: { label: ui.fieldBase, help: ui.fieldBaseHelp } };
81
+ return { one: { label: ui.fieldLength, help: ui.fieldLengthHelp }, two: { label: ui.fieldBase, help: ui.fieldBaseHelp } };
82
+ }
83
+
84
+ function render(root: Element, state: LadderInput, ui: LadderAngleReachUI): void {
85
+ const result = calculateLadder(state);
86
+ const error = root.querySelector<HTMLElement>('[data-la-error]');
87
+ const resultWrap = root.querySelector('[data-la-verdict-wrap]');
88
+ if (!error || !resultWrap) return;
89
+ if (!result.valid) {
90
+ error.textContent = getErrorCopy(result.error, ui);
91
+ error.hidden = false;
92
+ resultWrap.classList.add('la-hidden');
93
+ return;
94
+ }
95
+ error.hidden = true;
96
+ resultWrap.classList.remove('la-hidden');
97
+ renderOutput(root, result.output, ui, state.unitSystem);
98
+ }
99
+
100
+ function getErrorCopy(error: 'positive-values' | 'base-too-far' | 'target-too-high', ui: LadderAngleReachUI): string {
101
+ if (error === 'base-too-far') return ui.invalidBase;
102
+ if (error === 'target-too-high') return ui.invalidTarget;
103
+ return ui.invalidPositive;
104
+ }
105
+
106
+ function renderOutput(root: Element, output: LadderOutput, ui: LadderAngleReachUI, unitSystem: LadderUnitSystem): void {
107
+ const calculated = getCalculatedCopy(output.derivedField, ui);
108
+ setText(root, '[data-la-derived-label]', calculated.label);
109
+ setText(root, '[data-la-derived-help]', calculated.help);
110
+ setText(root, '[data-la-derived]', formatNumber(output.derivedValue));
111
+ setText(root, '[data-la-angle]', formatNumber(output.angle, 1));
112
+ setText(root, '[data-la-reach]', formatNumber(output.verticalReach));
113
+ setText(root, '[data-la-target]', formatNumber(output.targetHeight));
114
+ setText(root, '[data-la-guide-base]', formatNumber(output.guideBaseDistance));
115
+ setText(root, '[data-la-required]', formatNumber(output.requiredLengthAtGuideAngle));
116
+ const angleStatus = root.querySelector('[data-la-angle-status]');
117
+ if (angleStatus) {
118
+ const angleStatusText = getAngleStatusText(output.angleStatus, ui);
119
+ angleStatus.textContent = angleStatusText;
120
+ angleStatus.setAttribute('data-la-status', output.angleStatus);
121
+ setText(root, '[data-la-angle-help]', angleStatusText);
122
+ }
123
+ setText(root, '[data-la-verdict]', output.reachesTarget ? ui.verdictReach : ui.verdictShort);
124
+ setText(root, '[data-la-verdict-help]', output.reachesTarget ? ui.verdictReachHelp : ui.verdictShortHelp);
125
+ renderScene(root, output, ui, unitSystem);
126
+ }
127
+
128
+ function getCalculatedCopy(field: LadderOutput['derivedField'], ui: LadderAngleReachUI): { label: string; help: string } {
129
+ if (field === 'base-distance') return { label: ui.calculatedBase, help: ui.calculatedBaseHelp };
130
+ if (field === 'ladder-length') return { label: ui.calculatedLength, help: ui.calculatedLengthHelp };
131
+ return { label: ui.calculatedHeight, help: ui.calculatedHeightHelp };
132
+ }
133
+
134
+ function getAngleStatusText(status: LadderOutput['angleStatus'], ui: LadderAngleReachUI): string {
135
+ if (status === 'too-flat') return ui.angleTooFlat;
136
+ if (status === 'too-steep') return ui.angleTooSteep;
137
+ return ui.angleWithinGuide;
138
+ }
139
+
140
+ function renderScene(root: Element, output: LadderOutput, ui: LadderAngleReachUI, unitSystem: LadderUnitSystem): void {
141
+ const scene = root.querySelector('[data-la-scene]');
142
+ if (!scene) return;
143
+ const scale = Math.min(88, 180 / Math.max(output.baseDistance, output.guideBaseDistance, 1), 210 / Math.max(output.verticalReach, output.targetHeight, 1));
144
+ const baseX = 138;
145
+ const groundY = 264;
146
+ const wallX = baseX + output.baseDistance * scale;
147
+ const topY = groundY - output.verticalReach * scale;
148
+ const targetY = groundY - output.targetHeight * scale;
149
+ const guideBaseX = wallX - output.guideBaseDistance * scale;
150
+ const angleRad = output.angle * Math.PI / 180;
151
+ const arcR = 32;
152
+ const unit = unitSystem === 'metric' ? ui.unitLengthMetric : ui.unitLengthImperial;
153
+ scene.innerHTML = `<svg class="la-scene-svg" viewBox="0 0 520 320" role="img" aria-label="${ui.angleGuide}"><line class="la-wall" x1="${wallX.toFixed(1)}" y1="34" x2="${wallX.toFixed(1)}" y2="${groundY}"/><line class="la-ground" x1="70" y1="${groundY}" x2="450" y2="${groundY}"/><line class="la-guide" x1="${guideBaseX.toFixed(1)}" y1="${groundY}" x2="${wallX.toFixed(1)}" y2="${targetY.toFixed(1)}"/><line class="la-ladder" x1="${baseX}" y1="${groundY}" x2="${wallX.toFixed(1)}" y2="${topY.toFixed(1)}"/><line class="la-rail" x1="${(baseX + 8).toFixed(1)}" y1="${groundY}" x2="${(wallX + 8).toFixed(1)}" y2="${topY.toFixed(1)}"/><circle class="la-target-dot" cx="${wallX.toFixed(1)}" cy="${targetY.toFixed(1)}" r="5"/><line class="la-target" x1="${(wallX - 28).toFixed(1)}" y1="${targetY.toFixed(1)}" x2="${(wallX + 28).toFixed(1)}" y2="${targetY.toFixed(1)}"/><line class="la-dimension" x1="${(wallX + 24).toFixed(1)}" y1="${targetY.toFixed(1)}" x2="${(wallX + 24).toFixed(1)}" y2="${groundY}"/><line class="la-dimension" x1="${baseX}" y1="${groundY + 24}" x2="${wallX.toFixed(1)}" y2="${groundY + 24}"/><path class="la-arc" d="M ${baseX + arcR} ${groundY} A ${arcR} ${arcR} 0 0 0 ${(baseX + arcR * Math.cos(angleRad)).toFixed(1)} ${(groundY - arcR * Math.sin(angleRad)).toFixed(1)}"/><line class="la-guide" x1="365" y1="94" x2="392" y2="94"/><text class="la-svg-kicker" x="365" y="76">${ui.guideLabel}</text><text class="la-svg-label" x="365" y="116">1 : 4</text><text class="la-svg-label" x="40" y="34">${ui.ladderLabel}</text><text class="la-svg-label" x="${(wallX + 10).toFixed(1)}" y="52">${ui.wallLabel}</text><text class="la-svg-label" x="${(wallX + 34).toFixed(1)}" y="${(targetY - 5).toFixed(1)}">${ui.targetLabel}</text><text class="la-svg-label" x="${((baseX + wallX) / 2).toFixed(1)}" y="${groundY + 45}">${ui.baseLabel}</text><text class="la-svg-label" x="${(baseX + 8).toFixed(1)}" y="${groundY - 8}">${output.angle.toFixed(1)}°</text><text class="la-svg-label" x="${(wallX + 30).toFixed(1)}" y="${((targetY + groundY) / 2).toFixed(1)}">${formatNumber(output.targetHeight)} ${unit}</text></svg>`;
154
+ }
155
+
156
+ function setText(root: Element, selector: string, text: string): void {
157
+ const element = root.querySelector(selector);
158
+ if (element) element.textContent = text;
159
+ }
160
+
161
+ function formatNumber(value: number, decimals = 2): string {
162
+ return Number.isFinite(value) ? value.toFixed(decimals) : '';
163
+ }
@@ -0,0 +1,24 @@
1
+ import type { DiyToolEntry } from '../../types';
2
+ import type { LadderAngleReachUI } from './ui';
3
+
4
+ export const ladderAngleReachCalculator: DiyToolEntry<LadderAngleReachUI> = {
5
+ id: 'ladder-angle-and-reach-calculator',
6
+ icons: { bg: 'mdi:ladder', fg: 'mdi:angle-acute' },
7
+ i18n: {
8
+ en: () => import('./i18n/en').then((m) => m.content),
9
+ de: () => import('./i18n/de').then((m) => m.content),
10
+ es: () => import('./i18n/es').then((m) => m.content),
11
+ fr: () => import('./i18n/fr').then((m) => m.content),
12
+ id: () => import('./i18n/id').then((m) => m.content),
13
+ it: () => import('./i18n/it').then((m) => m.content),
14
+ ja: () => import('./i18n/ja').then((m) => m.content),
15
+ ko: () => import('./i18n/ko').then((m) => m.content),
16
+ nl: () => import('./i18n/nl').then((m) => m.content),
17
+ pl: () => import('./i18n/pl').then((m) => m.content),
18
+ pt: () => import('./i18n/pt').then((m) => m.content),
19
+ ru: () => import('./i18n/ru').then((m) => m.content),
20
+ sv: () => import('./i18n/sv').then((m) => m.content),
21
+ tr: () => import('./i18n/tr').then((m) => m.content),
22
+ zh: () => import('./i18n/zh').then((m) => m.content),
23
+ },
24
+ };
@@ -0,0 +1,31 @@
1
+ import { createLadderLocale } from '../locale';
2
+
3
+ export const content = createLadderLocale({
4
+ language: 'de', slug: 'leiternwinkel-und-reichweiten-rechner', title: 'Leiterwinkel und Reichweitenrechner', description: 'Berechne mit zwei Maßen den Abstand, Winkel und die Reichweite einer Leiter und vergleiche sie mit der 1:4-Aufstellregel.', faqTitle: 'Häufige Fragen', faq: [
5
+ { question: 'Was berechnet dieser Leiterrechner?', answer: 'Er berechnet aus zwei Werten für Zielhöhe, Leiterlänge und Fußabstand die fehlende geometrische Größe. Zusätzlich zeigt er Winkel, Reichhöhe und die 1:4-Referenz getrennt an.' },
6
+ { question: 'Warum unterscheiden sich tatsächlicher Abstand und 1:4-Abstand?', answer: 'Sie beantworten unterschiedliche Fragen. Bei 3 m Höhe und 4 m Leiter ergibt das echte Dreieck 2,65 m Abstand und 48,6°. Die 1:4-Referenz für 3 m liegt bei 0,75 m und benötigt mindestens 3,09 m Leiter.' },
7
+ { question: 'Wie berechne ich eine Leiter für 3 m Höhe?', answer: 'Gib 3 m als Zielhöhe und die verfügbare Leiterlänge ein. Das Ergebnis zeigt die echte Geometrie sowie getrennt den 1:4-Abstand von 0,75 m und die Mindestlänge von etwa 3,09 m.' },
8
+ { question: 'Welche drei Modi gibt es?', answer: 'Höhe + Leiter prüft den echten Fußabstand, Höhe + Fußabstand berechnet die Leiterlänge und Leiter + Fußabstand berechnet die erreichbare Höhe.' },
9
+ { question: 'Ist das Ergebnis ein Sicherheitsnachweis?', answer: 'Nein. Es ist nur eine geometrische Prüfung. Beachte Leiterkennzeichnung, Untergrund, Auflage, Last, Zugang und örtliche Vorschriften.' },
10
+ ],
11
+ howTo: [
12
+ { name: 'Zwei Maße auswählen', text: 'Wähle Höhe und Leiter, Höhe und Fußabstand oder Leiter und Fußabstand.' },
13
+ { name: 'Werte eingeben', text: 'Verwende für beide Felder dasselbe Einheitensystem.' },
14
+ { name: 'Geometrie prüfen', text: 'Lies Abstand, Winkel und Reichhöhe des eingegebenen Dreiecks ab.' },
15
+ { name: 'Mit 1:4 vergleichen', text: 'Vergleiche Referenzabstand und Mindestlänge vor der Aufstellung.' },
16
+ ],
17
+ seo: [
18
+ { type: 'title', text: 'Was dieser Leiterrechner beantwortet', level: 2 },
19
+ { type: 'paragraph', html: 'Nutze ihn, wenn du zwei Maße kennst und entscheiden möchtest, ob eine Leiter eine Höhe erreicht, welchen Fußabstand das echte Dreieck hat oder welche Länge benötigt wird. Die 1:4-Referenz wird nicht mit der tatsächlichen Geometrie verwechselt.' },
20
+ { type: 'title', text: 'Echte Geometrie deiner Maße', level: 3 },
21
+ { type: 'paragraph', html: 'Bei <strong>Höhe + Leiter</strong> lautet der echte Fußabstand <code>√(Leiterlänge² − Höhe²)</code>. Bei <strong>Höhe + Fußabstand</strong> wird die Leiterlänge berechnet; bei <strong>Leiter + Fußabstand</strong> die erreichbare Höhe.' },
22
+ { type: 'title', text: 'Die 1:4-Aufstellreferenz', level: 3 },
23
+ { type: 'paragraph', html: 'Die 1:4-Regel bedeutet einen Abstand von einer Einheit je vier Einheiten Höhe. Für 3 m sind das 0,75 m; die Mindestlänge dieses Dreiecks beträgt <code>√(3² + 0,75²) = 3,09 m</code>. Eine längere Leiter kann eine andere Reichhöhe und einen anderen Winkel ergeben.' },
24
+ { type: 'title', text: 'Ergebnis richtig lesen', level: 3 },
25
+ { type: 'list', items: ['Flacher als 1:4 bedeutet, dass der Fuß weiter vom Auflagepunkt entfernt ist.', 'Steiler als 1:4 bedeutet, dass der Fuß näher am Auflagepunkt steht.', 'Eine längere Leiter kann über die Zielhöhe hinausragen.'] },
26
+ { type: 'tip', title: 'Geometrie ist kein Sicherheitsnachweis', html: 'Prüfe Leiter, Untergrund, Auflage, Last, Zugang und Herstellerangaben vor der Benutzung.' },
27
+ ],
28
+ ui: {
29
+ unitSystemLabel: 'Einheitensystem', unitMetric: 'Metrisch', unitImperial: 'Imperial', intro: 'Gib zwei Maße ein. Geometrie und 1:4-Referenz bleiben getrennt.', solveTitle: 'Zwei Maße auswählen', modeHeightLength: 'Höhe + Leiter', modeHeightBase: 'Höhe + Fuß', modeLengthBase: 'Leiter + Fuß', modeHeightLengthHint: 'Echten Abstand prüfen', modeHeightBaseHint: 'Leiterlänge berechnen', modeLengthBaseHint: 'Reichhöhe berechnen', fieldHeight: 'Zielhöhe', fieldLength: 'Verfügbare Leiterlänge', fieldBase: 'Fußabstand', fieldHeightHelp: 'Höhe, die erreicht werden soll', fieldLengthHelp: 'Länge der Leiter', fieldBaseHelp: 'Horizontaler Abstand zur Auflage', calculatedTitle: 'Für dich berechnet', calculatedBase: 'Echter Fußabstand', calculatedBaseHelp: 'aus diesen beiden Maßen', calculatedLength: 'Leiterlänge', calculatedLengthHelp: 'für diese Höhe und diesen Abstand', calculatedHeight: 'Erreichbare Höhe', calculatedHeightHelp: 'mit dieser Leiter und diesem Abstand', diagramTitle: 'Geometrie des Ergebnisses', angleTitle: 'Winkel', angleGuide: 'Seitenansicht mit Leiter, Auflage, Zielhöhe und 1:4-Referenz', reachTitle: 'Vertikale Reichhöhe', reachHelp: 'Höhe an der Auflage', targetTitle: 'Zielhöhe', targetHelp: 'gewünschte Höhe', guideDistanceTitle: '1:4-Abstand für die Höhe', guideDistanceHelp: 'Zielhöhe ÷ 4', requiredLengthTitle: 'Mindestlänge bei 1:4', requiredLengthHelp: 'für die Zielhöhe', verdictReach: 'Die Leiter erreicht die Höhe', verdictShort: 'Die Leiter erreicht die Höhe nicht', verdictReachHelp: 'Die vertikale Reichhöhe erreicht oder übertrifft die Zielhöhe.', verdictShortHelp: 'Du brauchst mehr Länge oder eine andere Geometrie.', angleTooFlat: 'Flacher als die Referenz', angleWithinGuide: 'Nahe an der 1:4-Referenz', angleTooSteep: 'Steiler als die Referenz', ladderLabel: 'Leiter', wallLabel: 'Auflage', targetLabel: 'Ziel', baseLabel: 'Fußabstand', groundLabel: 'Boden', guideLabel: '1:4-Referenz', invalidPositive: 'Gib zwei positive Maße ein.', invalidBase: 'Der Fußabstand muss kleiner als die Leiterlänge sein.', invalidTarget: 'Die Zielhöhe muss kleiner als die Leiterlänge sein.', warning: 'Nur Geometrie. Beachte Leiterkennzeichnung und Herstellerangaben.', unitLengthMetric: 'm', unitLengthImperial: 'ft',
30
+ },
31
+ });
@@ -0,0 +1,98 @@
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
+
6
+ const slug = 'ladder-angle-and-reach-calculator';
7
+ const title = 'Ladder Angle and Reach Calculator';
8
+ const description = 'Calculate a ladder\'s actual base distance, angle and reach from two measurements, then compare it with the 1:4 placement guide and minimum ladder length.';
9
+
10
+ const faq = [
11
+ {
12
+ question: 'What does the ladder angle and reach calculator calculate?',
13
+ answer: 'It turns any two of target height, ladder length and base distance into the missing geometric value. It also reports the resulting angle, whether the ladder reaches the target, and a separate 1:4 reference for the target height.',
14
+ },
15
+ {
16
+ question: 'Why can the actual base differ from the 1:4 base?',
17
+ answer: 'They answer different questions. With a 3 m target and a 4 m ladder, the exact triangle that touches 3 m has a 2.65 m base and a 48.6° angle. The 1:4 reference for a 3 m target is 0.75 m out and needs a 3.09 m ladder. The calculator keeps those figures separate so a reach check is not mistaken for a placement recommendation.',
18
+ },
19
+ {
20
+ question: 'How do I use the calculator for a 3 m wall height?',
21
+ answer: 'Enter 3 m as the target height and your available ladder length. The geometric result tells you the base and angle if the ladder top is exactly at 3 m. The 1:4 rows then show 0.75 m as the target-height reference and about 3.09 m as the minimum ladder length at that placement angle.',
22
+ },
23
+ {
24
+ question: 'What are the three calculation modes?',
25
+ answer: 'Use height plus ladder length to check the actual base, height plus base distance to calculate the ladder length, or ladder length plus base distance to calculate the reachable height.',
26
+ },
27
+ {
28
+ question: 'What does the 1:4 ladder rule mean?',
29
+ answer: 'For a leaning portable ladder, the foot is placed one unit out for every four units of target height. This is a planning reference, not a complete safety assessment. Check the ladder label, surface, support and local requirements before use.',
30
+ },
31
+ ];
32
+
33
+ const howTo = [
34
+ { name: 'Choose the calculation you need', text: 'Pick height + ladder to check an existing setup, height + base to size a ladder, or ladder + base to find its reachable height.' },
35
+ { name: 'Enter your two measurements', text: 'Use the same unit system for both fields. Switch between metric and imperial if needed.' },
36
+ { name: 'Read the geometric result', text: 'Use the base, angle and reach to understand the triangle formed by your measurements.' },
37
+ { name: 'Check the 1:4 reference', text: 'Compare the target-height base and minimum ladder length with the available space and ladder before deciding how to place it.' },
38
+ ];
39
+
40
+ const faqSchema: WithContext<FAQPage> = {
41
+ '@context': 'https://schema.org',
42
+ '@type': 'FAQPage',
43
+ mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
44
+ };
45
+
46
+ const howToSchema: WithContext<HowTo> = {
47
+ '@context': 'https://schema.org',
48
+ '@type': 'HowTo',
49
+ name: title,
50
+ description,
51
+ step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })),
52
+ };
53
+
54
+ const appSchema: WithContext<SoftwareApplication> = {
55
+ '@context': 'https://schema.org',
56
+ '@type': 'SoftwareApplication',
57
+ name: title,
58
+ description,
59
+ applicationCategory: 'UtilityApplication',
60
+ operatingSystem: 'All',
61
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
62
+ inLanguage: 'en',
63
+ };
64
+
65
+ export const content: ToolLocaleContent<LadderAngleReachUI> = {
66
+ slug,
67
+ title,
68
+ description,
69
+ faqTitle: 'Frequently Asked Questions',
70
+ faq,
71
+ bibliographyTitle: 'Sources and technical notes',
72
+ bibliography: ladderAngleReachCalculatorBibliography,
73
+ howTo,
74
+ schemas: [faqSchema, howToSchema, appSchema],
75
+ seo: [
76
+ { type: 'title', text: 'What this ladder calculator tells you', level: 2 },
77
+ { type: 'paragraph', html: 'Use this calculator when you know two measurements and need to make a ladder setup decision. It calculates the missing side of the right triangle, the ladder angle and the vertical reach. It also gives a separate 1:4 reference so you can compare the target-height placement with the geometry created by your actual measurements.' },
78
+ { type: 'title', text: 'Actual geometry from the measurements you enter', level: 3 },
79
+ { type: 'paragraph', html: 'Choose <strong>height + ladder</strong> to check an existing ladder against a target height. The actual base is <code>√(ladder length² − target height²)</code>. Choose <strong>height + base</strong> to calculate the required ladder length, or <strong>ladder + base</strong> to calculate the height that setup can reach. The angle and reach are calculated from the same triangle, so the result reflects your measurements rather than silently replacing them with the 1:4 rule.' },
80
+ { type: 'title', text: '1:4 placement: base distance and minimum ladder length', level: 3 },
81
+ { type: 'paragraph', html: 'The common 1:4 planning reference places the foot one unit out for every four units of target height. For a 3 m target, that means a 0.75 m reference distance. The minimum ladder length for that target-height geometry is <code>√(3² + 0.75²) = 3.09 m</code>. These values are shown separately from the actual triangle because a longer ladder or a different base distance can change the angle and reach.' },
82
+ { type: 'title', text: 'How to interpret the result', level: 3 },
83
+ { type: 'list', items: ['If the actual angle is flatter than the 1:4 guide, the ladder is farther from the support for the height entered.', 'If the actual angle is steeper, the ladder is closer to the support than the guide reference.', 'If the available ladder is longer than the minimum, it may extend above the target; check the required access height and the ladder instructions.'] },
84
+ { type: 'tip', title: 'Geometry is not a safety certificate', html: 'A reachable height or a 1:4 comparison does not certify the ladder, surface, support, load, access clearance or work method. Inspect the ladder and follow its label, manufacturer instructions and local requirements before use.' },
85
+ ],
86
+ ui: {
87
+ unitSystemLabel: 'Measurement system', unitMetric: 'Metric', unitImperial: 'Imperial',
88
+ intro: 'Enter two measurements. The geometric result and the 1:4 reference stay separate.',
89
+ solveTitle: 'Choose two measurements', modeHeightLength: 'Height + ladder', modeHeightBase: 'Height + base', modeLengthBase: 'Ladder + base', modeHeightLengthHint: 'Check the actual base', modeHeightBaseHint: 'Find the ladder length', modeLengthBaseHint: 'Find the reachable height',
90
+ fieldHeight: 'Target height', fieldLength: 'Available ladder length', fieldBase: 'Base distance from wall', fieldHeightHelp: 'Height you need to reach', fieldLengthHelp: 'Length along the ladder', fieldBaseHelp: 'Horizontal distance on the ground',
91
+ calculatedTitle: 'Calculated for you', calculatedBase: 'Actual base distance', calculatedBaseHelp: 'from these two measurements', calculatedLength: 'Ladder length', calculatedLengthHelp: 'for this height and base distance', calculatedHeight: 'Reachable height', calculatedHeightHelp: 'from this ladder and base distance',
92
+ diagramTitle: 'The geometry behind the answer', angleTitle: 'Angle', angleUnit: '°', angleGuide: 'Functional side-view diagram showing ladder, wall, target height, base distance, and the 1:4 guide', reachTitle: 'Vertical reach', reachHelp: 'height at the wall', targetTitle: 'Target height', targetHelp: 'height requested', baseTitle: 'Base distance', baseHelp: 'horizontal ground distance', guideDistanceTitle: '1:4 base for target', guideDistanceHelp: 'target height ÷ 4', requiredLengthTitle: 'Minimum length at 1:4', requiredLengthHelp: 'for the target height',
93
+ verdictReach: 'The target is reachable', verdictShort: 'The target is higher than this setup', verdictReachHelp: 'The calculated vertical reach meets or exceeds the target height.', verdictShortHelp: 'Choose more ladder length or change the geometry only if the final setup remains suitable.',
94
+ angleTooFlat: 'Flatter than the guide', angleWithinGuide: 'Near the 1:4 guide', angleTooSteep: 'Steeper than the guide', ladderLabel: 'ladder', wallLabel: 'support', targetLabel: 'target', baseLabel: 'base distance', groundLabel: 'ground', guideLabel: '1:4 guide',
95
+ invalidPositive: 'Enter two positive measurements.', invalidBase: 'The base distance must be smaller than the ladder length.', invalidTarget: 'The target height must be lower than the ladder length.', warning: 'Geometry only. Follow the ladder label and manufacturer instructions.',
96
+ unitLengthMetric: 'm', unitLengthImperial: 'ft',
97
+ },
98
+ };
@@ -0,0 +1,31 @@
1
+ import { createLadderLocale } from '../locale';
2
+
3
+ export const content = createLadderLocale({
4
+ language: 'es', slug: 'calculadora-angulo-y-alcance-escalera', title: 'Calculadora de ángulo y alcance de escaleras', description: 'Calcula la base, el ángulo y el alcance de una escalera con dos medidas y compara el resultado con la referencia 1:4.', faqTitle: 'Preguntas frecuentes', faq: [
5
+ { question: '¿Qué calcula esta calculadora de escaleras?', answer: 'Convierte dos medidas entre altura objetivo, longitud de escalera y distancia de la base en la tercera medida geométrica. También muestra el ángulo, el alcance vertical y la referencia 1:4 por separado.' },
6
+ { question: '¿Por qué la base real puede diferir de la referencia uno a cuatro?', answer: 'Responden a preguntas distintas. Con 3 m de altura y una escalera de 4 m, el triángulo exacto tiene una base de 2,65 m y un ángulo de 48,6°. La referencia 1:4 para 3 m es 0,75 m y necesita una escalera mínima de 3,09 m.' },
7
+ { question: '¿Cómo calculo una escalera para alcanzar 3 m?', answer: 'Introduce 3 m como altura objetivo y la longitud disponible. La herramienta muestra la geometría real y, aparte, los 0,75 m de referencia 1:4 y los 3,09 m mínimos para esa altura.' },
8
+ { question: '¿Qué significan los tres modos?', answer: 'Altura + escalera comprueba la base real; altura + base calcula la longitud; escalera + base calcula la altura alcanzable.' },
9
+ { question: '¿El resultado certifica que la escalera es segura?', answer: 'No. Es una comprobación geométrica. Revisa la etiqueta de la escalera, el suelo, el apoyo, la carga, los accesos y la normativa aplicable.' },
10
+ ],
11
+ howTo: [
12
+ { name: 'Elige dos medidas', text: 'Selecciona altura y escalera, altura y base, o escalera y base.' },
13
+ { name: 'Introduce los valores', text: 'Usa el mismo sistema de unidades en ambos campos.' },
14
+ { name: 'Lee la geometría', text: 'Comprueba la base, el ángulo y el alcance que forman tus medidas.' },
15
+ { name: 'Compara con 1:4', text: 'Usa la base de referencia y la longitud mínima antes de decidir la colocación.' },
16
+ ],
17
+ seo: [
18
+ { type: 'title', text: 'Qué resuelve esta calculadora de escaleras', level: 2 },
19
+ { type: 'paragraph', html: 'Úsala cuando conozcas dos medidas y necesites decidir si una escalera alcanza una altura, qué distancia forma la base o qué longitud necesitas. Calcula el triángulo real y muestra aparte la referencia 1:4.' },
20
+ { type: 'title', text: 'Geometría real de tus medidas', level: 3 },
21
+ { type: 'paragraph', html: 'Con <strong>altura + escalera</strong>, la base real es <code>√(longitud² − altura²)</code>. Con <strong>altura + base</strong> obtienes la longitud, y con <strong>escalera + base</strong> obtienes la altura alcanzable. El ángulo siempre procede de esos mismos datos.' },
22
+ { type: 'title', text: 'Referencia de colocación 1:4', level: 3 },
23
+ { type: 'paragraph', html: 'La regla 1:4 coloca la base a una unidad por cada cuatro unidades de altura. Para 3 m son 0,75 m; la longitud mínima de ese triángulo es <code>√(3² + 0,75²) = 3,09 m</code>. Esta referencia no sustituye a la geometría real de una escalera más larga.' },
24
+ { type: 'title', text: 'Cómo interpretar el resultado', level: 3 },
25
+ { type: 'list', items: ['Un ángulo más tendido que 1:4 significa que la base queda más lejos.', 'Un ángulo más cerrado significa que la base queda más cerca.', 'Una escalera más larga que la mínima puede sobresalir por encima de la altura objetivo.'] },
26
+ { type: 'tip', title: 'La geometría no es un certificado de seguridad', html: 'Comprueba la escalera, la superficie, el apoyo, la carga, el acceso y las instrucciones del fabricante antes de usarla.' },
27
+ ],
28
+ ui: {
29
+ unitSystemLabel: 'Sistema de unidades', unitMetric: 'Métrico', unitImperial: 'Imperial', intro: 'Introduce dos medidas. La geometría y la referencia 1:4 aparecen separadas.', solveTitle: 'Elige dos medidas', modeHeightLength: 'Altura + escalera', modeHeightBase: 'Altura + base', modeLengthBase: 'Escalera + base', modeHeightLengthHint: 'Comprueba la base real', modeHeightBaseHint: 'Calcula la longitud', modeLengthBaseHint: 'Calcula el alcance', fieldHeight: 'Altura objetivo', fieldLength: 'Longitud disponible', fieldBase: 'Distancia de la base', fieldHeightHelp: 'Altura que necesitas alcanzar', fieldLengthHelp: 'Longitud de la escalera', fieldBaseHelp: 'Distancia horizontal al apoyo', calculatedTitle: 'Calculado para ti', calculatedBase: 'Base real', calculatedBaseHelp: 'con estas dos medidas', calculatedLength: 'Longitud de escalera', calculatedLengthHelp: 'para esta altura y base', calculatedHeight: 'Altura alcanzable', calculatedHeightHelp: 'con esta escalera y base', diagramTitle: 'Geometría del resultado', angleTitle: 'Ángulo', angleGuide: 'Diagrama lateral de escalera, apoyo, altura objetivo y referencia 1:4', reachTitle: 'Alcance vertical', reachHelp: 'altura en el apoyo', targetTitle: 'Altura objetivo', targetHelp: 'altura solicitada', guideDistanceTitle: 'Base 1:4 para la altura', guideDistanceHelp: 'altura objetivo ÷ 4', requiredLengthTitle: 'Longitud mínima a 1:4', requiredLengthHelp: 'para la altura objetivo', verdictReach: 'La escalera alcanza la altura', verdictShort: 'La escalera no alcanza la altura', verdictReachHelp: 'El alcance vertical iguala o supera la altura objetivo.', verdictShortHelp: 'Necesitas más longitud o una geometría distinta.', angleTooFlat: 'Más tendida que la guía', angleWithinGuide: 'Cerca de la guía 1:4', angleTooSteep: 'Más cerrada que la guía', ladderLabel: 'escalera', wallLabel: 'apoyo', targetLabel: 'objetivo', baseLabel: 'distancia de base', groundLabel: 'suelo', guideLabel: 'guía 1:4', invalidPositive: 'Introduce dos medidas positivas.', invalidBase: 'La distancia de la base debe ser menor que la longitud.', invalidTarget: 'La altura objetivo debe ser menor que la longitud.', warning: 'Solo geometría. Sigue la etiqueta y las instrucciones del fabricante.', unitLengthMetric: 'm', unitLengthImperial: 'ft',
30
+ },
31
+ });