@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
@@ -0,0 +1,229 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ clamp,
4
+ dragFactor,
5
+ estimateSkidSpeed,
6
+ getSurfaceSpec,
7
+ gradeTilt,
8
+ isBrakeUse,
9
+ isSurfaceId,
10
+ isUnitSystem,
11
+ lengthToMeters,
12
+ markRatio,
13
+ metersToLength,
14
+ METERS_PER_FOOT,
15
+ parsePositiveNumber,
16
+ parseSignedNumber,
17
+ speedFromMps,
18
+ speedFromSkid,
19
+ SURFACE_SPECS,
20
+ } from './logic';
21
+ import { evaluateSkid } from './evaluator';
22
+ import { buildSceneView, formatGrade, formatLength, formatRange, formatSpeed, gradeCaption, lengthUnit, speedUnit } from './dom-views';
23
+ import type { MotorcycleSkidMarkSpeedEstimatorUI } from './ui';
24
+ import { loadSkidState, saveSkidState } from './storage';
25
+
26
+ describe('motorcycle skid mark speed logic', () => {
27
+ it('converts length and speed units without changing the physical value', () => {
28
+ expect(lengthToMeters(40, 'imperial')).toBeCloseTo(40 * METERS_PER_FOOT, 8);
29
+ expect(metersToLength(12.192, 'imperial')).toBeCloseTo(40, 4);
30
+ expect(lengthToMeters(18, 'metric')).toBe(18);
31
+ expect(metersToLength(18, 'metric')).toBe(18);
32
+ expect(speedFromMps(13.7426, 'imperial')).toBeCloseTo(30.74, 1);
33
+ expect(speedFromMps(13.7426, 'metric')).toBeCloseTo(49.47, 1);
34
+ expect(lengthToMeters(Number.NaN, 'metric')).toBe(0);
35
+ expect(metersToLength(Number.NaN, 'imperial')).toBe(0);
36
+ expect(speedFromMps(Number.NaN, 'metric')).toBe(0);
37
+ });
38
+
39
+ it('matches the locked-wheel energy example of 40 ft at 0.79 drag', () => {
40
+ const meters = lengthToMeters(40, 'imperial');
41
+ const speed = speedFromSkid(meters, 0.79);
42
+ expect(speed).not.toBeNull();
43
+ expect(speedFromMps(speed ?? 0, 'imperial')).toBeCloseTo(30.75, 1);
44
+ });
45
+
46
+ it('adds grade to drag uphill and subtracts it downhill', () => {
47
+ expect(dragFactor(0.7, 1, 10)).toBeCloseTo(0.8, 8);
48
+ expect(dragFactor(0.7, 1, -10)).toBeCloseTo(0.6, 8);
49
+ const up = estimateSkidSpeed({
50
+ lengthMeters: 20,
51
+ friction: 0.7,
52
+ gradePercent: 10,
53
+ surface: 'custom',
54
+ brakeUse: 'both',
55
+ });
56
+ const down = estimateSkidSpeed({
57
+ lengthMeters: 20,
58
+ friction: 0.7,
59
+ gradePercent: -10,
60
+ surface: 'custom',
61
+ brakeUse: 'both',
62
+ });
63
+ expect(up.band?.midMps).toBeGreaterThan(down.band?.midMps ?? 0);
64
+ });
65
+
66
+ it('rejects a downhill case where gravity exceeds available drag', () => {
67
+ const result = estimateSkidSpeed({
68
+ lengthMeters: 20,
69
+ friction: 0.05,
70
+ gradePercent: -20,
71
+ surface: 'custom',
72
+ brakeUse: 'rear',
73
+ });
74
+ expect(result.status).toBe('invalid');
75
+ expect(result.band).toBeNull();
76
+ expect(speedFromSkid(0, 0.7)).toBeNull();
77
+ expect(speedFromSkid(10, 0)).toBeNull();
78
+ });
79
+
80
+ it('widens the band when brake use is unknown', () => {
81
+ const known = estimateSkidSpeed({
82
+ lengthMeters: 18,
83
+ friction: 0.7,
84
+ gradePercent: 0,
85
+ surface: 'dry-asphalt',
86
+ brakeUse: 'both',
87
+ });
88
+ const unknown = estimateSkidSpeed({
89
+ lengthMeters: 18,
90
+ friction: 0.7,
91
+ gradePercent: 0,
92
+ surface: 'dry-asphalt',
93
+ brakeUse: 'unknown',
94
+ });
95
+ expect(known.status).toBe('ready');
96
+ expect(unknown.status).toBe('ready');
97
+ expect(unknown.band?.lowMps).toBeLessThan(known.band?.lowMps ?? 0);
98
+ expect(unknown.band?.highMps).toBeGreaterThanOrEqual(known.band?.highMps ?? 0);
99
+ });
100
+
101
+ it('uses surface ranges and a custom plus-minus friction span', () => {
102
+ const asphalt = estimateSkidSpeed({
103
+ lengthMeters: 18,
104
+ friction: 0.7,
105
+ gradePercent: 0,
106
+ surface: 'dry-asphalt',
107
+ brakeUse: 'both',
108
+ });
109
+ const custom = estimateSkidSpeed({
110
+ lengthMeters: 18,
111
+ friction: 0.7,
112
+ gradePercent: 0,
113
+ surface: 'custom',
114
+ brakeUse: 'both',
115
+ });
116
+ expect(asphalt.band?.lowMps).not.toBe(asphalt.band?.highMps);
117
+ expect(custom.band?.dragLow).toBeCloseTo(0.6, 8);
118
+ expect(custom.band?.dragHigh).toBeCloseTo(0.8, 8);
119
+ expect(getSurfaceSpec('wet-asphalt').mu).toBe(0.5);
120
+ expect(getSurfaceSpec('missing' as never).id).toBe('dry-asphalt');
121
+ });
122
+
123
+ it('parses inputs, clamps scene helpers, and validates enumerations', () => {
124
+ expect(parsePositiveNumber('18')).toBe(18);
125
+ expect(parsePositiveNumber('0')).toBeNull();
126
+ expect(parsePositiveNumber('x')).toBeNull();
127
+ expect(parseSignedNumber('-4')).toBe(-4);
128
+ expect(parseSignedNumber('nope')).toBeNull();
129
+ expect(clamp(9, 0, 5)).toBe(5);
130
+ expect(markRatio(1)).toBeCloseTo(0.28, 2);
131
+ expect(markRatio(18)).toBeGreaterThan(0.55);
132
+ expect(markRatio(200)).toBeCloseTo(0.92, 2);
133
+ expect(gradeTilt(20)).toBeCloseTo(11, 4);
134
+ expect(isUnitSystem('metric')).toBe(true);
135
+ expect(isUnitSystem('stone')).toBe(false);
136
+ expect(isBrakeUse('rear')).toBe(true);
137
+ expect(isBrakeUse('abs')).toBe(false);
138
+ expect(isSurfaceId('ice')).toBe(true);
139
+ expect(isSurfaceId('sand')).toBe(false);
140
+ expect(SURFACE_SPECS).toHaveLength(7);
141
+ });
142
+
143
+ it('applies front and rear efficiency below a full locked-wheel both-brake case', () => {
144
+ const both = estimateSkidSpeed({
145
+ lengthMeters: 25,
146
+ friction: 0.7,
147
+ gradePercent: 0,
148
+ surface: 'custom',
149
+ brakeUse: 'both',
150
+ });
151
+ const front = estimateSkidSpeed({
152
+ lengthMeters: 25,
153
+ friction: 0.7,
154
+ gradePercent: 0,
155
+ surface: 'custom',
156
+ brakeUse: 'front',
157
+ });
158
+ const rear = estimateSkidSpeed({
159
+ lengthMeters: 25,
160
+ friction: 0.7,
161
+ gradePercent: 0,
162
+ surface: 'custom',
163
+ brakeUse: 'rear',
164
+ });
165
+ expect(front.band?.midMps).toBeLessThan(both.band?.midMps ?? 0);
166
+ expect(rear.band?.midMps).toBeLessThan(front.band?.midMps ?? 0);
167
+ });
168
+
169
+ it('evaluates status and formats the road stencil', () => {
170
+ const readyInput = {
171
+ lengthMeters: 18,
172
+ friction: 0.7,
173
+ gradePercent: 0,
174
+ surface: 'dry-asphalt' as const,
175
+ brakeUse: 'both' as const,
176
+ };
177
+ const invalidInput = {
178
+ lengthMeters: 18,
179
+ friction: 0.05,
180
+ gradePercent: -20,
181
+ surface: 'custom' as const,
182
+ brakeUse: 'rear' as const,
183
+ };
184
+ const ui = {
185
+ invalidMessage: 'bad',
186
+ educationalBadge: 'edu',
187
+ rangeLabel: 'band',
188
+ readyMessage: 'ok',
189
+ stencilUnitKmh: 'km/h',
190
+ stencilUnitMph: 'mph',
191
+ lengthUnitM: 'm',
192
+ lengthUnitFt: 'ft',
193
+ gradeUphill: 'up',
194
+ gradeDownhill: 'down',
195
+ gradeLevel: 'flat',
196
+ lowAssumption: 'L',
197
+ highAssumption: 'H',
198
+ dragMidLabel: 'M',
199
+ } as MotorcycleSkidMarkSpeedEstimatorUI;
200
+ expect(evaluateSkid(readyInput)).toBe('ready');
201
+ expect(evaluateSkid(invalidInput)).toBe('invalid');
202
+ expect(formatSpeed(40, 'metric')).toBe('144');
203
+ expect(formatSpeed(10, 'metric')).toBe('36.0');
204
+ expect(formatLength(18, 'metric')).toBe('18.0');
205
+ expect(formatLength(12.192, 'imperial')).toBe('40.0');
206
+ expect(formatGrade(0)).toBe('0');
207
+ expect(formatGrade(-4)).toBe('-4');
208
+ expect(formatRange(10, 12, 'metric')).toContain('to');
209
+ expect(speedUnit(ui, 'imperial')).toBe('mph');
210
+ expect(lengthUnit(ui, 'metric')).toBe('m');
211
+ expect(gradeCaption(2, ui)).toBe('up');
212
+ expect(gradeCaption(-2, ui)).toBe('down');
213
+ expect(gradeCaption(0, ui)).toBe('flat');
214
+ const readyView = buildSceneView(readyInput, estimateSkidSpeed(readyInput), 'metric', ui);
215
+ const invalidView = buildSceneView(invalidInput, estimateSkidSpeed(invalidInput), 'metric', ui);
216
+ expect(readyView.rangeText).toContain('to');
217
+ expect(invalidView.speedText).toBe('bad');
218
+ expect(loadSkidState()).toBeNull();
219
+ saveSkidState({
220
+ lengthMeters: 18,
221
+ unit: 'metric',
222
+ surface: 'dry-asphalt',
223
+ friction: 0.7,
224
+ gradePercent: 0,
225
+ brakeUse: 'both',
226
+ });
227
+ });
228
+ });
229
+
@@ -0,0 +1,179 @@
1
+ export const GRAVITY = 9.80665;
2
+ export const METERS_PER_FOOT = 0.3048;
3
+ export const MPS_TO_KMH = 3.6;
4
+ export const MPS_TO_MPH = 2.236936292;
5
+ export const MIN_FRICTION = 0.05;
6
+ export const MAX_FRICTION = 1.2;
7
+ export const MIN_LENGTH_M = 1;
8
+ export const MAX_LENGTH_M = 200;
9
+ export const MIN_GRADE = -20;
10
+ export const MAX_GRADE = 20;
11
+
12
+ export type UnitSystem = 'metric' | 'imperial';
13
+ export type BrakeUse = 'both' | 'front' | 'rear' | 'unknown';
14
+ export type SurfaceId =
15
+ | 'dry-asphalt'
16
+ | 'wet-asphalt'
17
+ | 'dry-concrete'
18
+ | 'gravel'
19
+ | 'grass'
20
+ | 'ice'
21
+ | 'custom';
22
+
23
+ export interface SurfaceSpec {
24
+ id: SurfaceId;
25
+ mu: number;
26
+ muLow: number;
27
+ muHigh: number;
28
+ }
29
+
30
+ export interface SkidInput {
31
+ lengthMeters: number;
32
+ friction: number;
33
+ gradePercent: number;
34
+ surface: SurfaceId;
35
+ brakeUse: BrakeUse;
36
+ }
37
+
38
+ export interface SpeedBand {
39
+ lowMps: number;
40
+ midMps: number;
41
+ highMps: number;
42
+ dragLow: number;
43
+ dragMid: number;
44
+ dragHigh: number;
45
+ }
46
+
47
+ export interface SkidEstimate {
48
+ status: 'ready' | 'invalid';
49
+ band: SpeedBand | null;
50
+ friction: number;
51
+ lengthMeters: number;
52
+ }
53
+
54
+ export const SURFACE_SPECS: readonly SurfaceSpec[] = [
55
+ { id: 'dry-asphalt', mu: 0.7, muLow: 0.55, muHigh: 0.85 },
56
+ { id: 'wet-asphalt', mu: 0.5, muLow: 0.35, muHigh: 0.65 },
57
+ { id: 'dry-concrete', mu: 0.75, muLow: 0.6, muHigh: 0.9 },
58
+ { id: 'gravel', mu: 0.5, muLow: 0.35, muHigh: 0.7 },
59
+ { id: 'grass', mu: 0.4, muLow: 0.25, muHigh: 0.55 },
60
+ { id: 'ice', mu: 0.1, muLow: 0.05, muHigh: 0.18 },
61
+ { id: 'custom', mu: 0.7, muLow: 0.6, muHigh: 0.8 },
62
+ ];
63
+
64
+ const BRAKE_SPAN: Record<BrakeUse, { low: number; mid: number; high: number }> = {
65
+ both: { low: 1, mid: 1, high: 1 },
66
+ front: { low: 0.7, mid: 0.7, high: 0.7 },
67
+ rear: { low: 0.45, mid: 0.45, high: 0.45 },
68
+ unknown: { low: 0.45, mid: 0.7, high: 1 },
69
+ };
70
+
71
+ export function isUnitSystem(value: string): value is UnitSystem {
72
+ return value === 'metric' || value === 'imperial';
73
+ }
74
+
75
+ export function isBrakeUse(value: string): value is BrakeUse {
76
+ return value === 'both' || value === 'front' || value === 'rear' || value === 'unknown';
77
+ }
78
+
79
+ export function isSurfaceId(value: string): value is SurfaceId {
80
+ return SURFACE_SPECS.some((spec) => spec.id === value);
81
+ }
82
+
83
+ const DEFAULT_SURFACE: SurfaceSpec = { id: 'dry-asphalt', mu: 0.7, muLow: 0.55, muHigh: 0.85 };
84
+
85
+ export function getSurfaceSpec(id: SurfaceId): SurfaceSpec {
86
+ return SURFACE_SPECS.find((spec) => spec.id === id) ?? DEFAULT_SURFACE;
87
+ }
88
+
89
+ export function clamp(value: number, min: number, max: number): number {
90
+ return Math.min(max, Math.max(min, value));
91
+ }
92
+
93
+ export function lengthToMeters(length: number, unit: UnitSystem): number {
94
+ const safe = Number.isFinite(length) ? Math.max(0, length) : 0;
95
+ return unit === 'imperial' ? safe * METERS_PER_FOOT : safe;
96
+ }
97
+
98
+ export function metersToLength(meters: number, unit: UnitSystem): number {
99
+ const safe = Number.isFinite(meters) ? Math.max(0, meters) : 0;
100
+ return unit === 'imperial' ? safe / METERS_PER_FOOT : safe;
101
+ }
102
+
103
+ export function speedFromMps(mps: number, unit: UnitSystem): number {
104
+ const safe = Number.isFinite(mps) ? Math.max(0, mps) : 0;
105
+ return unit === 'imperial' ? safe * MPS_TO_MPH : safe * MPS_TO_KMH;
106
+ }
107
+
108
+ export function parsePositiveNumber(raw: string): number | null {
109
+ const value = Number.parseFloat(raw);
110
+ if (!Number.isFinite(value) || value <= 0) return null;
111
+ return value;
112
+ }
113
+
114
+ export function parseSignedNumber(raw: string): number | null {
115
+ const value = Number.parseFloat(raw);
116
+ if (!Number.isFinite(value)) return null;
117
+ return value;
118
+ }
119
+
120
+ export function dragFactor(friction: number, efficiency: number, gradePercent: number): number {
121
+ const mu = clamp(friction, MIN_FRICTION, MAX_FRICTION);
122
+ const n = clamp(efficiency, 0, 1);
123
+ const grade = clamp(gradePercent, MIN_GRADE, MAX_GRADE) / 100;
124
+ return mu * n + grade;
125
+ }
126
+
127
+ export function speedFromSkid(lengthMeters: number, drag: number): number | null {
128
+ if (!(lengthMeters > 0) || !(drag > 0)) return null;
129
+ return Math.sqrt(2 * GRAVITY * drag * lengthMeters);
130
+ }
131
+
132
+ function customSensitivity(friction: number): { low: number; high: number } {
133
+ return {
134
+ low: clamp(friction - 0.1, MIN_FRICTION, MAX_FRICTION),
135
+ high: clamp(friction + 0.1, MIN_FRICTION, MAX_FRICTION),
136
+ };
137
+ }
138
+
139
+ function frictionBand(input: SkidInput): { low: number; mid: number; high: number } {
140
+ const spec = getSurfaceSpec(input.surface);
141
+ if (input.surface === 'custom') {
142
+ const span = customSensitivity(input.friction);
143
+ return { low: span.low, mid: clamp(input.friction, MIN_FRICTION, MAX_FRICTION), high: span.high };
144
+ }
145
+ return { low: spec.muLow, mid: spec.mu, high: spec.muHigh };
146
+ }
147
+
148
+ function buildBand(input: SkidInput): SpeedBand | null {
149
+ const brakes = BRAKE_SPAN[input.brakeUse];
150
+ const mu = frictionBand(input);
151
+ const dragLow = dragFactor(mu.low, brakes.low, input.gradePercent);
152
+ const dragMid = dragFactor(mu.mid, brakes.mid, input.gradePercent);
153
+ const dragHigh = dragFactor(mu.high, brakes.high, input.gradePercent);
154
+ const low = speedFromSkid(input.lengthMeters, dragLow);
155
+ const mid = speedFromSkid(input.lengthMeters, dragMid);
156
+ const high = speedFromSkid(input.lengthMeters, dragHigh);
157
+ if (low === null || mid === null || high === null) return null;
158
+ return { lowMps: low, midMps: mid, highMps: high, dragLow, dragMid, dragHigh };
159
+ }
160
+
161
+ export function estimateSkidSpeed(input: SkidInput): SkidEstimate {
162
+ const lengthMeters = clamp(input.lengthMeters, MIN_LENGTH_M, MAX_LENGTH_M);
163
+ const friction = clamp(input.friction, MIN_FRICTION, MAX_FRICTION);
164
+ const band = buildBand({ ...input, lengthMeters, friction });
165
+ if (!band) {
166
+ return { status: 'invalid', band: null, friction, lengthMeters };
167
+ }
168
+ return { status: 'ready', band, friction, lengthMeters };
169
+ }
170
+
171
+ export function markRatio(lengthMeters: number): number {
172
+ const clamped = clamp(lengthMeters, MIN_LENGTH_M, MAX_LENGTH_M);
173
+ const t = Math.log(clamped) / Math.log(MAX_LENGTH_M);
174
+ return 0.28 + t * 0.64;
175
+ }
176
+
177
+ export function gradeTilt(gradePercent: number): number {
178
+ return clamp(gradePercent, MIN_GRADE, MAX_GRADE) * 0.55;
179
+ }