@jjlmoya/utils-motor 1.10.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.
- package/package.json +2 -2
- package/scripts/validate-icons.mjs +57 -0
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/index.ts +5 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/trailerTongueWeightCalculator/bibliography.astro +6 -0
- package/src/tool/trailerTongueWeightCalculator/bibliography.ts +16 -0
- package/src/tool/trailerTongueWeightCalculator/component.astro +116 -0
- package/src/tool/trailerTongueWeightCalculator/controller.ts +143 -0
- package/src/tool/trailerTongueWeightCalculator/dom-views.ts +79 -0
- package/src/tool/trailerTongueWeightCalculator/entry.ts +30 -0
- package/src/tool/trailerTongueWeightCalculator/evaluator.ts +10 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/de.ts +39 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/en.ts +63 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/es.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/fr.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/id.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/it.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/ja.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/ko.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/nl.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/pl.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/pt.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/ru.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/sv.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/tr.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/i18n/zh.ts +38 -0
- package/src/tool/trailerTongueWeightCalculator/index.ts +10 -0
- package/src/tool/trailerTongueWeightCalculator/logic.test.ts +44 -0
- package/src/tool/trailerTongueWeightCalculator/logic.ts +91 -0
- package/src/tool/trailerTongueWeightCalculator/seo.astro +15 -0
- package/src/tool/trailerTongueWeightCalculator/storage.ts +32 -0
- package/src/tool/trailerTongueWeightCalculator/trailer-tongue-weight-calculator.css +429 -0
- package/src/tool/trailerTongueWeightCalculator/ui.ts +49 -0
- 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.
|
|
3
|
+
"version": "1.12.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
|
+
}
|
package/src/category/index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(7);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('motorCategory should be defined', () => {
|
|
@@ -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
|
+
] };
|