@jjlmoya/utils-motor 1.11.0 → 1.12.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 +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +5 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/trailerTongueWeightCalculator/bibliography.astro +6 -0
  8. package/src/tool/trailerTongueWeightCalculator/bibliography.ts +16 -0
  9. package/src/tool/trailerTongueWeightCalculator/component.astro +116 -0
  10. package/src/tool/trailerTongueWeightCalculator/controller.ts +143 -0
  11. package/src/tool/trailerTongueWeightCalculator/dom-views.ts +79 -0
  12. package/src/tool/trailerTongueWeightCalculator/entry.ts +30 -0
  13. package/src/tool/trailerTongueWeightCalculator/evaluator.ts +10 -0
  14. package/src/tool/trailerTongueWeightCalculator/i18n/de.ts +39 -0
  15. package/src/tool/trailerTongueWeightCalculator/i18n/en.ts +63 -0
  16. package/src/tool/trailerTongueWeightCalculator/i18n/es.ts +38 -0
  17. package/src/tool/trailerTongueWeightCalculator/i18n/fr.ts +38 -0
  18. package/src/tool/trailerTongueWeightCalculator/i18n/id.ts +38 -0
  19. package/src/tool/trailerTongueWeightCalculator/i18n/it.ts +38 -0
  20. package/src/tool/trailerTongueWeightCalculator/i18n/ja.ts +38 -0
  21. package/src/tool/trailerTongueWeightCalculator/i18n/ko.ts +38 -0
  22. package/src/tool/trailerTongueWeightCalculator/i18n/nl.ts +38 -0
  23. package/src/tool/trailerTongueWeightCalculator/i18n/pl.ts +38 -0
  24. package/src/tool/trailerTongueWeightCalculator/i18n/pt.ts +38 -0
  25. package/src/tool/trailerTongueWeightCalculator/i18n/ru.ts +38 -0
  26. package/src/tool/trailerTongueWeightCalculator/i18n/sv.ts +38 -0
  27. package/src/tool/trailerTongueWeightCalculator/i18n/tr.ts +38 -0
  28. package/src/tool/trailerTongueWeightCalculator/i18n/zh.ts +38 -0
  29. package/src/tool/trailerTongueWeightCalculator/index.ts +10 -0
  30. package/src/tool/trailerTongueWeightCalculator/logic.test.ts +44 -0
  31. package/src/tool/trailerTongueWeightCalculator/logic.ts +91 -0
  32. package/src/tool/trailerTongueWeightCalculator/seo.astro +15 -0
  33. package/src/tool/trailerTongueWeightCalculator/storage.ts +32 -0
  34. package/src/tool/trailerTongueWeightCalculator/trailer-tongue-weight-calculator.css +429 -0
  35. package/src/tool/trailerTongueWeightCalculator/ui.ts +49 -0
  36. package/src/tools.ts +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-motor",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -5,10 +5,11 @@ import { motorcycleSkidMarkSpeedEstimator } from '../tool/motorcycleSkidMarkSpee
5
5
  import { realFuelConsumptionCalculator } from '../tool/realFuelConsumptionCalculator/entry';
6
6
  import { tirePressureConverter } from '../tool/tirePressureConverter/entry';
7
7
  import { evChargingTimeCostPlanner } from '../tool/evChargingTimeCostPlanner/entry';
8
+ import { trailerTongueWeightCalculator } from '../tool/trailerTongueWeightCalculator/entry';
8
9
 
