@jjlmoya/utils-motor 1.3.0 → 1.5.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 (66) hide show
  1. package/package.json +2 -2
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +10 -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/evChargingTimeCostPlanner/bibliography.astro +6 -0
  8. package/src/tool/evChargingTimeCostPlanner/bibliography.ts +16 -0
  9. package/src/tool/evChargingTimeCostPlanner/component.astro +147 -0
  10. package/src/tool/evChargingTimeCostPlanner/controller.ts +214 -0
  11. package/src/tool/evChargingTimeCostPlanner/dom-views.ts +124 -0
  12. package/src/tool/evChargingTimeCostPlanner/entry.ts +30 -0
  13. package/src/tool/evChargingTimeCostPlanner/ev-charging-time-cost-planner.css +598 -0
  14. package/src/tool/evChargingTimeCostPlanner/evaluator.ts +10 -0
  15. package/src/tool/evChargingTimeCostPlanner/i18n/de.ts +22 -0
  16. package/src/tool/evChargingTimeCostPlanner/i18n/en.ts +161 -0
  17. package/src/tool/evChargingTimeCostPlanner/i18n/es.ts +16 -0
  18. package/src/tool/evChargingTimeCostPlanner/i18n/factory.ts +113 -0
  19. package/src/tool/evChargingTimeCostPlanner/i18n/fr.ts +9 -0
  20. package/src/tool/evChargingTimeCostPlanner/i18n/id.ts +9 -0
  21. package/src/tool/evChargingTimeCostPlanner/i18n/it.ts +9 -0
  22. package/src/tool/evChargingTimeCostPlanner/i18n/ja.ts +13 -0
  23. package/src/tool/evChargingTimeCostPlanner/i18n/ko.ts +13 -0
  24. package/src/tool/evChargingTimeCostPlanner/i18n/nl.ts +9 -0
  25. package/src/tool/evChargingTimeCostPlanner/i18n/pl.ts +9 -0
  26. package/src/tool/evChargingTimeCostPlanner/i18n/pt.ts +9 -0
  27. package/src/tool/evChargingTimeCostPlanner/i18n/ru.ts +13 -0
  28. package/src/tool/evChargingTimeCostPlanner/i18n/sv.ts +9 -0
  29. package/src/tool/evChargingTimeCostPlanner/i18n/tr.ts +9 -0
  30. package/src/tool/evChargingTimeCostPlanner/i18n/zh.ts +13 -0
  31. package/src/tool/evChargingTimeCostPlanner/index.ts +10 -0
  32. package/src/tool/evChargingTimeCostPlanner/logic.test.ts +71 -0
  33. package/src/tool/evChargingTimeCostPlanner/logic.ts +174 -0
  34. package/src/tool/evChargingTimeCostPlanner/seo.astro +15 -0
  35. package/src/tool/evChargingTimeCostPlanner/storage.ts +27 -0
  36. package/src/tool/evChargingTimeCostPlanner/ui.ts +50 -0
  37. package/src/tool/motorcycleSkidMarkSpeedEstimator/bibliography.astro +6 -0
  38. package/src/tool/motorcycleSkidMarkSpeedEstimator/bibliography.ts +12 -0
  39. package/src/tool/motorcycleSkidMarkSpeedEstimator/component.astro +166 -0
  40. package/src/tool/motorcycleSkidMarkSpeedEstimator/controller.ts +207 -0
  41. package/src/tool/motorcycleSkidMarkSpeedEstimator/dom-views.ts +101 -0
  42. package/src/tool/motorcycleSkidMarkSpeedEstimator/entry.ts +30 -0
  43. package/src/tool/motorcycleSkidMarkSpeedEstimator/evaluator.ts +7 -0
  44. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/de.ts +185 -0
  45. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/en.ts +185 -0
  46. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/es.ts +185 -0
  47. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/fr.ts +185 -0
  48. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/id.ts +185 -0
  49. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/it.ts +185 -0
  50. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/ja.ts +201 -0
  51. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/ko.ts +201 -0
  52. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/nl.ts +185 -0
  53. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/pl.ts +185 -0
  54. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/pt.ts +185 -0
  55. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/ru.ts +185 -0
  56. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/sv.ts +185 -0
  57. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/tr.ts +185 -0
  58. package/src/tool/motorcycleSkidMarkSpeedEstimator/i18n/zh.ts +201 -0
  59. package/src/tool/motorcycleSkidMarkSpeedEstimator/index.ts +10 -0
  60. package/src/tool/motorcycleSkidMarkSpeedEstimator/logic.test.ts +229 -0
  61. package/src/tool/motorcycleSkidMarkSpeedEstimator/logic.ts +179 -0
  62. package/src/tool/motorcycleSkidMarkSpeedEstimator/motorcycle-skid-mark-speed-estimator.css +523 -0
  63. package/src/tool/motorcycleSkidMarkSpeedEstimator/seo.astro +15 -0
  64. package/src/tool/motorcycleSkidMarkSpeedEstimator/storage.ts +49 -0
  65. package/src/tool/motorcycleSkidMarkSpeedEstimator/ui.ts +46 -0
  66. package/src/tools.ts +5 -1
