@jjlmoya/utils-motor 1.3.0 → 1.4.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/motorcycleSkidMarkSpeedEstimator/bibliography.astro +6 -0
  8. package/src/tool/motorcycleSkidMarkSpeedEstimator/bibliography.ts +12 -0
  9. package/src/tool/motorcycleSkidMarkSpeedEstimator/component.astro +166 -0
  10. package/src/tool/motorcycleSkidMarkSpeedEstimator/controller.ts +207 -0
  11. package/src/tool/motorcycleSkidMarkSpeedEstimator/dom-views.ts +101 -0
  12. package/src/tool/motorcycleSkidMarkSpeedEstimator/entry.ts +30 -0
  13. package/src/tool/motorcycleSkidMarkSpeedEstimator/evaluator.ts +7 -0
  14. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/de.ts +185 -0
  15. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/en.ts +185 -0
  16. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/es.ts +185 -0
  17. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/fr.ts +185 -0
  18. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/id.ts +185 -0
  19. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/it.ts +185 -0
  20. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/ja.ts +201 -0
  21. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/ko.ts +201 -0
  22. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/nl.ts +185 -0
  23. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/pl.ts +185 -0
  24. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/pt.ts +185 -0
  25. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/ru.ts +185 -0
  26. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/sv.ts +185 -0
  27. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/tr.ts +185 -0
  28. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/zh.ts +201 -0
  29. package/src/tool/motorcycleSkidMarkSpeedEstimator/index.ts +10 -0
  30. package/src/tool/motorcycleSkidMarkSpeedEstimator/logic.test.ts +229 -0
  31. package/src/tool/motorcycleSkidMarkSpeedEstimator/logic.ts +179 -0
  32. package/src/tool/motorcycleSkidMarkSpeedEstimator/motorcycle-skid-mark-speed-estimator.css +523 -0
  33. package/src/tool/motorcycleSkidMarkSpeedEstimator/seo.astro +15 -0
  34. package/src/tool/motorcycleSkidMarkSpeedEstimator/storage.ts +49 -0
  35. package/src/tool/motorcycleSkidMarkSpeedEstimator/ui.ts +46 -0
  36. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-motor",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,12 +1,13 @@
1
1
  import type { MotorCategoryEntry, MotorToolEntry } from '../types';
2
2
  import { brakingDistanceCalculator } from '../tool/brakingDistanceCalculator/entry';
3
3
  import { carMotorcycleGearRatioCalculator } from '../tool/carMotorcycleGearRatioCalculator/entry';
4
+ import { motorcycleSkidMarkSpeedEstimator } from '../tool/motorcycleSkidMarkSpeedEstimator/entry';
4
5
  import { realFuelConsumptionCalculator } from '../tool/realFuelConsumptionCalculator/entry';
5
6
  import { tirePressureConverter } from '../tool/tirePressureConverter/entry';
6
7
 
