@jjlmoya/utils-diy 1.31.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 (36) 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/layouts/ProductionUtilityPage.astro +4 -4
  6. package/src/mfe/widget-height.ts +14 -0
  7. package/src/tests/locale_completeness.test.ts +2 -2
  8. package/src/tests/tool_validation.test.ts +2 -2
  9. package/src/tool/ladderAngleReachCalculator/bibliography.astro +6 -0
  10. package/src/tool/ladderAngleReachCalculator/bibliography.ts +12 -0
  11. package/src/tool/ladderAngleReachCalculator/component.astro +68 -0
  12. package/src/tool/ladderAngleReachCalculator/controller.ts +163 -0
  13. package/src/tool/ladderAngleReachCalculator/entry.ts +24 -0
  14. package/src/tool/ladderAngleReachCalculator/i18n/de.ts +31 -0
  15. package/src/tool/ladderAngleReachCalculator/i18n/en.ts +98 -0
  16. package/src/tool/ladderAngleReachCalculator/i18n/es.ts +31 -0
  17. package/src/tool/ladderAngleReachCalculator/i18n/fr.ts +32 -0
  18. package/src/tool/ladderAngleReachCalculator/i18n/id.ts +32 -0
  19. package/src/tool/ladderAngleReachCalculator/i18n/it.ts +32 -0
  20. package/src/tool/ladderAngleReachCalculator/i18n/ja.ts +32 -0
  21. package/src/tool/ladderAngleReachCalculator/i18n/ko.ts +32 -0
  22. package/src/tool/ladderAngleReachCalculator/i18n/nl.ts +31 -0
  23. package/src/tool/ladderAngleReachCalculator/i18n/pl.ts +32 -0
  24. package/src/tool/ladderAngleReachCalculator/i18n/pt.ts +31 -0
  25. package/src/tool/ladderAngleReachCalculator/i18n/ru.ts +32 -0
  26. package/src/tool/ladderAngleReachCalculator/i18n/sv.ts +32 -0
  27. package/src/tool/ladderAngleReachCalculator/i18n/tr.ts +32 -0
  28. package/src/tool/ladderAngleReachCalculator/i18n/zh.ts +32 -0
  29. package/src/tool/ladderAngleReachCalculator/index.ts +11 -0
  30. package/src/tool/ladderAngleReachCalculator/ladder-angle-and-reach-calculator.css +491 -0
  31. package/src/tool/ladderAngleReachCalculator/locale.ts +57 -0
  32. package/src/tool/ladderAngleReachCalculator/logic.test.ts +44 -0
  33. package/src/tool/ladderAngleReachCalculator/logic.ts +64 -0
  34. package/src/tool/ladderAngleReachCalculator/seo.astro +15 -0
  35. package/src/tool/ladderAngleReachCalculator/ui.ts +91 -0
  36. 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.31.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];
@@ -45,6 +45,7 @@ const ui = getCategoryUi(locale);
45
45
  </ProductionPage>
46
46
 
47
47
  <script>
48
+ import { observeWidgetHeight } from "../mfe/widget-height";
48
49
  const isWidget = new URLSearchParams(window.location.search).get("widget") === "true";
49
50
  const page = document.querySelector<HTMLElement>(".utility-production");
50
51
  const container = document.querySelector<HTMLElement>(".utility-tool-production");
@@ -60,11 +61,10 @@ const ui = getCategoryUi(locale);
60
61
  document.querySelector('[data-zoom="reset"]')?.addEventListener("click", () => { zoom = 1; applyZoom(); localStorage.removeItem("gamebob-utility-zoom"); });
61
62
  if (isWidget) {
62
63
  document.body.classList.add("utility-widget-body");
63
- if (page) {
64
- page.classList.add("utility-widget-mode");
65
- }
64
+ page?.classList.add("utility-widget-mode");
66
65
  }
67
66
  applyZoom();
67
+ if (isWidget && container) observeWidgetHeight(container);
68
68
  </script>
69
69
 
70
70
  <style>
@@ -88,7 +88,7 @@ const ui = getCategoryUi(locale);
88
88
  margin-top: 0;
89
89
  }
90
90
 
91
- .utility-production.utility-widget-mode .utility-production-inner > :not(.utility-tool-production) {
91
+ :global(.utility-production.utility-widget-mode .utility-production-inner > :not(.utility-tool-production)) {
92
92
  display: none;
93
93
  }
94
94
 
@@ -0,0 +1,14 @@
1
+ export function observeWidgetHeight(container: HTMLElement): void {
2
+ if (window.parent === window) return;
3
+
4
+ const pathSlug = window.location.pathname.split("/").filter(Boolean).pop() ?? "utility";
5
+ const widgetId = new URLSearchParams(window.location.search).get("id") ?? `jj-widget-${pathSlug}`;
6
+ const reportHeight = (height: number) => {
7
+ if (height > 50) {
8
+ window.parent.postMessage({ jjlmoyaHeight: Math.ceil(height), jjlmoyaId: widgetId }, "*");
9
+ }
10
+ };
11
+ const observer = new ResizeObserver(([entry]) => reportHeight(entry?.contentRect.height ?? 0));
12
+
13
+ observer.observe(container);
14
+ }
@@ -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
+ };