9
10
  export const motorCategory: MotorCategoryEntry = {
10
11
  icon: 'mdi:car-cog',
11
- tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner] as unknown as MotorToolEntry<Record<string, string>>[],
12
+ tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner, trailerTongueWeightCalculator] as unknown as MotorToolEntry<Record<string, string>>[],
12
13
  i18n: {
13
14
  es: () => import('./i18n/es').then((module) => module.content),
14
15
  en: () => import('./i18n/en').then((module) => module.content),
package/src/entries.ts CHANGED
@@ -8,6 +8,8 @@ export { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalcula
8
8
  export type { RealFuelConsumptionCalculatorLocaleContent } from './tool/realFuelConsumptionCalculator/entry';
9
9
  export { tirePressureConverter } from './tool/tirePressureConverter/entry';
10
10
  export type { TirePressureConverterLocaleContent } from './tool/tirePressureConverter/entry';
11
+ export { trailerTongueWeightCalculator } from './tool/trailerTongueWeightCalculator/entry';
12
+ export type { TrailerTongueWeightCalculatorLocaleContent } from './tool/trailerTongueWeightCalculator/entry';
11
13
  export { motorCategory } from './category';
12
14
 
13
15
  import { brakingDistanceCalculator } from './tool/brakingDistanceCalculator/entry';
@@ -16,5 +18,6 @@ import { motorcycleSkidMarkSpeedEstimator } from './tool/motorcycleSkidMarkSpeed
16
18
  import { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator/entry';
17
19
  import { tirePressureConverter } from './tool/tirePressureConverter/entry';
18
20
  import { evChargingTimeCostPlanner } from './tool/evChargingTimeCostPlanner/entry';
21
+ import { trailerTongueWeightCalculator } from './tool/trailerTongueWeightCalculator/entry';
19
22
 
20
- export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner];
23
+ export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner, trailerTongueWeightCalculator];
package/src/index.ts CHANGED
@@ -46,3 +46,8 @@ export type {
46
46
  EVChargingTimeCostPlannerUI,
47
47
  EVChargingTimeCostPlannerLocaleContent,
48
48
  } from './tool/evChargingTimeCostPlanner';
49
+ export { TRAILER_TONGUE_WEIGHT_CALCULATOR_TOOL, trailerTongueWeightCalculator } from './tool/trailerTongueWeightCalculator';
50
+ export type {
51
+ TrailerTongueWeightCalculatorUI,
52
+ TrailerTongueWeightCalculatorLocaleContent,
53
+ } from './tool/trailerTongueWeightCalculator';
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../types';
4
4
 
5
5
  describe('Locale Completeness and Slug Validation', () => {
6
6
  it('all tools registered in ALL_TOOLS', () => {
7
- expect(ALL_TOOLS.length).toBe(6);
7
+ expect(ALL_TOOLS.length).toBe(7);
8
8
  });
9
9
 
10
10
  ALL_TOOLS.forEach((tool) => {
@@ -5,7 +5,7 @@ import { motorCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools registered in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(6);
8
+ expect(ALL_TOOLS.length).toBe(7);
9
9
  });
10
10
 
11
11
  it('motorCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'Dirección General de Tráfico, Conducir seguro con remolque',
6
+ url: 'https://www-pro.dgt.es/comunicacion/noticias/202406278-conducir-seguro-con-remolque/',
7
+ },
8
+ {
9
+ name: 'The Highway Code, Rule 98: Vehicle towing and loading',
10
+ url: 'https://www.gov.uk/guidance/the-highway-code/rules-for-drivers-and-motorcyclists-89-to-102',
11
+ },
12
+ {
13
+ name: 'OpenStax, Física universitaria volumen 1, 12.1 Condiciones para el equilibrio estático',
14
+ url: 'https://openstax.org/books/f%C3%ADsica-universitaria-volumen-1/pages/12-1-condiciones-para-el-equilibrio-estatico',
15
+ },
16
+ ];
@@ -0,0 +1,116 @@
1
+ ---
2
+ import './trailer-tongue-weight-calculator.css';
3
+ import { calculateTrailerPlan, TRAILER_PRESETS } from './logic';
4
+ import { formatDistance, formatMass } from './dom-views';
5
+ import type { TrailerTongueWeightCalculatorUI } from './ui';
6
+
7
+ interface Props {
8
+ ui: Record<string, string>;
9
+ }
10
+
11
+ const ui = Astro.props.ui as unknown as TrailerTongueWeightCalculatorUI;
12
+ const initialInputs = TRAILER_PRESETS[0]!.inputs;
13
+ const initialPlan = calculateTrailerPlan(initialInputs);
14
+ const fields = [
15
+ { id: 'totalMassKg', label: ui.totalMassLabel, hint: ui.totalMassHint, value: '1200', min: '1', max: '20000', step: '1', unit: 'kg', kind: 'mass' },
16
+ { id: 'hitchDistanceM', label: ui.hitchDistanceLabel, hint: ui.hitchDistanceHint, value: '3', min: '0.5', max: '30', step: '0.1', unit: 'm', kind: 'distance' },
17
+ { id: 'centerPositionM', label: ui.centerPositionLabel, hint: ui.centerPositionHint, value: '0.3', min: '-15', max: '30', step: '0.1', unit: 'm', kind: 'distance' },
18
+ { id: 'minimumTongueKg', label: ui.minimumTongueLabel, hint: ui.minimumTongueHint, value: '90', min: '0', max: '5000', step: '1', unit: 'kg', kind: 'mass' },
19
+ { id: 'maximumTongueKg', label: ui.maximumTongueLabel, hint: ui.maximumTongueHint, value: '180', min: '1', max: '5000', step: '1', unit: 'kg', kind: 'mass' },
20
+ ];
21
+ const presetMeta = (index: number): string => ui.presetMetaTemplate
22
+ .replace('{mass}', formatMass(TRAILER_PRESETS[index]!.inputs.totalMassKg, 'metric'))
23
+ .replace('{position}', formatDistance(TRAILER_PRESETS[index]!.inputs.centerPositionM, 'metric'));
24
+ ---
25
+
26
+ <div class="ttw-tool" lang="en-US" data-trailer-tongue-weight data-unit="metric" data-status="safe">
27
+ <section class="ttw-surface">
28
+ <div class="ttw-toolbar">
29
+ <div class="ttw-unit-switch" role="group" aria-label="Units">
30
+ <button type="button" data-ttw-unit="metric" aria-pressed="true">{ui.metricLabel}</button>
31
+ <button type="button" data-ttw-unit="imperial" aria-pressed="false">{ui.imperialLabel}</button>
32
+ </div>
33
+ <button class="ttw-reset" type="button" data-ttw-reset>{ui.resetLabel}</button>
34
+ </div>
35
+
36
+ <div class="ttw-presets" role="group" aria-label={ui.presetLabel}>
37
+ <span class="ttw-presets-label">{ui.presetLabel}</span>
38
+ <button type="button" data-ttw-preset="balanced" aria-pressed="true" class="is-active"><strong>{ui.balancedPreset}</strong><small>{presetMeta(0)}</small></button>
39
+ <button type="button" data-ttw-preset="front-heavy" aria-pressed="false"><strong>{ui.frontHeavyPreset}</strong><small>{presetMeta(1)}</small></button>
40
+ <button type="button" data-ttw-preset="rear-heavy" aria-pressed="false"><strong>{ui.rearHeavyPreset}</strong><small>{presetMeta(2)}</small></button>
41
+ </div>
42
+
43
+ <div class="ttw-layout">
44
+ <section class="ttw-controls" aria-labelledby="ttw-geometry-title">
45
+ <div class="ttw-section-heading">
46
+ <p class="ttw-overline">{ui.geometryLabel}</p>
47
+ <h2 id="ttw-geometry-title">{ui.geometryHint}</h2>
48
+ </div>
49
+ <div class="ttw-field-stack">
50
+ {fields.slice(0, 3).map((field) => (
51
+ <label class="ttw-field" for={`ttw-${field.id}`}>
52
+ <span class="ttw-field-title">{field.label}</span>
53
+ <small>{field.hint}</small>
54
+ <span class="ttw-input-row"><input id={`ttw-${field.id}`} data-ttw-field={field.id} data-ttw-number type="number" min={field.min} max={field.max} step={field.step} value={field.value} /><em data-ttw-mass-unit={field.kind === 'mass' ? '' : undefined} data-ttw-distance-unit={field.kind === 'distance' ? '' : undefined}>{field.unit}</em></span>
55
+ <input data-ttw-field={field.id} data-ttw-range type="range" min={field.min} max={field.max} step={field.step} value={field.value} aria-label={field.label} />
56
+ </label>
57
+ ))}
58
+ </div>
59
+ <div class="ttw-limits">
60
+ <div class="ttw-section-heading">
61
+ <p class="ttw-overline">{ui.tongueLimitsLabel}</p>
62
+ <h2>{ui.tongueLimitsHint}</h2>
63
+ </div>
64
+ {fields.slice(3).map((field) => (
65
+ <label class="ttw-field" for={`ttw-${field.id}`}>
66
+ <span class="ttw-field-title">{field.label}</span>
67
+ <small>{field.hint}</small>
68
+ <span class="ttw-input-row"><input id={`ttw-${field.id}`} data-ttw-field={field.id} data-ttw-number type="number" min={field.min} max={field.max} step={field.step} value={field.value} /><em data-ttw-mass-unit>{field.unit}</em></span>
69
+ <input data-ttw-field={field.id} data-ttw-range type="range" min={field.min} max={field.max} step={field.step} value={field.value} aria-label={field.label} />
70
+ </label>
71
+ ))}
72
+ </div>
73
+ </section>
74
+
75
+ <section class="ttw-result" aria-labelledby="ttw-result-title" aria-live="polite">
76
+ <div class="ttw-result-heading"><div><p class="ttw-overline">{ui.resultKicker}</p><h2 id="ttw-result-title">{ui.resultTitle}</h2></div><span class="ttw-status" data-ttw-status>{ui.statusSafe}</span></div>
77
+ <div class="ttw-scene-wrap">
78
+ <svg class="ttw-scene" viewBox="0 0 620 250" role="img" aria-labelledby="ttw-scene-title">
79
+ <title id="ttw-scene-title">{ui.sceneLabel}</title>
80
+ <line class="ttw-force-line" x1="76" y1="45" x2="76" y2="178" />
81
+ <line class="ttw-load-line" data-ttw-load-line x1="325" y1="58" x2="325" y2="140" />
82
+ <circle class="ttw-load-dot" data-ttw-load-dot cx="325" cy="58" r="13" />
83
+ <line class="ttw-deck" x1="76" y1="140" x2="498" y2="140" />
84
+ <path class="ttw-drawbar" d="M76 140 L35 140 L18 122" />
85
+ <line class="ttw-axle" x1="370" y1="140" x2="370" y2="198" />
86
+ <circle class="ttw-wheel" cx="345" cy="212" r="22" />
87
+ <circle class="ttw-wheel" cx="395" cy="212" r="22" />
88
+ <path class="ttw-ground" d="M300 234 H440" />
89
+ <text class="ttw-scene-label" x="36" y="34">{ui.hitchLabel}</text>
90
+ <text class="ttw-scene-label" x="340" y="184">{ui.axleLabel}</text>
91
+ <text class="ttw-scene-value" x="250" y="38" data-ttw-scene-load>{formatMass(initialInputs.totalMassKg, 'metric')}</text>
92
+ <text class="ttw-scene-value" x="250" y="84" data-ttw-scene-center>{formatDistance(initialInputs.centerPositionM, 'metric')}</text>
93
+ <text class="ttw-scene-value" x="28" y="224" data-ttw-scene-tongue>{formatMass(initialPlan.tongueWeightKg, 'metric')}</text>
94
+ </svg>
95
+ <div class="ttw-scene-legend"><span><i class="ttw-dot ttw-dot-load"></i>{ui.centerOfMassLabel}</span><span><i class="ttw-dot ttw-dot-tongue"></i>{ui.tongueWeightLabel}</span><span><i class="ttw-dot ttw-dot-axle"></i>{ui.axleLabel}</span></div>
96
+ </div>
97
+ <div class="ttw-lead"><span>{ui.tongueWeightLabel}</span><strong data-ttw-tongue>{formatMass(initialPlan.tongueWeightKg, 'metric')}</strong><small data-ttw-percent>{initialPlan.tonguePercent.toFixed(1)}%</small></div>
98
+ <div class="ttw-metrics">
99
+ <div><span>{ui.tonguePercentLabel}</span><strong data-ttw-percent>{initialPlan.tonguePercent.toFixed(1)}%</strong></div>
100
+ <div><span>{ui.axleLoadLabel}</span><strong data-ttw-axle>{formatMass(initialPlan.axleLoadKg, 'metric')}</strong></div>
101
+ <div><span>{ui.allowedRangeLabel}</span><strong data-ttw-range>{formatMass(initialInputs.minimumTongueKg, 'metric')} - {formatMass(initialInputs.maximumTongueKg, 'metric')}</strong></div>
102
+ </div>
103
+ <div class="ttw-shift"><strong data-ttw-shift-title>{ui.shiftTitle}</strong><p data-ttw-shift>{ui.shiftNone}</p></div>
104
+ <p class="ttw-summary" data-ttw-summary>{ui.summaryTemplate.replace('{tongue}', formatMass(initialPlan.tongueWeightKg, 'metric')).replace('{percent}', `${initialPlan.tonguePercent.toFixed(1)}%`).replace('{position}', formatDistance(initialInputs.centerPositionM, 'metric'))}</p>
105
+ </section>
106
+ </div>
107
+ <footer class="ttw-formula"><strong>{ui.formulaLabel}</strong><span>{ui.formulaText}</span></footer>
108
+ </section>
109
+ </div>
110
+
111
+ <script is:inline id="ttw-copy" type="application/json" set:html={JSON.stringify(ui)}></script>
112
+ <script>
113
+ import { initTrailerTongueWeightCalculator } from './controller';
114
+ const copy = document.querySelector<HTMLScriptElement>('#ttw-copy');
115
+ if (copy?.textContent) initTrailerTongueWeightCalculator(JSON.parse(copy.textContent));
116
+ </script>
@@ -0,0 +1,143 @@
1
+ import { evaluateTrailerStatus } from './evaluator';
2
+ import { calculateTrailerPlan, convertDistance, convertMass, getPreset, parseDistance, parseMass, type TrailerInputs, type UnitSystem } from './logic';
3
+ import { clearTrailerState, loadTrailerState, saveTrailerState } from './storage';
4
+ import { renderTrailerPlan } from './dom-views';
5
+ import type { TrailerTongueWeightCalculatorUI } from './ui';
6
+
7
+ type FieldName = 'totalMassKg' | 'hitchDistanceM' | 'centerPositionM' | 'minimumTongueKg' | 'maximumTongueKg';
8
+ type UnitState = { value: UnitSystem };
9
+
10
+ const DEFAULT_INPUTS: TrailerInputs = getPreset('balanced').inputs;
11
+ const FIELD_LIMITS: Record<FieldName, [number, number, number]> = {
12
+ totalMassKg: [1, 20000, 1],
13
+ hitchDistanceM: [0.5, 30, 0.1],
14
+ centerPositionM: [-15, 30, 0.1],
15
+ minimumTongueKg: [0, 5000, 1],
16
+ maximumTongueKg: [1, 5000, 1],
17
+ };
18
+
19
+ function isUnit(value: unknown): value is UnitSystem {
20
+ return value === 'metric' || value === 'imperial';
21
+ }
22
+
23
+ function isMassField(field: FieldName): boolean {
24
+ return field.endsWith('Kg');
25
+ }
26
+
27
+ function parseDisplayedValue(value: number, field: FieldName, unit: UnitSystem): number {
28
+ return isMassField(field) ? parseMass(value, unit) : parseDistance(value, unit);
29
+ }
30
+
31
+ function displayValue(value: number, field: FieldName, unit: UnitSystem): string {
32
+ const converted = isMassField(field) ? convertMass(value, unit) : convertDistance(value, unit);
33
+ return String(Math.round(converted * 10) / 10);
34
+ }
35
+
36
+ function readInputs(root: HTMLElement, unit: UnitSystem): TrailerInputs {
37
+ const values = {} as Record<FieldName, number>;
38
+ (Object.keys(DEFAULT_INPUTS) as FieldName[]).forEach((field) => {
39
+ const input = root.querySelector<HTMLInputElement>(`[data-ttw-field="${field}"][data-ttw-number]`);
40
+ values[field] = input ? parseDisplayedValue(Number(input.value), field, unit) : DEFAULT_INPUTS[field];
41
+ });
42
+ return values as TrailerInputs;
43
+ }
44
+
45
+ function syncField(root: HTMLElement, field: FieldName, value: number, unit: UnitSystem): void {
46
+ const [minimum, maximum, step] = FIELD_LIMITS[field];
47
+ const conversion = isMassField(field) ? convertMass : convertDistance;
48
+ root.querySelectorAll<HTMLInputElement>(`[data-ttw-field="${field}"]`).forEach((input) => {
49
+ input.value = displayValue(value, field, unit);
50
+ input.min = String(conversion(minimum, unit));
51
+ input.max = String(conversion(maximum, unit));
52
+ input.step = String(conversion(step, unit));
53
+ });
54
+ }
55
+
56
+ function syncUnits(root: HTMLElement, unit: UnitSystem): void {
57
+ const massUnit = unit === 'metric' ? 'kg' : 'lb';
58
+ const distanceUnit = unit === 'metric' ? 'm' : 'ft';
59
+ root.querySelectorAll<HTMLElement>('[data-ttw-mass-unit]').forEach((element) => { element.textContent = massUnit; });
60
+ root.querySelectorAll<HTMLElement>('[data-ttw-distance-unit]').forEach((element) => { element.textContent = distanceUnit; });
61
+ }
62
+
63
+ function setPresetState(root: HTMLElement, presetId: string): void {
64
+ root.querySelectorAll<HTMLButtonElement>('[data-ttw-preset]').forEach((button) => {
65
+ const active = button.dataset.ttwPreset === presetId;
66
+ button.classList.toggle('is-active', active);
67
+ button.setAttribute('aria-pressed', String(active));
68
+ });
69
+ }
70
+
71
+ function update(root: HTMLElement, ui: TrailerTongueWeightCalculatorUI, unit: UnitSystem): void {
72
+ const inputs = readInputs(root, unit);
73
+ const plan = calculateTrailerPlan(inputs);
74
+ const status = evaluateTrailerStatus(inputs, plan);
75
+ renderTrailerPlan({ root, inputs, plan, unit, ui }, status);
76
+ saveTrailerState({ inputs, unit });
77
+ }
78
+
79
+ function applyInputs(root: HTMLElement, inputs: TrailerInputs, unit: UnitSystem): void {
80
+ (Object.keys(inputs) as FieldName[]).forEach((field) => syncField(root, field, inputs[field], unit));
81
+ syncUnits(root, unit);
82
+ }
83
+
84
+ function bindField(root: HTMLElement, ui: TrailerTongueWeightCalculatorUI, unitState: UnitState, field: FieldName): void {
85
+ root.querySelectorAll<HTMLInputElement>(`[data-ttw-field="${field}"]`).forEach((input) => {
86
+ input.addEventListener('input', () => {
87
+ const numberInput = root.querySelector<HTMLInputElement>(`[data-ttw-field="${field}"][data-ttw-number]`);
88
+ if (numberInput && input !== numberInput) numberInput.value = input.value;
89
+ update(root, ui, unitState.value);
90
+ });
91
+ });
92
+ }
93
+
94
+ function bindPresets(root: HTMLElement, ui: TrailerTongueWeightCalculatorUI, unitState: UnitState): void {
95
+ root.querySelectorAll<HTMLButtonElement>('[data-ttw-preset]').forEach((button) => {
96
+ button.addEventListener('click', () => {
97
+ const id = button.dataset.ttwPreset as 'balanced' | 'front-heavy' | 'rear-heavy';
98
+ applyInputs(root, getPreset(id).inputs, unitState.value);
99
+ setPresetState(root, id);
100
+ update(root, ui, unitState.value);
101
+ });
102
+ });
103
+ }
104
+
105
+ function bindUnitToggle(root: HTMLElement, ui: TrailerTongueWeightCalculatorUI, unitState: UnitState): void {
106
+ root.querySelectorAll<HTMLButtonElement>('[data-ttw-unit]').forEach((button) => {
107
+ button.addEventListener('click', () => {
108
+ const inputs = readInputs(root, unitState.value);
109
+ unitState.value = button.dataset.ttwUnit === 'imperial' ? 'imperial' : 'metric';
110
+ root.dataset.unit = unitState.value;
111
+ root.querySelectorAll<HTMLButtonElement>('[data-ttw-unit]').forEach((option) => option.setAttribute('aria-pressed', String(option.dataset.ttwUnit === unitState.value)));
112
+ applyInputs(root, inputs, unitState.value);
113
+ update(root, ui, unitState.value);
114
+ });
115
+ });
116
+ }
117
+
118
+ function bindReset(root: HTMLElement, ui: TrailerTongueWeightCalculatorUI, unitState: UnitState): void {
119
+ root.querySelector<HTMLButtonElement>('[data-ttw-reset]')?.addEventListener('click', () => {
120
+ clearTrailerState();
121
+ unitState.value = 'metric';
122
+ root.dataset.unit = 'metric';
123
+ applyInputs(root, DEFAULT_INPUTS, unitState.value);
124
+ setPresetState(root, 'balanced');
125
+ update(root, ui, unitState.value);
126
+ });
127
+ }
128
+
129
+ export function initTrailerTongueWeightCalculator(ui: TrailerTongueWeightCalculatorUI): void {
130
+ const root = document.querySelector<HTMLElement>('[data-trailer-tongue-weight]');
131
+ if (!root) return;
132
+ const stored = loadTrailerState();
133
+ const unitState: UnitState = { value: isUnit(stored?.unit) ? stored.unit : 'metric' };
134
+ const inputs = stored?.inputs ?? DEFAULT_INPUTS;
135
+ root.dataset.unit = unitState.value;
136
+ applyInputs(root, inputs, unitState.value);
137
+ root.querySelectorAll<HTMLButtonElement>('[data-ttw-unit]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.ttwUnit === unitState.value)));
138
+ (Object.keys(DEFAULT_INPUTS) as FieldName[]).forEach((field) => bindField(root, ui, unitState, field));
139
+ bindPresets(root, ui, unitState);
140
+ bindUnitToggle(root, ui, unitState);
141
+ bindReset(root, ui, unitState);
142
+ update(root, ui, unitState.value);
143
+ }
@@ -0,0 +1,79 @@
1
+ import type { TrailerStatus } from './evaluator';
2
+ import type { TrailerInputs, TrailerPlan, UnitSystem } from './logic';
3
+ import type { TrailerTongueWeightCalculatorUI } from './ui';
4
+
5
+ const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
6
+
7
+ export function formatNumber(value: number): string {
8
+ return numberFormat.format(Math.round(value * 10) / 10);
9
+ }
10
+
11
+ export function formatMass(valueKg: number, unit: UnitSystem): string {
12
+ return `${formatNumber(unit === 'metric' ? valueKg : valueKg / 0.45359237)} ${unit === 'metric' ? 'kg' : 'lb'}`;
13
+ }
14
+
15
+ export function formatDistance(valueM: number, unit: UnitSystem): string {
16
+ return `${formatNumber(unit === 'metric' ? valueM : valueM / 0.3048)} ${unit === 'metric' ? 'm' : 'ft'}`;
17
+ }
18
+
19
+ const replaceTemplate = (template: string, values: Record<string, string>): string => Object.entries(values).reduce((text, [key, value]) => text.replaceAll(`{${key}}`, value), template);
20
+
21
+ function setText(root: HTMLElement, selector: string, value: string): void {
22
+ const element = root.querySelector<HTMLElement>(selector);
23
+ if (element) element.textContent = value;
24
+ }
25
+
26
+ function renderScene(root: HTMLElement, inputs: TrailerInputs, plan: TrailerPlan, unit: UnitSystem): void {
27
+ const sceneX = 250 + Math.max(-0.45, Math.min(0.75, inputs.centerPositionM / inputs.hitchDistanceM)) * 220;
28
+ const loadDot = root.querySelector<SVGCircleElement>('[data-ttw-load-dot]');
29
+ const loadLine = root.querySelector<SVGLineElement>('[data-ttw-load-line]');
30
+ if (loadDot) loadDot.setAttribute('cx', String(sceneX));
31
+ if (loadLine) loadLine.setAttribute('x1', String(sceneX));
32
+ if (loadLine) loadLine.setAttribute('x2', String(sceneX));
33
+ setText(root, '[data-ttw-scene-load]', formatMass(inputs.totalMassKg, unit));
34
+ setText(root, '[data-ttw-scene-center]', formatDistance(inputs.centerPositionM, unit));
35
+ setText(root, '[data-ttw-scene-tongue]', formatMass(plan.tongueWeightKg, unit));
36
+ }
37
+
38
+ function renderStatus(root: HTMLElement, status: TrailerStatus, ui: TrailerTongueWeightCalculatorUI): void {
39
+ const statusText = { safe: ui.statusSafe, light: ui.statusLight, heavy: ui.statusHeavy, invalid: ui.statusInvalid }[status];
40
+ const badge = root.querySelector<HTMLElement>('[data-ttw-status]');
41
+ if (badge) {
42
+ badge.textContent = statusText;
43
+ badge.dataset.status = status;
44
+ }
45
+ root.dataset.status = status;
46
+ }
47
+
48
+ interface RenderContext {
49
+ root: HTMLElement;
50
+ inputs: TrailerInputs;
51
+ plan: TrailerPlan;
52
+ unit: UnitSystem;
53
+ ui: TrailerTongueWeightCalculatorUI;
54
+ }
55
+
56
+ function renderShift({ root, inputs, plan, unit, ui }: RenderContext): void {
57
+ const current = inputs.centerPositionM;
58
+ const target = plan.targetCenterPositionM;
59
+ let message = ui.shiftNone;
60
+ if (plan.shiftDistanceM >= 0.01) {
61
+ const template = target > current ? ui.shiftForwardTemplate : ui.shiftRearwardTemplate;
62
+ message = replaceTemplate(template, { distance: formatDistance(plan.shiftDistanceM, unit) });
63
+ }
64
+ setText(root, '[data-ttw-shift]', message);
65
+ }
66
+
67
+ export function renderTrailerPlan(context: RenderContext, status: TrailerStatus): void {
68
+ const { root, inputs, plan, unit, ui } = context;
69
+ renderScene(root, inputs, plan, unit);
70
+ renderStatus(root, status, ui);
71
+ setText(root, '[data-ttw-tongue]', formatMass(plan.tongueWeightKg, unit));
72
+ root.querySelectorAll<HTMLElement>('[data-ttw-percent]').forEach((element) => { element.textContent = `${formatNumber(plan.tonguePercent)}%`; });
73
+ setText(root, '[data-ttw-axle]', formatMass(plan.axleLoadKg, unit));
74
+ setText(root, '[data-ttw-range]', `${formatMass(inputs.minimumTongueKg, unit)} - ${formatMass(inputs.maximumTongueKg, unit)}`);
75
+ const summary = replaceTemplate(ui.summaryTemplate, { tongue: formatMass(plan.tongueWeightKg, unit), percent: `${formatNumber(plan.tonguePercent)}%`, position: formatDistance(inputs.centerPositionM, unit) });
76
+ setText(root, '[data-ttw-summary]', summary);
77
+ setText(root, '[data-ttw-shift-title]', ui.shiftTitle);
78
+ renderShift(context);
79
+ }
@@ -0,0 +1,30 @@
1
+ import type { MotorToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { TrailerTongueWeightCalculatorUI } from './ui';
3
+
4
+ export type { TrailerTongueWeightCalculatorUI };
5
+ export type TrailerTongueWeightCalculatorLocaleContent = ToolLocaleContent<TrailerTongueWeightCalculatorUI>;
6
+
7
+ export const trailerTongueWeightCalculator: MotorToolEntry<TrailerTongueWeightCalculatorUI> = {
8
+ id: 'trailer-tongue-weight-calculator',
9
+ icons: {
10
+ bg: 'mdi:truck-trailer',
11
+ fg: 'mdi:scale-balance',
12
+ },
13
+ i18n: {
14
+ de: () => import('./i18n/de').then((module) => module.content),
15
+ en: () => import('./i18n/en').then((module) => module.content),
16
+ es: () => import('./i18n/es').then((module) => module.content),
17
+ fr: () => import('./i18n/fr').then((module) => module.content),
18
+ id: () => import('./i18n/id').then((module) => module.content),
19
+ it: () => import('./i18n/it').then((module) => module.content),
20
+ ja: () => import('./i18n/ja').then((module) => module.content),
21
+ ko: () => import('./i18n/ko').then((module) => module.content),
22
+ nl: () => import('./i18n/nl').then((module) => module.content),
23
+ pl: () => import('./i18n/pl').then((module) => module.content),
24
+ pt: () => import('./i18n/pt').then((module) => module.content),
25
+ ru: () => import('./i18n/ru').then((module) => module.content),
26
+ sv: () => import('./i18n/sv').then((module) => module.content),
27
+ tr: () => import('./i18n/tr').then((module) => module.content),
28
+ zh: () => import('./i18n/zh').then((module) => module.content),
29
+ },
30
+ };
@@ -0,0 +1,10 @@
1
+ import type { TrailerInputs, TrailerPlan } from './logic';
2
+
3
+ export type TrailerStatus = 'safe' | 'light' | 'heavy' | 'invalid';
4
+
5
+ export function evaluateTrailerStatus(inputs: Pick<TrailerInputs, 'minimumTongueKg' | 'maximumTongueKg'>, plan: TrailerPlan): TrailerStatus {
6
+ if (!plan.valid || inputs.maximumTongueKg <= inputs.minimumTongueKg) return 'invalid';
7
+ if (plan.tongueWeightKg < inputs.minimumTongueKg) return 'light';
8
+ if (plan.tongueWeightKg > inputs.maximumTongueKg) return 'heavy';
9
+ return 'safe';
10
+ }
@@ -0,0 +1,39 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import { bibliographyEntries } from '../bibliography';
4
+ import type { TrailerTongueWeightCalculatorUI } from '../ui';
5
+
6
+ const ui: TrailerTongueWeightCalculatorUI = {
7
+ metricLabel: 'Metrisch kg / m', imperialLabel: 'Imperial lb / ft', resetLabel: 'Rechner zurücksetzen', presetLabel: 'Ladeposition testen', balancedPreset: 'Ausgeglichen', frontHeavyPreset: 'Vorne schwer', rearHeavyPreset: 'Hinten schwer', presetMetaTemplate: '{mass} geladen, Schwerpunkt {position}', geometryLabel: 'Geometrie des Anhängers', geometryHint: 'Platziere den Ladungsschwerpunkt zwischen Kupplung und Achse', totalMassLabel: 'Geladene Anhängermasse', totalMassHint: 'Anhänger und Ladung in fahrbereitem Zustand', hitchDistanceLabel: 'Abstand Kupplung bis Achse', hitchDistanceHint: 'Vom Kupplungspunkt bis zur Achsreferenz messen', centerPositionLabel: 'Ladungsschwerpunkt ab Achse', centerPositionHint: 'Positiv zur Kupplung, negativ hinter der Achse', tongueLimitsLabel: 'Kupplungsgrenzen', tongueLimitsHint: 'Verwende die Werte von Fahrzeug und Kupplung', minimumTongueLabel: 'Minimale Stützlast', minimumTongueHint: 'Gewünschte Abwärtslast an der Kupplung', maximumTongueLabel: 'Maximale Stützlast', maximumTongueHint: 'Der niedrigere Wert aus Fahrzeug- und Kupplungsgrenze', resultKicker: 'Gleichgewicht an der Kupplung', resultTitle: 'Sieh, wohin die Ladung wirkt', sceneLabel: 'Seitenansicht des Anhängers mit Kupplung, Achse, Schwerpunkt und Stützlast', hitchLabel: 'Kupplung', axleLabel: 'Achse', centerOfMassLabel: 'Schwerpunkt', loadedMassLabel: 'Geladene Masse', tongueWeightLabel: 'Stützlast', tonguePercentLabel: 'Anteil an der Kupplung', axleLoadLabel: 'Restlast auf der Achse', allowedRangeLabel: 'Dein zulässiger Bereich', statusSafe: 'Im Bereich', statusLight: 'Zu leicht', statusHeavy: 'Zu schwer', statusInvalid: 'Grenzen prüfen', shiftTitle: 'Praktische Anpassung der Ladung', shiftForwardTemplate: 'Verschiebe den Gesamtschwerpunkt etwa {distance} zur Kupplung, um die Mitte deines Bereichs zu erreichen.', shiftRearwardTemplate: 'Verschiebe den Gesamtschwerpunkt etwa {distance} zur Achse, um die Mitte deines Bereichs zu erreichen.', shiftNone: 'Der aktuelle Schwerpunkt liegt bereits nahe der Mitte deines Bereichs.', summaryTemplate: 'Bei {position} ab der Achse wirken {tongue} an der Kupplung, also {percent} der geladenen Anhängermasse.', formulaLabel: 'Modell', formulaText: 'Stützlast = geladene Masse × Abstand des Schwerpunkts ÷ Abstand Kupplung bis Achse. Das Modell betrachtet den stehenden Anhänger und den eingegebenen Gesamtschwerpunkt.', inputUnitMass: 'kg', inputUnitDistance: 'm', outputUnitMass: 'kg',
8
+ };
9
+
10
+ const faq = [
11
+ { question: 'Was schätzt dieser Stützlastrechner?', answer: 'Er schätzt die Abwärtslast an der Kupplung aus geladener Anhängermasse, Abstand zwischen Kupplung und Achse sowie dem Gesamtschwerpunkt ab der Achse. Danach vergleicht er das Ergebnis mit deinen eingegebenen Grenzen.' },
12
+ { question: 'Warum wird die Stützlast kleiner, wenn die Ladung nach hinten wandert?', answer: 'Der Hebelarm zur Kupplung wird kürzer, wenn der Schwerpunkt zur Achse oder dahinter wandert. Im statischen Gleichgewicht sinkt dadurch die Abwärtslast an der Kupplung.' },
13
+ { question: 'Welche Grenze soll ich eingeben?', answer: 'Verwende die für deine Kombination gültigen Werte. Als Maximum zählt der niedrigste Wert aus Fahrzeug, Anhängerkupplung, Kupplung und Anhänger. Der Rechner prüft keine Herstellervorgaben.' },
14
+ { question: 'Ersetzt das Ergebnis eine Wägung?', answer: 'Nein. Es ist eine Planungshilfe für eine Ladungsposition. Prüfe die Herstellerdaten und miss die tatsächliche Stützlast vor der Fahrt mit einer geeigneten Waage.' },
15
+ { question: 'Was bedeutet die vorgeschlagene Verschiebung?', answer: 'Sie zeigt die Änderung des Gesamtschwerpunkts bis zur Mitte deines Bereichs. Sie sagt nicht, dass ein einzelner Gegenstand genau diese Strecke bewegt werden muss.' },
16
+ ];
17
+ const howTo = [
18
+ { name: 'Einheit wählen', text: 'Wähle metrische kg / m oder imperiale lb / ft, bevor du Maße eingibst.' },
19
+ { name: 'Geometrie eingeben', text: 'Gib die geladene Gesamtmasse, den Abstand von Kupplung zu Achse und den Schwerpunkt ab der Achse ein.' },
20
+ { name: 'Grenzen eintragen', text: 'Trage minimale gewünschte und maximale zulässige Stützlast ein.' },
21
+ { name: 'Ergebnis prüfen', text: 'Lies Szene, Bereich und Richtung der empfohlenen Schwerpunktverschiebung.' },
22
+ ];
23
+ const schemas: ToolLocaleContent<TrailerTongueWeightCalculatorUI>['schemas'] = [
24
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) } as WithContext<FAQPage>,
25
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: 'Stützlast vor dem Ziehen schätzen', description: 'Statische Stützlast aus Masse und Schwerpunkt berechnen.', step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) } as WithContext<HowTo>,
26
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Stützlastrechner für Anhänger', description: 'Stützlast aus geladener Masse, Achsabstand und Schwerpunkt im Browser schätzen.', applicationCategory: 'UtilitiesApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, inLanguage: 'de' } as WithContext<SoftwareApplication>,
27
+ ];
28
+ export const content: ToolLocaleContent<TrailerTongueWeightCalculatorUI> = { slug: 'rechner-stuetzlast-anhaenger', title: 'Stützlastrechner für Anhänger', description: 'Schätze die Stützlast deines Anhängers aus Masse, Achsabstand und Schwerpunkt vor der Fahrt.', ui, faqTitle: 'Häufige Fragen', faq, howTo, bibliographyTitle: 'Bibliografische Quellen', bibliography: bibliographyEntries, schemas, seo: [
29
+ { type: 'title', text: 'Die Last an der Anhängerkupplung berechnen', level: 2 },
30
+ { type: 'paragraph', html: 'Dieser Rechner übersetzt eine Ladungsposition in eine statische Schätzung der Abwärtslast an der Kupplung. Gib die fahrbereite Anhängermasse, den Abstand von der Achse zur Kupplung und den Gesamtschwerpunkt ein.' },
31
+ { type: 'paragraph', html: 'So kannst du eine Beladung prüfen, bevor du Kisten, Werkzeug oder Fahrräder umstellst. Das Ergebnis zeigt Stützlast, Prozentanteil, Restlast auf der Achse und den Vergleich mit deinen Grenzen.' },
32
+ { type: 'title', text: 'Warum die Position der Ladung zählt', level: 2 },
33
+ { type: 'paragraph', html: 'Das Modell nutzt das statische Drehgleichgewicht um die Achse. Ein Schwerpunkt näher an der Kupplung erhöht seinen Hebelarm und überträgt mehr Last auf die Kupplung. Ein Schwerpunkt an oder hinter der Achse verringert sie.' },
34
+ { type: 'table', headers: ['Eingabe', 'Rolle im Modell', 'Nutzen'], rows: [['Geladene Masse', 'Gesamte Abwärtslast', 'Vollständige Ladung berücksichtigen'], ['Kupplung bis Achse', 'Hebelarm', 'Reale Geometrie verwenden'], ['Schwerpunkt ab Achse', 'Lage der Gesamtmasse', 'Vordere und hintere Beladung testen'], ['Stützlastgrenzen', 'Vergleichsbereich', 'Umverteilung erkennen']] },
35
+ { type: 'title', text: 'Das Ergebnis vor der Fahrt nutzen', level: 2 },
36
+ { type: 'paragraph', html: 'Verwende die geladene Masse und vergleiche die berechnete Stützlast mit dem niedrigsten gültigen Grenzwert aus Fahrzeug, Kupplung und Anhänger. Diese Angaben kennt der Rechner nicht automatisch.' },
37
+ { type: 'list', items: ['Schwere Gegenstände tief und möglichst nahe an der Achse platzieren, sofern die Anleitung das erlaubt.', 'Die vorgeschlagene Richtung für eine neue Schwerpunktposition testen.', 'Nach jeder Änderung sichern und die tatsächliche Stützlast vor der Fahrt messen.'] },
38
+ { type: 'tip', title: 'Eine Ladehilfe ist keine rechtliche Freigabe', html: 'Der Rechner nimmt einen stehenden starren Anhänger und einen Gesamtschwerpunkt an. Dynamische Kräfte, Bremsung, Steigung, Reifenlasten und Herstellergrenzen sind nicht enthalten.' },
39
+ ] };
@@ -0,0 +1,63 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import { bibliographyEntries } from '../bibliography';
4
+ import type { TrailerTongueWeightCalculatorUI } from '../ui';
5
+
6
+ const ui: TrailerTongueWeightCalculatorUI = {
7
+ metricLabel: 'Metric kg / m', imperialLabel: 'Imperial lb / ft', resetLabel: 'Reset calculator',
8
+ presetLabel: 'Try a loading position', balancedPreset: 'Balanced', frontHeavyPreset: 'Front heavy', rearHeavyPreset: 'Rear heavy',
9
+ presetMetaTemplate: '{mass} loaded, center {position}', geometryLabel: 'Trailer geometry', geometryHint: 'Place the loaded center between the hitch and the axle',
10
+ totalMassLabel: 'Loaded trailer mass', totalMassHint: 'The complete trailer and load ready to tow', hitchDistanceLabel: 'Hitch to axle distance',
11
+ hitchDistanceHint: 'Measure from the coupling point to the axle reference', centerPositionLabel: 'Load center from axle',
12
+ centerPositionHint: 'Positive is toward the hitch; negative is behind the axle', tongueLimitsLabel: 'Coupling limits',
13
+ tongueLimitsHint: 'Use the limits printed for your vehicle and hitch', minimumTongueLabel: 'Minimum tongue load',
14
+ minimumTongueHint: 'The downward load you want at the coupling', maximumTongueLabel: 'Maximum tongue load',
15
+ maximumTongueHint: 'The lower of your hitch and vehicle limits', resultKicker: 'Balance at the coupling',
16
+ resultTitle: 'See where the load is asking to go', sceneLabel: 'Trailer side view showing the hitch, axle, center of mass, and calculated tongue load',
17
+ hitchLabel: 'Hitch', axleLabel: 'Axle', centerOfMassLabel: 'Load center', loadedMassLabel: 'Loaded mass', tongueWeightLabel: 'Tongue load',
18
+ tonguePercentLabel: 'Share on hitch', axleLoadLabel: 'Remaining on axle', allowedRangeLabel: 'Your allowed range', statusSafe: 'Inside range',
19
+ statusLight: 'Too light', statusHeavy: 'Too heavy', statusInvalid: 'Check limits', shiftTitle: 'A practical loading adjustment',
20
+ shiftForwardTemplate: 'Move the overall load center about {distance} toward the hitch to reach the middle of your range.',
21
+ shiftRearwardTemplate: 'Move the overall load center about {distance} toward the axle to reach the middle of your range.',
22
+ shiftNone: 'The current center is already close to the middle of your selected range.',
23
+ summaryTemplate: 'At {position} from the axle, {tongue} reaches the hitch, or {percent} of the loaded trailer mass.',
24
+ formulaLabel: 'Model', formulaText: 'Tongue load = loaded mass × load-center distance ÷ hitch-to-axle distance. The model treats the trailer as stationary and uses the overall center of mass you enter.',
25
+ inputUnitMass: 'kg', inputUnitDistance: 'm', outputUnitMass: 'kg',
26
+ };
27
+
28
+ const faq = [
29
+ { question: 'What does the trailer tongue weight calculator estimate?', answer: 'It estimates the downward load at the coupling from the loaded trailer mass, the hitch-to-axle distance, and the overall load center measured from the axle. It then compares that estimate with the minimum and maximum limits you enter.' },
30
+ { question: 'Why does moving the load toward the rear make tongue load lighter?', answer: 'The load center has a shorter lever arm to the hitch when it moves toward the axle or behind it. In the static balance model, tongue load changes in proportion to that distance, so a rearward center reduces the downward coupling load and can make the trailer less stable.' },
31
+ { question: 'Which tongue limit should I enter?', answer: 'Enter the limits that apply to your actual combination. The maximum should be the lower of the vehicle, towbar, coupling, and trailer limits. The calculator does not look up a manufacturer specification or certify that a setup is legal.' },
32
+ { question: 'Can this tool replace weighing the trailer?', answer: 'No. It is a planning model for testing a loading position before you move the trailer. Use the result with the manufacturer data and confirm the actual coupling load with an appropriate scale before travelling.' },
33
+ { question: 'What does the suggested adjustment mean?', answer: 'It is the change in the overall center of mass needed to reach the midpoint of your selected tongue-load range. It is not a prescription to move a particular object by that distance, because the required movement depends on the mass of the object you can relocate.' },
34
+ ];
35
+
36
+ const howTo = [
37
+ { name: 'Choose a unit system', text: 'Select Metric kg / m or Imperial lb / ft before entering measurements.' },
38
+ { name: 'Enter the loaded geometry', text: 'Add the complete loaded trailer mass, the hitch-to-axle distance, and the overall load-center distance from the axle.' },
39
+ { name: 'Add the coupling limits', text: 'Enter the minimum downward load you want and the lower of the maximum limits that apply to your vehicle and hitch.' },
40
+ { name: 'Read and adjust the result', text: 'Check the balance scene, the tongue-load range, and the suggested direction for moving the overall load center.' },
41
+ ];
42
+
43
+ const schemas: ToolLocaleContent<TrailerTongueWeightCalculatorUI>['schemas'] = [
44
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) } as WithContext<FAQPage>,
45
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: 'Estimate a trailer tongue load before towing', description: 'Calculate a stationary trailer tongue load from mass and load-center position.', step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) } as WithContext<HowTo>,
46
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Trailer Tongue Weight Calculator', description: 'Estimate trailer tongue load from loaded mass, axle distance, and load-center position in your browser.', applicationCategory: 'UtilitiesApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, inLanguage: 'en' } as WithContext<SoftwareApplication>,
47
+ ];
48
+
49
+ export const content: ToolLocaleContent<TrailerTongueWeightCalculatorUI> = {
50
+ slug: 'trailer-tongue-weight-calculator', title: 'Trailer Tongue Weight Calculator', description: 'Estimate the downward trailer tongue load from your loaded mass, axle distance, and load-center position before you tow.', ui, faqTitle: 'Frequently asked questions', faq, howTo, bibliographyTitle: 'Bibliographic references', bibliography: bibliographyEntries, schemas,
51
+ seo: [
52
+ { type: 'title', text: 'Calculate the Load at Your Trailer Hitch', level: 2 },
53
+ { type: 'paragraph', html: 'This calculator turns a loading position into a useful static estimate of the downward load at the trailer coupling. Enter the trailer mass as it will travel, measure the distance from the axle reference to the hitch, and describe where the overall center of mass sits between them.' },
54
+ { type: 'paragraph', html: 'The result helps you test a loading plan before moving boxes, tools, bikes, or other cargo. It reports the estimated tongue load, the percentage of trailer mass at the coupling, the remaining load on the axle, and whether the result sits inside the limits you provide.' },
55
+ { type: 'title', text: 'How Load Position Changes Tongue Load', level: 2 },
56
+ { type: 'paragraph', html: 'The model uses static rotational equilibrium around the axle. If the loaded center moves toward the hitch, its lever arm increases and more of the trailer load is transferred to the coupling. If the center moves toward or behind the axle, the coupling load falls. A negative center position means the overall center is behind the axle.' },
57
+ { type: 'table', headers: ['Input', 'Role in the model', 'Decision it supports'], rows: [['Loaded trailer mass', 'Total downward load in the stationary model', 'Check that the complete trailer and cargo are represented'], ['Hitch to axle distance', 'Lever arm between the coupling and axle', 'Use the actual geometry, not the trailer body length'], ['Load center from axle', 'Position of the combined center of mass', 'Test front, balanced, and rear loading'], ['Tongue limits', 'Comparison band supplied by you', 'Flag a plan that needs redistribution or verification']] },
58
+ { type: 'title', text: 'Use the Output Before You Move the Trailer', level: 2 },
59
+ { type: 'paragraph', html: 'Start with the loaded mass you expect to tow, not the empty trailer rating. Then compare the calculated coupling load with the lowest applicable limit from the vehicle, towbar, coupling, and trailer documentation. The tool cannot know which specification governs your setup, so that choice remains yours.' },
60
+ { type: 'list', items: ['Put heavy items low and close to the axle when that matches the trailer instructions.', 'Use the suggested direction to test a revised overall center of mass.', 'Secure every item after redistributing it and check the coupling load with a suitable scale before a real trip.'] },
61
+ { type: 'tip', title: 'Treat the estimate as a loading check, not a legal limit', html: 'The calculator assumes a stationary rigid trailer and a single overall center of mass. It does not include dynamic forces, braking, road slope, suspension geometry, tyre loads, manufacturer ratings, or a measurement of the actual coupling.' },
62
+ ],
63
+ };