7
8
  export const motorCategory: MotorCategoryEntry = {
8
9
  icon: 'mdi:car-cog',
9
- tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter] as unknown as MotorToolEntry<Record<string, string>>[],
10
+ tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator] as unknown as MotorToolEntry<Record<string, string>>[],
10
11
  i18n: {
11
12
  es: () => import('./i18n/es').then((module) => module.content),
12
13
  en: () => import('./i18n/en').then((module) => module.content),
package/src/entries.ts CHANGED
@@ -2,6 +2,8 @@ export { brakingDistanceCalculator } from './tool/brakingDistanceCalculator/entr
2
2
  export type { BrakingDistanceCalculatorLocaleContent } from './tool/brakingDistanceCalculator/entry';
3
3
  export { carMotorcycleGearRatioCalculator } from './tool/carMotorcycleGearRatioCalculator/entry';
4
4
  export type { CarMotorcycleGearRatioCalculatorLocaleContent } from './tool/carMotorcycleGearRatioCalculator/entry';
5
+ export { motorcycleSkidMarkSpeedEstimator } from './tool/motorcycleSkidMarkSpeedEstimator/entry';
6
+ export type { MotorcycleSkidMarkSpeedEstimatorLocaleContent } from './tool/motorcycleSkidMarkSpeedEstimator/entry';
5
7
  export { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator/entry';
6
8
  export type { RealFuelConsumptionCalculatorLocaleContent } from './tool/realFuelConsumptionCalculator/entry';
7
9
  export { tirePressureConverter } from './tool/tirePressureConverter/entry';
@@ -10,7 +12,8 @@ export { motorCategory } from './category';
10
12
 
11
13
  import { brakingDistanceCalculator } from './tool/brakingDistanceCalculator/entry';
12
14
  import { carMotorcycleGearRatioCalculator } from './tool/carMotorcycleGearRatioCalculator/entry';
15
+ import { motorcycleSkidMarkSpeedEstimator } from './tool/motorcycleSkidMarkSpeedEstimator/entry';
13
16
  import { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator/entry';
14
17
  import { tirePressureConverter } from './tool/tirePressureConverter/entry';
15
18
 
16
- export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter];
19
+ export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator];
package/src/index.ts CHANGED
@@ -36,3 +36,8 @@ export type {
36
36
  TirePressureConverterUI,
37
37
  TirePressureConverterLocaleContent,
38
38
  } from './tool/tirePressureConverter';
39
+ export { MOTORCYCLE_SKID_MARK_SPEED_ESTIMATOR_TOOL, motorcycleSkidMarkSpeedEstimator } from './tool/motorcycleSkidMarkSpeedEstimator';
40
+ export type {
41
+ MotorcycleSkidMarkSpeedEstimatorUI,
42
+ MotorcycleSkidMarkSpeedEstimatorLocaleContent,
43
+ } from './tool/motorcycleSkidMarkSpeedEstimator';
@@ -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(4);
7
+ expect(ALL_TOOLS.length).toBe(5);
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(4);
8
+ expect(ALL_TOOLS.length).toBe(5);
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,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: '49 CFR 571.122 Motorcycle brake systems, including independent front and rear controls, wheel lock, and peak braking coefficient test surfaces',
6
+ url: 'https://www.ecfr.gov/current/title-49/subtitle-B/chapter-V/part-571/subpart-B/section-571.122',
7
+ },
8
+ {
9
+ name: 'FHWA Technical Advisory T 5040.38 Pavement Friction Management, locked-wheel method as the analog of emergency braking without ABS',
10
+ url: 'https://www.fhwa.dot.gov/pavement/t504038.cfm',
11
+ },
12
+ ];
@@ -0,0 +1,166 @@
1
+ ---
2
+ interface Props {
3
+ ui: Record<string, string>;
4
+ }
5
+
6
+ const { ui } = Astro.props;
7
+ ---
8
+
9
+ <div class="trace" data-trace-root data-surface="dry-asphalt" data-unit="metric" data-brake="both" data-eval="ready">
10
+ <div class="trace-card">
11
+ <div class="trace-header">
12
+ <div class="trace-control-group">
13
+ <span class="trace-label-sm">{ui.unitMetric} / {ui.unitImperial}</span>
14
+ <div class="trace-segmented" role="group" aria-label={ui.unitMetric}>
15
+ <button type="button" class="trace-pill" data-unit-choice="metric">
16
+ <span class="trace-pill-title">{ui.unitMetric}</span>
17
+ <span class="trace-pill-unit">{ui.stencilUnitKmh}</span>
18
+ </button>
19
+ <button type="button" class="trace-pill" data-unit-choice="imperial">
20
+ <span class="trace-pill-title">{ui.unitImperial}</span>
21
+ <span class="trace-pill-unit">{ui.stencilUnitMph}</span>
22
+ </button>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="trace-control-group trace-length-group">
27
+ <label class="trace-length-input-box">
28
+ <span class="trace-input-label">{ui.lengthLabel}</span>
29
+ <div class="trace-input-wrapper">
30
+ <input data-trace-length type="number" min="1" step="0.1" inputmode="decimal" class="trace-numeric-input" />
31
+ <b class="trace-unit-badge" data-length-unit>{ui.lengthUnitM}</b>
32
+ </div>
33
+ </label>
34
+ </div>
35
+
36
+ <div class="trace-control-group">
37
+ <span class="trace-label-sm">{ui.brakeLabel}</span>
38
+ <div class="trace-segmented" role="group" aria-label={ui.brakeLabel}>
39
+ <button type="button" class="trace-pill" data-brake-choice="rear">{ui.brakeRear}</button>
40
+ <button type="button" class="trace-pill" data-brake-choice="front">{ui.brakeFront}</button>
41
+ <button type="button" class="trace-pill" data-brake-choice="both">{ui.brakeBoth}</button>
42
+ <button type="button" class="trace-pill" data-brake-choice="unknown">{ui.brakeUnknown}</button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="trace-canvas-container" data-trace-road>
48
+ <div class="trace-hud-overlay">
49
+ <div class="trace-hud-badge">
50
+ <span class="trace-hud-caption">{ui.speedLabel}</span>
51
+ <div class="trace-hud-speed" data-trace-speed>56.6 km/h</div>
52
+ <div class="trace-hud-range" data-trace-range>51.2 to 62.0 km/h</div>
53
+ </div>
54
+ </div>
55
+
56
+ <svg class="trace-svg" viewBox="0 0 1000 340" role="img" aria-label={ui.speedLabel}>
57
+ <defs>
58
+ <pattern id="trace-grit" width="24" height="24" patternUnits="userSpaceOnUse">
59
+ <rect width="24" height="24" class="trace-fill-road"></rect>
60
+ <circle cx="4" cy="6" r="1.5" class="trace-fill-grit" opacity="0.4"></circle>
61
+ <circle cx="16" cy="18" r="1.2" class="trace-fill-grit" opacity="0.3"></circle>
62
+ <circle cx="11" cy="4" r="1" class="trace-fill-grit" opacity="0.5"></circle>
63
+ <circle cx="20" cy="9" r="1.6" class="trace-fill-grit" opacity="0.35"></circle>
64
+ </pattern>
65
+ <linearGradient id="trace-road-shade" x1="0" y1="0" x2="0" y2="1">
66
+ <stop offset="0%" stop-color="#000000" stop-opacity="0.35"></stop>
67
+ <stop offset="50%" stop-color="#000000" stop-opacity="0"></stop>
68
+ <stop offset="100%" stop-color="#000000" stop-opacity="0.4"></stop>
69
+ </linearGradient>
70
+ </defs>
71
+
72
+ <rect class="trace-pavement" x="0" y="0" width="1000" height="340" fill="url(#trace-grit)"></rect>
73
+ <rect class="trace-pavement-shade" x="0" y="0" width="1000" height="340" fill="url(#trace-road-shade)"></rect>
74
+ <path class="trace-lane" d="M20 188 H980"></path>
75
+
76
+ <path class="trace-scar-line trace-scar-rear" pathLength="1" d="M188 248 C 400 242 640 234 940 224"></path>
77
+ <path class="trace-scar-line trace-scar-front" pathLength="1" d="M300 236 C 500 230 720 224 940 216"></path>
78
+ <path class="trace-stria" pathLength="1" d="M190 244 C 400 238 640 230 930 220"></path>
79
+ <path class="trace-stria" pathLength="1" d="M190 252 C 400 246 640 238 930 228"></path>
80
+
81
+ <g class="trace-machine">
82
+ <circle class="trace-tyre" cx="150" cy="210" r="40"></circle>
83
+ <circle class="trace-tyre" cx="278" cy="204" r="38"></circle>
84
+ <circle class="trace-hub" cx="150" cy="210" r="14"></circle>
85
+ <circle class="trace-hub" cx="278" cy="204" r="13"></circle>
86
+ <circle class="trace-rotor trace-rotor-rear" cx="150" cy="210" r="22" data-brake-choice="rear"></circle>
87
+ <circle class="trace-rotor trace-rotor-front" cx="278" cy="204" r="21" data-brake-choice="front"></circle>
88
+ <path class="trace-swing" d="M150 210 L214 194 L278 204"></path>
89
+ <path class="trace-body" d="M168 194 C 186 164 230 152 262 170 L 270 186 L 214 194 L 176 202 Z"></path>
90
+ <path class="trace-saddle" d="M176 180 C 164 172 150 176 144 188 L 168 194 Z"></path>
91
+ <path class="trace-bars" d="M262 170 L 286 156 L 298 158"></path>
92
+ <path class="trace-pipe" d="M188 202 C 210 216 240 218 268 210"></path>
93
+ <circle class="trace-head" cx="248" cy="146" r="11"></circle>
94
+ <path class="trace-rider" d="M248 156 L 232 184 L 200 194 L 226 172 L 258 182"></path>
95
+ </g>
96
+ </svg>
97
+ </div>
98
+
99
+ <div class="trace-controls-bottom">
100
+ <div class="trace-surfaces-section">
101
+ <h4 class="trace-section-title">{ui.surfaceLabel}</h4>
102
+ <div class="trace-surface-grid" role="group" aria-label={ui.surfaceLabel}>
103
+ <button type="button" class="trace-surface-chip" data-surface-choice="dry-asphalt" data-tone="dry">
104
+ <span class="trace-surface-dot"></span>
105
+ <span>{ui.surfaceDryAsphalt}</span>
106
+ </button>
107
+ <button type="button" class="trace-surface-chip" data-surface-choice="wet-asphalt" data-tone="wet">
108
+ <span class="trace-surface-dot"></span>
109
+ <span>{ui.surfaceWetAsphalt}</span>
110
+ </button>
111
+ <button type="button" class="trace-surface-chip" data-surface-choice="dry-concrete" data-tone="concrete">
112
+ <span class="trace-surface-dot"></span>
113
+ <span>{ui.surfaceDryConcrete}</span>
114
+ </button>
115
+ <button type="button" class="trace-surface-chip" data-surface-choice="gravel" data-tone="gravel">
116
+ <span class="trace-surface-dot"></span>
117
+ <span>{ui.surfaceGravel}</span>
118
+ </button>
119
+ <button type="button" class="trace-surface-chip" data-surface-choice="grass" data-tone="earth">
120
+ <span class="trace-surface-dot"></span>
121
+ <span>{ui.surfaceGrass}</span>
122
+ </button>
123
+ <button type="button" class="trace-surface-chip" data-surface-choice="ice" data-tone="ice">
124
+ <span class="trace-surface-dot"></span>
125
+ <span>{ui.surfaceIce}</span>
126
+ </button>
127
+ <button type="button" class="trace-surface-chip" data-surface-choice="custom" data-tone="steel">
128
+ <span class="trace-surface-dot"></span>
129
+ <span>{ui.surfaceCustom}</span>
130
+ </button>
131
+ </div>
132
+ </div>
133
+
134
+ <div class="trace-sliders-section">
135
+ <div class="trace-slider-card">
136
+ <div class="trace-slider-header">
137
+ <span class="trace-slider-title">{ui.frictionLabel}</span>
138
+ <div class="trace-slider-number-box">
139
+ <input data-trace-friction-number type="number" min="0.05" max="1.2" step="0.01" inputmode="decimal" class="trace-small-input" aria-label={ui.frictionLabel} />
140
+ </div>
141
+ </div>
142
+ <input data-trace-friction type="range" min="0.05" max="1.2" step="0.01" value="0.70" class="trace-range-slider" aria-label={ui.frictionLabel} />
143
+ </div>
144
+
145
+ <div class="trace-slider-card">
146
+ <div class="trace-slider-header">
147
+ <span class="trace-slider-title">{ui.gradeLabel}</span>
148
+ <span class="trace-slider-val-badge" data-grade-value>0%</span>
149
+ </div>
150
+ <input data-trace-grade type="range" min="-20" max="20" step="1" value="0" class="trace-range-slider" aria-label={ui.gradeLabel} />
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <script is:inline type="application/json" data-trace-ui set:html={JSON.stringify(ui)}></script>
158
+ <script>
159
+ import { mountSkidEstimator } from './controller';
160
+
161
+ const root = document.querySelector('[data-trace-root]');
162
+ const payload = document.querySelector('[data-trace-ui]');
163
+ if (root instanceof HTMLElement && payload?.textContent) {
164
+ mountSkidEstimator(root, JSON.parse(payload.textContent));
165
+ }
166
+ </script>
@@ -0,0 +1,207 @@
1
+ import { applyScene, buildSceneView, formatFriction, formatGrade, formatLength, lengthUnit } from './dom-views';
2
+ import { evaluateSkid } from './evaluator';
3
+ import {
4
+ clamp,
5
+ estimateSkidSpeed,
6
+ getSurfaceSpec,
7
+ isBrakeUse,
8
+ isSurfaceId,
9
+ isUnitSystem,
10
+ lengthToMeters,
11
+ MAX_FRICTION,
12
+ MAX_GRADE,
13
+ MAX_LENGTH_M,
14
+ MIN_FRICTION,
15
+ MIN_GRADE,
16
+ MIN_LENGTH_M,
17
+ parsePositiveNumber,
18
+ parseSignedNumber,
19
+ type BrakeUse,
20
+ type SkidInput,
21
+ type SurfaceId,
22
+ type UnitSystem,
23
+ } from './logic';
24
+ import { loadSkidState, saveSkidState } from './storage';
25
+
26
+ type SkidUI = Record<string, string>;
27
+
28
+ interface SkidState {
29
+ lengthMeters: number;
30
+ unit: UnitSystem;
31
+ surface: SurfaceId;
32
+ friction: number;
33
+ gradePercent: number;
34
+ brakeUse: BrakeUse;
35
+ }
36
+
37
+ const DEFAULT_STATE: SkidState = {
38
+ lengthMeters: 18,
39
+ unit: 'metric',
40
+ surface: 'dry-asphalt',
41
+ friction: 0.7,
42
+ gradePercent: 0,
43
+ brakeUse: 'both',
44
+ };
45
+
46
+ function toInput(state: SkidState): SkidInput {
47
+ return {
48
+ lengthMeters: state.lengthMeters,
49
+ friction: state.friction,
50
+ gradePercent: state.gradePercent,
51
+ surface: state.surface,
52
+ brakeUse: state.brakeUse,
53
+ };
54
+ }
55
+
56
+ function restoreState(): SkidState {
57
+ const saved = loadSkidState();
58
+ return saved ? { ...DEFAULT_STATE, ...saved } : { ...DEFAULT_STATE };
59
+ }
60
+
61
+ function queryEl(root: HTMLElement, selector: string): HTMLElement | null {
62
+ return root.querySelector(selector);
63
+ }
64
+
65
+ function queryInput(root: HTMLElement, selector: string): HTMLInputElement | null {
66
+ return root.querySelector(selector);
67
+ }
68
+
69
+ function bindHandle(root: HTMLElement, state: SkidState, ui: SkidUI): void {
70
+ const road = queryEl(root, '[data-trace-road]');
71
+ if (!road) return;
72
+ const move = (event: PointerEvent) => {
73
+ const box = road.getBoundingClientRect();
74
+ if (box.width <= 0) return;
75
+ const ratio = clamp((event.clientX - box.left) / box.width, 0.18, 0.92);
76
+ const meters = MIN_LENGTH_M + ((ratio - 0.18) / 0.74) * (MAX_LENGTH_M - MIN_LENGTH_M);
77
+ state.lengthMeters = clamp(meters, MIN_LENGTH_M, MAX_LENGTH_M);
78
+ render(root, state, ui);
79
+ };
80
+ road.addEventListener('pointerdown', (event) => {
81
+ const target = event.target;
82
+ if (target instanceof Element && target.closest('button, label, input, [data-brake-choice]')) return;
83
+ road.setPointerCapture(event.pointerId);
84
+ move(event);
85
+ });
86
+ road.addEventListener('pointermove', (event) => {
87
+ if (!road.hasPointerCapture(event.pointerId)) return;
88
+ move(event);
89
+ });
90
+ }
91
+
92
+ function markChoice(root: HTMLElement, attr: string, value: string, on: string): void {
93
+ root.querySelectorAll(`[${attr}]`).forEach((node) => {
94
+ node.classList.toggle(on, node.getAttribute(attr) === value);
95
+ });
96
+ }
97
+
98
+ function syncFields(root: HTMLElement, state: SkidState, ui: SkidUI): void {
99
+ const length = queryInput(root, '[data-trace-length]');
100
+ const grade = queryInput(root, '[data-trace-grade]');
101
+ const friction = queryInput(root, '[data-trace-friction]');
102
+ const frictionNumber = queryInput(root, '[data-trace-friction-number]');
103
+ const lengthUnitNode = queryEl(root, '[data-length-unit]');
104
+ const gradeValue = queryEl(root, '[data-grade-value]');
105
+ if (length) length.value = formatLength(state.lengthMeters, state.unit);
106
+ if (grade) grade.value = String(state.gradePercent);
107
+ if (friction) friction.value = formatFriction(state.friction);
108
+ if (frictionNumber) frictionNumber.value = formatFriction(state.friction);
109
+ if (lengthUnitNode) lengthUnitNode.textContent = lengthUnit(ui, state.unit);
110
+ if (gradeValue) gradeValue.textContent = `${formatGrade(state.gradePercent)}${ui.percentSuffix ?? '%'}`;
111
+ root.dataset.unit = state.unit;
112
+ root.dataset.brake = state.brakeUse;
113
+ markChoice(root, 'data-unit-choice', state.unit, 'is-stencil');
114
+ markChoice(root, 'data-surface-choice', state.surface, 'is-core');
115
+ markChoice(root, 'data-brake-choice', state.brakeUse, 'is-lock');
116
+ }
117
+
118
+ function render(root: HTMLElement, state: SkidState, ui: SkidUI): void {
119
+ const input = toInput(state);
120
+ root.dataset.eval = evaluateSkid(input);
121
+ applyScene(root, buildSceneView(input, estimateSkidSpeed(input), state.unit, ui));
122
+ syncFields(root, state, ui);
123
+ saveSkidState(state);
124
+ }
125
+
126
+ function onLengthInput(state: SkidState, raw: string): void {
127
+ const parsed = parsePositiveNumber(raw);
128
+ if (parsed === null) return;
129
+ state.lengthMeters = clamp(lengthToMeters(parsed, state.unit), MIN_LENGTH_M, MAX_LENGTH_M);
130
+ }
131
+
132
+ function onGradeInput(state: SkidState, raw: string): void {
133
+ const parsed = parseSignedNumber(raw);
134
+ if (parsed === null) return;
135
+ state.gradePercent = clamp(parsed, MIN_GRADE, MAX_GRADE);
136
+ }
137
+
138
+ function onFrictionInput(state: SkidState, raw: string): void {
139
+ const parsed = parsePositiveNumber(raw);
140
+ if (parsed === null) return;
141
+ state.friction = clamp(parsed, MIN_FRICTION, MAX_FRICTION);
142
+ state.surface = 'custom';
143
+ }
144
+
145
+ function onSurfaceChoice(state: SkidState, raw: string): void {
146
+ if (!isSurfaceId(raw)) return;
147
+ state.surface = raw;
148
+ if (raw === 'custom') return;
149
+ state.friction = getSurfaceSpec(raw).mu;
150
+ }
151
+
152
+ function onUnitChoice(state: SkidState, raw: string): void {
153
+ if (!isUnitSystem(raw)) return;
154
+ state.unit = raw;
155
+ }
156
+
157
+ function onBrakeChoice(state: SkidState, raw: string): void {
158
+ if (!isBrakeUse(raw)) return;
159
+ state.brakeUse = raw;
160
+ }
161
+
162
+ function bindClicks(root: HTMLElement, attr: string, apply: (value: string) => void): void {
163
+ root.querySelectorAll(`[${attr}]`).forEach((node) => {
164
+ node.addEventListener('click', (event) => {
165
+ event.stopPropagation();
166
+ const value = node.getAttribute(attr);
167
+ if (value) apply(value);
168
+ });
169
+ });
170
+ }
171
+
172
+ function bindNumber(
173
+ root: HTMLElement,
174
+ bind: { selector: string; apply: (raw: string) => void; state: SkidState; ui: SkidUI },
175
+ ): void {
176
+ const input = queryInput(root, bind.selector);
177
+ input?.addEventListener('input', () => {
178
+ bind.apply(input.value);
179
+ render(root, bind.state, bind.ui);
180
+ });
181
+ }
182
+
183
+ function bindInputs(root: HTMLElement, state: SkidState, ui: SkidUI): void {
184
+ bindNumber(root, { selector: '[data-trace-length]', apply: (raw) => onLengthInput(state, raw), state, ui });
185
+ bindNumber(root, { selector: '[data-trace-grade]', apply: (raw) => onGradeInput(state, raw), state, ui });
186
+ bindNumber(root, { selector: '[data-trace-friction]', apply: (raw) => onFrictionInput(state, raw), state, ui });
187
+ bindNumber(root, { selector: '[data-trace-friction-number]', apply: (raw) => onFrictionInput(state, raw), state, ui });
188
+ }
189
+
190
+ export function mountSkidEstimator(root: HTMLElement, ui: SkidUI): void {
191
+ const state = restoreState();
192
+ bindInputs(root, state, ui);
193
+ bindClicks(root, 'data-unit-choice', (value) => {
194
+ onUnitChoice(state, value);
195
+ render(root, state, ui);
196
+ });
197
+ bindClicks(root, 'data-surface-choice', (value) => {
198
+ onSurfaceChoice(state, value);
199
+ render(root, state, ui);
200
+ });
201
+ bindClicks(root, 'data-brake-choice', (value) => {
202
+ onBrakeChoice(state, value);
203
+ render(root, state, ui);
204
+ });
205
+ bindHandle(root, state, ui);
206
+ render(root, state, ui);
207
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ gradeTilt,
3
+ markRatio,
4
+ metersToLength,
5
+ speedFromMps,
6
+ type SkidEstimate,
7
+ type SkidInput,
8
+ type UnitSystem,
9
+ } from './logic';
10
+
11
+ function uiText(ui: Record<string, string>, key: string): string {
12
+ return ui[key] ?? '';
13
+ }
14
+
15
+ export function formatSpeed(mps: number, unit: UnitSystem): string {
16
+ const value = speedFromMps(mps, unit);
17
+ return value >= 100 ? value.toFixed(0) : value.toFixed(1);
18
+ }
19
+
20
+ export function formatLength(meters: number, unit: UnitSystem): string {
21
+ return metersToLength(meters, unit).toFixed(1);
22
+ }
23
+
24
+ export function formatFriction(value: number): string {
25
+ return value.toFixed(2);
26
+ }
27
+
28
+ export function formatGrade(value: number): string {
29
+ if (Object.is(value, -0) || value === 0) return '0';
30
+ return value.toFixed(0);
31
+ }
32
+
33
+ export function speedUnit(ui: Record<string, string>, unit: UnitSystem): string {
34
+ return unit === 'imperial' ? uiText(ui, 'stencilUnitMph') : uiText(ui, 'stencilUnitKmh');
35
+ }
36
+
37
+ export function lengthUnit(ui: Record<string, string>, unit: UnitSystem): string {
38
+ return unit === 'imperial' ? uiText(ui, 'lengthUnitFt') : uiText(ui, 'lengthUnitM');
39
+ }
40
+
41
+ export function gradeCaption(gradePercent: number, ui: Record<string, string>): string {
42
+ if (gradePercent > 0) return uiText(ui, 'gradeUphill');
43
+ if (gradePercent < 0) return uiText(ui, 'gradeDownhill');
44
+ return uiText(ui, 'gradeLevel');
45
+ }
46
+
47
+ export function formatRange(lowMps: number, highMps: number, unit: UnitSystem): string {
48
+ return `${formatSpeed(lowMps, unit)} to ${formatSpeed(highMps, unit)}`;
49
+ }
50
+
51
+ export interface SceneView {
52
+ tilt: number;
53
+ needle: number;
54
+ mark: number;
55
+ surface: string;
56
+ speedText: string;
57
+ rangeText: string;
58
+ }
59
+
60
+ function emptyScene(input: SkidInput, ui: Record<string, string>): SceneView {
61
+ return {
62
+ tilt: gradeTilt(input.gradePercent),
63
+ needle: input.gradePercent * 1.7,
64
+ mark: markRatio(input.lengthMeters),
65
+ surface: input.surface,
66
+ speedText: uiText(ui, 'invalidMessage'),
67
+ rangeText: '',
68
+ };
69
+ }
70
+
71
+ export function buildSceneView(
72
+ input: SkidInput,
73
+ estimate: SkidEstimate,
74
+ unit: UnitSystem,
75
+ ui: Record<string, string>,
76
+ ): SceneView {
77
+ if (estimate.status === 'invalid' || !estimate.band) return emptyScene(input, ui);
78
+ const unitLabel = speedUnit(ui, unit);
79
+ return {
80
+ tilt: gradeTilt(input.gradePercent),
81
+ needle: input.gradePercent * 1.7,
82
+ mark: markRatio(input.lengthMeters),
83
+ surface: input.surface,
84
+ speedText: `${formatSpeed(estimate.band.midMps, unit)} ${unitLabel}`,
85
+ rangeText: `${formatRange(estimate.band.lowMps, estimate.band.highMps, unit)} ${unitLabel}`,
86
+ };
87
+ }
88
+
89
+ export function applyScene(root: HTMLElement, view: SceneView): void {
90
+ root.dataset.surface = view.surface;
91
+ root.style.setProperty('--trace-tilt', `${view.tilt}deg`);
92
+ root.style.setProperty('--trace-needle', `${view.needle}deg`);
93
+ root.style.setProperty('--trace-span', view.mark.toFixed(3));
94
+ writeText(root, '[data-trace-speed]', view.speedText);
95
+ writeText(root, '[data-trace-range]', view.rangeText);
96
+ }
97
+
98
+ function writeText(root: HTMLElement, selector: string, text: string): void {
99
+ const node = root.querySelector(selector);
100
+ if (node) node.textContent = text;
101
+ }
@@ -0,0 +1,30 @@
1
+ import type { MotorToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { MotorcycleSkidMarkSpeedEstimatorUI } from './ui';
3
+
4
+ export type { MotorcycleSkidMarkSpeedEstimatorUI };
5
+ export type MotorcycleSkidMarkSpeedEstimatorLocaleContent = ToolLocaleContent<MotorcycleSkidMarkSpeedEstimatorUI>;
6
+
7
+ export const motorcycleSkidMarkSpeedEstimator: MotorToolEntry<MotorcycleSkidMarkSpeedEstimatorUI> = {
8
+ id: 'motorcycle-skid-mark-speed-estimator',
9
+ icons: {
10
+ bg: 'mdi:motorbike',
11
+ fg: 'mdi:road',
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,7 @@
1
+ import { estimateSkidSpeed, type SkidEstimate, type SkidInput } from './logic';
2
+
3
+ export type SkidEvaluation = SkidEstimate['status'];
4
+
5
+ export function evaluateSkid(input: SkidInput): SkidEvaluation {
6
+ return estimateSkidSpeed(input).status;
7
+ }