@@ -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
+ }
@@ -0,0 +1,523 @@
1
+ .trace {
2
+ --trace-bg-primary: #f8fafc;
3
+ --trace-bg-secondary: #fff;
4
+ --trace-bg-card: rgba(255, 255, 255, 0.95);
5
+ --trace-border: #e2e8f0;
6
+ --trace-border-active: #0284c7;
7
+ --trace-text-primary: #0f172a;
8
+ --trace-text-muted: #64748b;
9
+ --trace-accent-cyan: #0284c7;
10
+ --trace-accent-amber: #d97706;
11
+ --trace-accent-red: #dc2626;
12
+ --trace-road-asphalt: #334155;
13
+ --trace-grit-color: #475569;
14
+ --trace-rubber-mark: #0f172a;
15
+ --trace-metal-silver: #475569;
16
+ --trace-hud-bg: rgba(255, 255, 255, 0.92);
17
+ --trace-hud-shadow: rgba(0, 0, 0, 0.12);
18
+ --trace-input-bg: #f1f5f9;
19
+ --trace-segmented-bg: #f1f5f9;
20
+ --trace-span: 0.55;
21
+ --trace-tilt: 0deg;
22
+
23
+ color: var(--trace-text-primary);
24
+ width: 100%;
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ .theme-dark .trace {
29
+ --trace-bg-primary: #0f172a;
30
+ --trace-bg-secondary: #1e293b;
31
+ --trace-bg-card: rgba(30, 41, 59, 0.85);
32
+ --trace-border: rgba(255, 255, 255, 0.1);
33
+ --trace-border-active: #38bdf8;
34
+ --trace-text-primary: #f8fafc;
35
+ --trace-text-muted: #94a3b8;
36
+ --trace-accent-cyan: #38bdf8;
37
+ --trace-accent-amber: #f59e0b;
38
+ --trace-accent-red: #ef4444;
39
+ --trace-road-asphalt: #334155;
40
+ --trace-grit-color: #475569;
41
+ --trace-rubber-mark: #0f172a;
42
+ --trace-metal-silver: #cbd5e1;
43
+ --trace-hud-bg: rgba(15, 23, 42, 0.88);
44
+ --trace-hud-shadow: rgba(0, 0, 0, 0.6);
45
+ --trace-input-bg: rgba(15, 23, 42, 0.8);
46
+ --trace-segmented-bg: rgba(15, 23, 42, 0.6);
47
+ }
48
+
49
+ .trace * {
50
+ box-sizing: border-box;
51
+ }
52
+
53
+ .trace-card {
54
+ background: var(--trace-bg-primary);
55
+ border: 1px solid var(--trace-border);
56
+ border-radius: 1rem;
57
+ overflow: hidden;
58
+ box-shadow: 0 20px 25px -5px var(--trace-hud-shadow), 0 8px 10px -6px var(--trace-hud-shadow);
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: 0;
62
+ transition: background-color 0.3s ease, border-color 0.3s ease;
63
+ }
64
+
65
+ .trace-header {
66
+ background: var(--trace-bg-secondary);
67
+ padding: 1.25rem 1.5rem;
68
+ display: grid;
69
+ grid-template-columns: auto 1fr auto;
70
+ gap: 1.5rem;
71
+ align-items: center;
72
+ border-bottom: 1px solid var(--trace-border);
73
+ transition: background-color 0.3s ease, border-color 0.3s ease;
74
+ }
75
+
76
+ .trace-control-group {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 0.4rem;
80
+ }
81
+
82
+ .trace-label-sm {
83
+ font-size: 0.75rem;
84
+ font-weight: 600;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.05em;
87
+ color: var(--trace-text-muted);
88
+ }
89
+
90
+ .trace-segmented {
91
+ display: inline-flex;
92
+ background: var(--trace-segmented-bg);
93
+ padding: 0.25rem;
94
+ border-radius: 0.6rem;
95
+ border: 1px solid var(--trace-border);
96
+ gap: 0.25rem;
97
+ }
98
+
99
+ .trace-pill {
100
+ border: none;
101
+ background: transparent;
102
+ color: var(--trace-text-muted);
103
+ padding: 0.45rem 0.85rem;
104
+ font-size: 0.85rem;
105
+ font-weight: 500;
106
+ border-radius: 0.45rem;
107
+ cursor: pointer;
108
+ transition: all 0.2s ease;
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 0.35rem;
112
+ }
113
+
114
+ .trace-pill:hover {
115
+ color: var(--trace-text-primary);
116
+ background: rgba(0, 0, 0, 0.05);
117
+ }
118
+
119
+ .theme-dark .trace-pill:hover {
120
+ background: rgba(255, 255, 255, 0.05);
121
+ }
122
+
123
+ .trace-pill.is-stencil,
124
+ .trace-pill.is-lock {
125
+ background: var(--trace-accent-cyan);
126
+ color: #fff;
127
+ font-weight: 600;
128
+ box-shadow: 0 0 12px rgba(2, 132, 199, 0.3);
129
+ }
130
+
131
+ .theme-dark .trace-pill.is-stencil,
132
+ .theme-dark .trace-pill.is-lock {
133
+ color: #0f172a;
134
+ box-shadow: 0 0 12px rgba(56, 189, 248, 0.4);
135
+ }
136
+
137
+ .trace-pill-unit {
138
+ font-size: 0.7rem;
139
+ opacity: 0.85;
140
+ font-weight: 400;
141
+ }
142
+
143
+ .trace-length-group {
144
+ max-width: 220px;
145
+ justify-self: center;
146
+ width: 100%;
147
+ }
148
+
149
+ .trace-length-input-box {
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 0.4rem;
153
+ width: 100%;
154
+ }
155
+
156
+ .trace-input-label {
157
+ font-size: 0.75rem;
158
+ font-weight: 600;
159
+ text-transform: uppercase;
160
+ letter-spacing: 0.05em;
161
+ color: var(--trace-text-muted);
162
+ }
163
+
164
+ .trace-input-wrapper {
165
+ display: flex;
166
+ align-items: center;
167
+ background: var(--trace-input-bg);
168
+ border: 1px solid var(--trace-border);
169
+ border-radius: 0.6rem;
170
+ overflow: hidden;
171
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
172
+ }
173
+
174
+ .trace-input-wrapper:focus-within {
175
+ border-color: var(--trace-accent-cyan);
176
+ box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.2);
177
+ }
178
+
179
+ .trace-numeric-input {
180
+ background: transparent;
181
+ border: none;
182
+ color: var(--trace-text-primary);
183
+ padding: 0.5rem 0.75rem;
184
+ font-size: 1.1rem;
185
+ font-weight: 700;
186
+ width: 100%;
187
+ outline: none;
188
+ }
189
+
190
+ .trace-unit-badge {
191
+ background: rgba(0, 0, 0, 0.05);
192
+ color: var(--trace-accent-cyan);
193
+ padding: 0.5rem 0.75rem;
194
+ font-size: 0.85rem;
195
+ font-weight: 600;
196
+ }
197
+
198
+ .theme-dark .trace-unit-badge {
199
+ background: rgba(255, 255, 255, 0.08);
200
+ }
201
+
202
+ .trace-canvas-container {
203
+ position: relative;
204
+ background: #1e293b;
205
+ cursor: ew-resize;
206
+ overflow: hidden;
207
+ user-select: none;
208
+ touch-action: none;
209
+ }
210
+
211
+ .trace-hud-overlay {
212
+ position: absolute;
213
+ top: 1.25rem;
214
+ left: 1.25rem;
215
+ z-index: 10;
216
+ pointer-events: none;
217
+ }
218
+
219
+ .trace-hud-badge {
220
+ background: var(--trace-hud-bg);
221
+ backdrop-filter: blur(12px);
222
+ border: 1px solid var(--trace-border);
223
+ border-radius: 0.75rem;
224
+ padding: 0.85rem 1.25rem;
225
+ box-shadow: 0 10px 25px -5px var(--trace-hud-shadow);
226
+ }
227
+
228
+ .trace-hud-caption {
229
+ font-size: 0.7rem;
230
+ font-weight: 700;
231
+ text-transform: uppercase;
232
+ letter-spacing: 0.08em;
233
+ color: var(--trace-text-muted);
234
+ display: block;
235
+ margin-bottom: 0.2rem;
236
+ }
237
+
238
+ .trace-hud-speed {
239
+ font-size: 2.2rem;
240
+ font-weight: 800;
241
+ color: var(--trace-accent-cyan);
242
+ line-height: 1.1;
243
+ letter-spacing: -0.02em;
244
+ }
245
+
246
+ .trace-hud-range {
247
+ font-size: 0.85rem;
248
+ font-weight: 500;
249
+ color: var(--trace-text-muted);
250
+ margin-top: 0.25rem;
251
+ }
252
+
253
+ .trace[data-eval='invalid'] .trace-hud-speed {
254
+ font-size: 1.25rem;
255
+ color: var(--trace-accent-red);
256
+ text-shadow: none;
257
+ }
258
+
259
+ .trace-svg {
260
+ display: block;
261
+ width: 100%;
262
+ height: auto;
263
+ min-height: 260px;
264
+ transform: rotate(var(--trace-tilt));
265
+ transform-origin: center center;
266
+ transition: transform 0.3s ease;
267
+ }
268
+
269
+ .trace-fill-road {
270
+ fill: var(--trace-road-asphalt);
271
+ transition: fill 0.4s ease;
272
+ }
273
+
274
+ .trace-fill-grit {
275
+ fill: var(--trace-grit-color);
276
+ }
277
+
278
+ .trace-lane {
279
+ fill: none;
280
+ stroke: rgba(255, 255, 255, 0.4);
281
+ stroke-width: 5;
282
+ stroke-dasharray: 28 20;
283
+ }
284
+
285
+ .trace-scar-line {
286
+ fill: none;
287
+ stroke: var(--trace-rubber-mark);
288
+ stroke-linecap: round;
289
+ stroke-dasharray: 1;
290
+ stroke-dashoffset: calc(1 - var(--trace-span));
291
+ transition: stroke-dashoffset 0.1s linear, opacity 0.3s ease;
292
+ }
293
+
294
+ .trace-scar-rear {
295
+ stroke-width: 20;
296
+ }
297
+
298
+ .trace-scar-front {
299
+ stroke-width: 12;
300
+ }
301
+
302
+ .trace-stria {
303
+ fill: none;
304
+ stroke: rgba(255, 255, 255, 0.25);
305
+ stroke-width: 2;
306
+ stroke-dasharray: 1;
307
+ stroke-dashoffset: calc(1 - var(--trace-span));
308
+ transition: stroke-dashoffset 0.1s linear;
309
+ }
310
+
311
+ .trace[data-brake='rear'] .trace-scar-front,
312
+ .trace[data-brake='front'] .trace-scar-rear {
313
+ opacity: 0.15;
314
+ }
315
+
316
+ .trace[data-brake='unknown'] .trace-scar-line {
317
+ opacity: 0.45;
318
+ }
319
+
320
+ .trace-tyre {
321
+ fill: #090d16;
322
+ }
323
+
324
+ .trace-hub {
325
+ fill: var(--trace-metal-silver);
326
+ }
327
+
328
+ .trace-rotor {
329
+ fill: none;
330
+ stroke: var(--trace-metal-silver);
331
+ stroke-width: 4;
332
+ transition: all 0.3s ease;
333
+ }
334
+
335
+ .trace[data-brake='rear'] .trace-rotor-rear,
336
+ .trace[data-brake='both'] .trace-rotor-rear,
337
+ .trace[data-brake='front'] .trace-rotor-front,
338
+ .trace[data-brake='both'] .trace-rotor-front {
339
+ fill: var(--trace-accent-amber);
340
+ stroke: var(--trace-accent-amber);
341
+ }
342
+
343
+ .trace-swing,
344
+ .trace-bars,
345
+ .trace-pipe,
346
+ .trace-rider {
347
+ fill: none;
348
+ stroke: var(--trace-accent-cyan);
349
+ stroke-width: 4.5;
350
+ stroke-linecap: round;
351
+ stroke-linejoin: round;
352
+ }
353
+
354
+ .trace-body,
355
+ .trace-saddle {
356
+ fill: #1e293b;
357
+ stroke: var(--trace-accent-cyan);
358
+ stroke-width: 2;
359
+ }
360
+
361
+ .trace-head {
362
+ fill: var(--trace-accent-cyan);
363
+ }
364
+
365
+ .trace-controls-bottom {
366
+ background: var(--trace-bg-secondary);
367
+ padding: 1.25rem 1.5rem;
368
+ display: grid;
369
+ grid-template-columns: 1fr 340px;
370
+ gap: 1.5rem;
371
+ border-top: 1px solid var(--trace-border);
372
+ transition: background-color 0.3s ease, border-color 0.3s ease;
373
+ }
374
+
375
+ .trace-section-title {
376
+ font-size: 0.75rem;
377
+ font-weight: 700;
378
+ text-transform: uppercase;
379
+ letter-spacing: 0.06em;
380
+ color: var(--trace-text-muted);
381
+ margin: 0 0 0.75rem;
382
+ }
383
+
384
+ .trace-surface-grid {
385
+ display: grid;
386
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
387
+ gap: 0.5rem;
388
+ }
389
+
390
+ .trace-surface-chip {
391
+ background: var(--trace-segmented-bg);
392
+ border: 1px solid var(--trace-border);
393
+ border-radius: 0.6rem;
394
+ color: var(--trace-text-primary);
395
+ padding: 0.6rem 0.75rem;
396
+ font-size: 0.8rem;
397
+ font-weight: 500;
398
+ cursor: pointer;
399
+ display: flex;
400
+ align-items: center;
401
+ gap: 0.5rem;
402
+ transition: all 0.2s ease;
403
+ text-align: left;
404
+ }
405
+
406
+ .trace-surface-chip:hover {
407
+ background: rgba(0, 0, 0, 0.04);
408
+ border-color: var(--trace-text-muted);
409
+ }
410
+
411
+ .theme-dark .trace-surface-chip:hover {
412
+ background: rgba(255, 255, 255, 0.08);
413
+ }
414
+
415
+ .trace-surface-dot {
416
+ width: 0.6rem;
417
+ height: 0.6rem;
418
+ border-radius: 50%;
419
+ background: var(--trace-text-muted);
420
+ flex-shrink: 0;
421
+ }
422
+
423
+ .trace-surface-chip[data-tone='dry'] .trace-surface-dot { background: #64748b; }
424
+ .trace-surface-chip[data-tone='wet'] .trace-surface-dot { background: #0284c7; }
425
+ .trace-surface-chip[data-tone='concrete'] .trace-surface-dot { background: #94a3b8; }
426
+ .trace-surface-chip[data-tone='gravel'] .trace-surface-dot { background: #d97706; }
427
+ .trace-surface-chip[data-tone='earth'] .trace-surface-dot { background: #16a34a; }
428
+ .trace-surface-chip[data-tone='ice'] .trace-surface-dot { background: #0284c7; }
429
+ .trace-surface-chip[data-tone='steel'] .trace-surface-dot { background: #dc2626; }
430
+
431
+ .trace-surface-chip.is-core {
432
+ border-color: var(--trace-accent-cyan);
433
+ background: rgba(2, 132, 199, 0.1);
434
+ color: var(--trace-accent-cyan);
435
+ font-weight: 600;
436
+ box-shadow: 0 0 10px rgba(2, 132, 199, 0.15);
437
+ }
438
+
439
+ .theme-dark .trace-surface-chip.is-core {
440
+ background: rgba(56, 189, 248, 0.12);
441
+ box-shadow: 0 0 12px rgba(56, 189, 248, 0.2);
442
+ }
443
+
444
+ .trace-sliders-section {
445
+ display: flex;
446
+ flex-direction: column;
447
+ gap: 1rem;
448
+ }
449
+
450
+ .trace-slider-card {
451
+ background: var(--trace-segmented-bg);
452
+ border: 1px solid var(--trace-border);
453
+ border-radius: 0.6rem;
454
+ padding: 0.75rem 1rem;
455
+ }
456
+
457
+ .trace-slider-header {
458
+ display: flex;
459
+ justify-content: space-between;
460
+ align-items: center;
461
+ margin-bottom: 0.5rem;
462
+ }
463
+
464
+ .trace-slider-title {
465
+ font-size: 0.8rem;
466
+ font-weight: 600;
467
+ color: var(--trace-text-muted);
468
+ }
469
+
470
+ .trace-small-input {
471
+ background: var(--trace-input-bg);
472
+ border: 1px solid var(--trace-border);
473
+ border-radius: 0.4rem;
474
+ color: var(--trace-accent-cyan);
475
+ font-size: 0.85rem;
476
+ font-weight: 700;
477
+ width: 4rem;
478
+ padding: 0.2rem 0.4rem;
479
+ text-align: right;
480
+ outline: none;
481
+ }
482
+
483
+ .trace-small-input:focus {
484
+ border-color: var(--trace-accent-cyan);
485
+ }
486
+
487
+ .trace-slider-val-badge {
488
+ font-size: 0.85rem;
489
+ font-weight: 700;
490
+ color: var(--trace-accent-cyan);
491
+ }
492
+
493
+ .trace-range-slider {
494
+ width: 100%;
495
+ height: 6px;
496
+ border-radius: 3px;
497
+ background: var(--trace-border);
498
+ outline: none;
499
+ accent-color: var(--trace-accent-cyan);
500
+ cursor: pointer;
501
+ }
502
+
503
+ /* Dynamic surface environment colors */
504
+ .trace[data-surface='wet-asphalt'] .trace-fill-road { fill: #1e293b; }
505
+ .trace[data-surface='dry-concrete'] .trace-fill-road { fill: #475569; }
506
+ .trace[data-surface='gravel'] .trace-fill-road { fill: #451a03; }
507
+ .trace[data-surface='grass'] .trace-fill-road { fill: #064e3b; }
508
+ .trace[data-surface='ice'] .trace-fill-road { fill: #1e3a8a; }
509
+
510
+ @media (max-width: 900px) {
511
+ .trace-header {
512
+ grid-template-columns: 1fr;
513
+ gap: 1rem;
514
+ }
515
+
516
+ .trace-length-group {
517
+ max-width: 100%;
518
+ }
519
+
520
+ .trace-controls-bottom {
521
+ grid-template-columns: 1fr;
522
+ }
523
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { motorcycleSkidMarkSpeedEstimator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const loader = motorcycleSkidMarkSpeedEstimator.i18n[locale] ?? motorcycleSkidMarkSpeedEstimator.i18n.en;
12
+ const content = await loader?.();
13
+ ---
14
+
15
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}