@jjlmoya/utils-drones 1.39.0 → 1.41.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 -0
- package/src/entries.ts +4 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/fpv-drone-lap-timer/audio.ts +160 -0
- package/src/tool/fpv-drone-lap-timer/bibliography.astro +6 -0
- package/src/tool/fpv-drone-lap-timer/bibliography.ts +14 -0
- package/src/tool/fpv-drone-lap-timer/bootstrap.ts +20 -0
- package/src/tool/fpv-drone-lap-timer/component.astro +136 -0
- package/src/tool/fpv-drone-lap-timer/contract.test.ts +24 -0
- package/src/tool/fpv-drone-lap-timer/controller.ts +304 -0
- package/src/tool/fpv-drone-lap-timer/dom-views.ts +213 -0
- package/src/tool/fpv-drone-lap-timer/entry.ts +27 -0
- package/src/tool/fpv-drone-lap-timer/export.ts +41 -0
- package/src/tool/fpv-drone-lap-timer/fpv-drone-lap-timer.css +705 -0
- package/src/tool/fpv-drone-lap-timer/i18n/de.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/en.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/es.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/fr.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/id.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/it.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/ja.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/ko.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/nl.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/pl.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/pt.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/ru.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/sv.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/tr.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/i18n/zh.ts +216 -0
- package/src/tool/fpv-drone-lap-timer/index.ts +10 -0
- package/src/tool/fpv-drone-lap-timer/logic.test.ts +152 -0
- package/src/tool/fpv-drone-lap-timer/logic.ts +228 -0
- package/src/tool/fpv-drone-lap-timer/seo.astro +16 -0
- package/src/tool/fpv-drone-lap-timer/storage.ts +52 -0
- package/src/tool/fpv-drone-lap-timer/ui.ts +58 -0
- package/src/tools.ts +3 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calculateSessionMetrics,
|
|
3
|
+
computeLapRecords,
|
|
4
|
+
formatLapTime,
|
|
5
|
+
isDebounceLocked,
|
|
6
|
+
PRESETS,
|
|
7
|
+
type SessionConfig,
|
|
8
|
+
} from './logic';
|
|
9
|
+
import { loadSessionConfig, saveSessionConfig, loadSessionLaps, saveSessionLaps } from './storage';
|
|
10
|
+
import { RaceAudioSynthesizer } from './audio';
|
|
11
|
+
import { renderTelemetryScene, renderLapTable } from './dom-views';
|
|
12
|
+
import { exportSessionCsv, copySessionSummary } from './export';
|
|
13
|
+
import type { FpvDroneLapTimerUI } from './ui';
|
|
14
|
+
|
|
15
|
+
export class LapTimerSession {
|
|
16
|
+
private config: SessionConfig;
|
|
17
|
+
private audio: RaceAudioSynthesizer;
|
|
18
|
+
private status: 'idle' | 'countdown' | 'running' | 'paused' | 'finished' = 'idle';
|
|
19
|
+
private lapDurationsMs: number[] = [];
|
|
20
|
+
private lapStartTime = 0;
|
|
21
|
+
private lastRecordedTimestamp = 0;
|
|
22
|
+
private animFrameId: number | null = null;
|
|
23
|
+
private currentLapNumber = 1;
|
|
24
|
+
|
|
25
|
+
constructor(private root: HTMLElement, private ui: FpvDroneLapTimerUI) {
|
|
26
|
+
this.config = loadSessionConfig();
|
|
27
|
+
this.lapDurationsMs = loadSessionLaps();
|
|
28
|
+
this.currentLapNumber = Math.max(1, this.lapDurationsMs.length + 1);
|
|
29
|
+
this.audio = new RaceAudioSynthesizer(this.config.soundEnabled);
|
|
30
|
+
this.bindEvents();
|
|
31
|
+
this.syncInputs();
|
|
32
|
+
this.updateViews();
|
|
33
|
+
if (this.lapDurationsMs.length > 0) {
|
|
34
|
+
const display = this.query<HTMLElement>('.fpv-lap-timer-digits');
|
|
35
|
+
if (display) {
|
|
36
|
+
const lastLap = this.lapDurationsMs[this.lapDurationsMs.length - 1];
|
|
37
|
+
if (lastLap !== undefined) {
|
|
38
|
+
display.textContent = formatLapTime(lastLap);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private query<T extends HTMLElement>(selector: string): T | null {
|
|
45
|
+
return this.root.querySelector<T>(selector);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private syncInputs(): void {
|
|
49
|
+
const fields: [string, number][] = [
|
|
50
|
+
['#fpv-lap-timer-track-length', this.config.trackLengthM],
|
|
51
|
+
['#fpv-lap-timer-target-laps', this.config.targetLaps],
|
|
52
|
+
['#fpv-lap-timer-battery', this.config.batteryCapacityMah],
|
|
53
|
+
['#fpv-lap-timer-debounce', this.config.debounceMs / 1000],
|
|
54
|
+
];
|
|
55
|
+
fields.forEach(([id, val]) => {
|
|
56
|
+
const el = this.query<HTMLInputElement>(id);
|
|
57
|
+
if (el) el.value = String(val);
|
|
58
|
+
});
|
|
59
|
+
const sound = this.query<HTMLInputElement>('#fpv-lap-timer-sound');
|
|
60
|
+
if (sound) sound.checked = this.config.soundEnabled;
|
|
61
|
+
this.audio.setEnabled(this.config.soundEnabled);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private readInputs(): void {
|
|
65
|
+
const getNum = (id: string, def: number, min: number) =>
|
|
66
|
+
Math.max(min, Number(this.query<HTMLInputElement>(id)?.value) || def);
|
|
67
|
+
this.config.trackLengthM = getNum('#fpv-lap-timer-track-length', 250, 10);
|
|
68
|
+
this.config.targetLaps = getNum('#fpv-lap-timer-target-laps', 3, 0);
|
|
69
|
+
this.config.batteryCapacityMah = getNum('#fpv-lap-timer-battery', 1300, 100);
|
|
70
|
+
this.config.debounceMs = getNum('#fpv-lap-timer-debounce', 3, 1) * 1000;
|
|
71
|
+
const sound = this.query<HTMLInputElement>('#fpv-lap-timer-sound');
|
|
72
|
+
this.config.soundEnabled = sound?.checked ?? true;
|
|
73
|
+
this.audio.setEnabled(this.config.soundEnabled);
|
|
74
|
+
saveSessionConfig(this.config);
|
|
75
|
+
this.updateViews();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private updateSidebarStats(completed: number, avgMs: number, avgKmh: number): void {
|
|
79
|
+
const setText = (id: string, text: string) => {
|
|
80
|
+
const el = this.query<HTMLElement>(id);
|
|
81
|
+
if (el) el.textContent = text;
|
|
82
|
+
};
|
|
83
|
+
setText('#fpv-lap-timer-stat-completed', String(completed));
|
|
84
|
+
setText('#fpv-lap-timer-stat-avg', avgMs > 0 ? formatLapTime(avgMs) : '--');
|
|
85
|
+
setText('#fpv-lap-timer-stat-speed', avgKmh > 0 ? `${avgKmh} ${this.ui.speedUnitKmh || 'km/h'}` : '--');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private updateHud(completed: number): void {
|
|
89
|
+
const targetStr = this.config.targetLaps > 0 ? String(this.config.targetLaps) : '∞';
|
|
90
|
+
const lapNum = this.status === 'finished' ? completed : this.currentLapNumber;
|
|
91
|
+
const hudLap = this.query<HTMLElement>('#fpv-lap-timer-hud-lap');
|
|
92
|
+
if (hudLap) hudLap.textContent = `${this.ui.lapNumberPrefix || 'Lap'} ${lapNum} / ${targetStr}`;
|
|
93
|
+
const statusText = this.query<HTMLElement>('#fpv-lap-timer-status-text');
|
|
94
|
+
if (statusText) statusText.textContent = this.getStatusLabel();
|
|
95
|
+
const statusChip = this.query<HTMLElement>('.fpv-lap-timer-status-chip');
|
|
96
|
+
if (statusChip) statusChip.className = `fpv-lap-timer-chip fpv-lap-timer-status-chip fpv-lap-timer-status-${this.status}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private updateViews(): void {
|
|
100
|
+
const laps = computeLapRecords(this.lapDurationsMs, this.config.trackLengthM, this.config.batteryCapacityMah);
|
|
101
|
+
const metrics = calculateSessionMetrics(this.lapDurationsMs, this.config.trackLengthM, this.config.batteryCapacityMah);
|
|
102
|
+
const telem = this.query<HTMLElement>('.fpv-lap-timer-telemetry-mount');
|
|
103
|
+
if (telem) {
|
|
104
|
+
telem.innerHTML = renderTelemetryScene({
|
|
105
|
+
metrics, laps, currentLapNumber: this.currentLapNumber,
|
|
106
|
+
targetLaps: this.config.targetLaps, status: this.status, ui: this.ui,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const table = this.query<HTMLElement>('.fpv-lap-timer-table-mount');
|
|
110
|
+
if (table) table.innerHTML = renderLapTable(laps, this.ui);
|
|
111
|
+
this.updateSidebarStats(metrics.completedLaps, metrics.averageLapMs, metrics.averageSpeedKmh);
|
|
112
|
+
this.updateHud(metrics.completedLaps);
|
|
113
|
+
this.updateControlButtons();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private updateControlButtons(): void {
|
|
117
|
+
const isBusy = this.status === 'running' || this.status === 'countdown';
|
|
118
|
+
this.updatePrimaryButtons(isBusy);
|
|
119
|
+
this.updateTriggerButton();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private updatePrimaryButtons(isBusy: boolean): void {
|
|
123
|
+
const start = this.query<HTMLButtonElement>('#fpv-lap-timer-btn-start');
|
|
124
|
+
if (start) {
|
|
125
|
+
start.disabled = isBusy;
|
|
126
|
+
start.style.display = this.status === 'paused' ? 'none' : 'inline-flex';
|
|
127
|
+
if (this.status !== 'countdown') start.textContent = this.ui.startCountdownButton;
|
|
128
|
+
}
|
|
129
|
+
const pause = this.query<HTMLButtonElement>('#fpv-lap-timer-btn-pause');
|
|
130
|
+
if (pause) {
|
|
131
|
+
pause.style.display = isBusy || this.status === 'paused' ? 'inline-flex' : 'none';
|
|
132
|
+
pause.textContent = this.status === 'paused' ? this.ui.resumeTimerButton : this.ui.pauseTimerButton;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private updateTriggerButton(): void {
|
|
137
|
+
const trigger = this.query<HTMLButtonElement>('.fpv-lap-timer-giant-btn');
|
|
138
|
+
if (trigger) {
|
|
139
|
+
trigger.disabled = this.status !== 'running';
|
|
140
|
+
trigger.classList.toggle('fpv-lap-timer-btn-countdown-active', this.status === 'countdown');
|
|
141
|
+
const caption = trigger.querySelector<HTMLElement>('.fpv-lap-timer-btn-caption');
|
|
142
|
+
if (caption && this.status !== 'countdown') caption.textContent = this.ui.recordLapButton;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private tick = (): void => {
|
|
147
|
+
if (this.status === 'running') {
|
|
148
|
+
const display = this.query<HTMLElement>('.fpv-lap-timer-digits');
|
|
149
|
+
if (display) display.textContent = formatLapTime(performance.now() - this.lapStartTime);
|
|
150
|
+
this.animFrameId = requestAnimationFrame(this.tick);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
public startCountdown(): void {
|
|
155
|
+
if (this.status === 'running' || this.status === 'countdown') return;
|
|
156
|
+
this.status = 'countdown';
|
|
157
|
+
this.updateViews();
|
|
158
|
+
this.audio.playFaiCountdown(
|
|
159
|
+
(label) => this.paintCountdown(label),
|
|
160
|
+
() => this.beginRace()
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private paintCountdown(label: string): void {
|
|
165
|
+
const start = this.query<HTMLButtonElement>('#fpv-lap-timer-btn-start');
|
|
166
|
+
if (start) start.textContent = label;
|
|
167
|
+
const triggerCaption = this.query<HTMLElement>('.fpv-lap-timer-giant-btn .fpv-lap-timer-btn-caption');
|
|
168
|
+
if (triggerCaption) triggerCaption.textContent = label;
|
|
169
|
+
const display = this.query<HTMLElement>('.fpv-lap-timer-digits');
|
|
170
|
+
if (display) display.textContent = label;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private beginRace(): void {
|
|
174
|
+
this.status = 'running';
|
|
175
|
+
this.lapStartTime = performance.now();
|
|
176
|
+
this.lastRecordedTimestamp = 0;
|
|
177
|
+
this.currentLapNumber = 1;
|
|
178
|
+
this.updateViews();
|
|
179
|
+
this.tick();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public recordLap(): void {
|
|
183
|
+
if (this.status !== 'running') return;
|
|
184
|
+
const now = performance.now();
|
|
185
|
+
if (isDebounceLocked(this.lastRecordedTimestamp, now, this.config.debounceMs)) return;
|
|
186
|
+
|
|
187
|
+
const lapDuration = now - this.lapStartTime;
|
|
188
|
+
const isNewFastest = this.lapDurationsMs.length === 0 || lapDuration < Math.min(...this.lapDurationsMs);
|
|
189
|
+
this.lapDurationsMs.push(lapDuration);
|
|
190
|
+
saveSessionLaps(this.lapDurationsMs);
|
|
191
|
+
this.lastRecordedTimestamp = now;
|
|
192
|
+
this.lapStartTime = now;
|
|
193
|
+
this.currentLapNumber += 1;
|
|
194
|
+
this.audio.playLapSound(isNewFastest);
|
|
195
|
+
|
|
196
|
+
if (this.config.targetLaps > 0 && this.lapDurationsMs.length >= this.config.targetLaps) {
|
|
197
|
+
this.finishRace();
|
|
198
|
+
} else {
|
|
199
|
+
this.updateViews();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
public pauseRace(): void {
|
|
204
|
+
if (this.status === 'running') {
|
|
205
|
+
this.status = 'paused';
|
|
206
|
+
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
|
|
207
|
+
this.updateViews();
|
|
208
|
+
} else if (this.status === 'paused') {
|
|
209
|
+
this.status = 'running';
|
|
210
|
+
this.lapStartTime = performance.now();
|
|
211
|
+
this.updateViews();
|
|
212
|
+
this.tick();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public finishRace(): void {
|
|
217
|
+
this.status = 'finished';
|
|
218
|
+
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
|
|
219
|
+
const display = this.query<HTMLElement>('.fpv-lap-timer-digits');
|
|
220
|
+
if (display) {
|
|
221
|
+
const total = this.lapDurationsMs.reduce((acc, d) => acc + d, 0);
|
|
222
|
+
display.textContent = formatLapTime(total);
|
|
223
|
+
}
|
|
224
|
+
this.audio.playFinishFanfare();
|
|
225
|
+
this.updateViews();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public resetSession(): void {
|
|
229
|
+
this.status = 'idle';
|
|
230
|
+
this.audio.cancelPendingTones();
|
|
231
|
+
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
|
|
232
|
+
this.lapDurationsMs = [];
|
|
233
|
+
saveSessionLaps([]);
|
|
234
|
+
this.currentLapNumber = 1;
|
|
235
|
+
this.lapStartTime = 0;
|
|
236
|
+
this.lastRecordedTimestamp = 0;
|
|
237
|
+
const display = this.query<HTMLElement>('.fpv-lap-timer-digits');
|
|
238
|
+
if (display) display.textContent = '00:00.000';
|
|
239
|
+
this.updateViews();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
public applyPreset(presetKey: 'multigp' | 'whoop' | 'sprint'): void {
|
|
243
|
+
const p = PRESETS[presetKey];
|
|
244
|
+
this.config.trackLengthM = p.trackLengthM;
|
|
245
|
+
this.config.targetLaps = p.targetLaps;
|
|
246
|
+
this.config.batteryCapacityMah = p.batteryCapacityMah;
|
|
247
|
+
saveSessionConfig(this.config);
|
|
248
|
+
this.syncInputs();
|
|
249
|
+
this.root.querySelectorAll('.fpv-lap-timer-preset').forEach((btn) => {
|
|
250
|
+
btn.classList.toggle('active', btn.getAttribute('data-preset') === presetKey);
|
|
251
|
+
});
|
|
252
|
+
this.updateViews();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
public exportCsv(): void {
|
|
256
|
+
exportSessionCsv(this.lapDurationsMs, this.config);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
public async copySummary(): Promise<void> {
|
|
260
|
+
const copied = await copySessionSummary(this.lapDurationsMs, this.config);
|
|
261
|
+
if (copied) {
|
|
262
|
+
const notice = this.query<HTMLElement>('#fpv-lap-timer-copy-notice');
|
|
263
|
+
if (notice) {
|
|
264
|
+
notice.style.display = 'inline-block';
|
|
265
|
+
setTimeout(() => {
|
|
266
|
+
notice.style.display = 'none';
|
|
267
|
+
}, 3000);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private getStatusLabel(): string {
|
|
273
|
+
const labels = { countdown: this.ui.statusCountdown, running: this.ui.statusRunning, paused: this.ui.statusPaused, finished: this.ui.statusFinished };
|
|
274
|
+
return labels[this.status as keyof typeof labels] || this.ui.statusIdle || 'Ready for Start';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private bindEvents(): void {
|
|
278
|
+
const clicks: [string, () => void][] = [
|
|
279
|
+
['#fpv-lap-timer-btn-start', () => this.startCountdown()],
|
|
280
|
+
['#fpv-lap-timer-btn-pause', () => this.pauseRace()],
|
|
281
|
+
['#fpv-lap-timer-btn-reset', () => this.resetSession()],
|
|
282
|
+
['.fpv-lap-timer-giant-btn', () => this.recordLap()],
|
|
283
|
+
['#fpv-lap-timer-export-csv', () => this.exportCsv()],
|
|
284
|
+
['#fpv-lap-timer-copy-summary', () => this.copySummary()],
|
|
285
|
+
];
|
|
286
|
+
clicks.forEach(([sel, fn]) => this.query(sel)?.addEventListener('click', fn));
|
|
287
|
+
['#fpv-lap-timer-track-length', '#fpv-lap-timer-target-laps', '#fpv-lap-timer-battery', '#fpv-lap-timer-debounce', '#fpv-lap-timer-sound'].forEach((id) => {
|
|
288
|
+
this.query(id)?.addEventListener('change', () => this.readInputs());
|
|
289
|
+
});
|
|
290
|
+
this.root.querySelectorAll('.fpv-lap-timer-preset').forEach((btn) => {
|
|
291
|
+
btn.addEventListener('click', () => {
|
|
292
|
+
const k = btn.getAttribute('data-preset') as 'multigp' | 'whoop' | 'sprint';
|
|
293
|
+
if (k) this.applyPreset(k);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
public destroy(): void {
|
|
299
|
+
this.audio.cancelPendingTones();
|
|
300
|
+
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export { initializeLapTimer } from './bootstrap';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { LapRecord, SessionMetrics } from './logic';
|
|
2
|
+
import { formatLapTime, formatDelta } from './logic';
|
|
3
|
+
import type { FpvDroneLapTimerUI } from './ui';
|
|
4
|
+
|
|
5
|
+
export interface TelemetrySceneProps {
|
|
6
|
+
metrics: SessionMetrics;
|
|
7
|
+
laps: LapRecord[];
|
|
8
|
+
currentLapNumber: number;
|
|
9
|
+
targetLaps: number;
|
|
10
|
+
status: 'idle' | 'countdown' | 'running' | 'paused' | 'finished';
|
|
11
|
+
ui: FpvDroneLapTimerUI;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function renderPaceBarsSvg(laps: LapRecord[]): string {
|
|
15
|
+
const maxDur = laps.length > 0 ? Math.max(...laps.map((l) => l.durationMs)) : 1;
|
|
16
|
+
return laps
|
|
17
|
+
.map((lap, idx) => {
|
|
18
|
+
const barHeight = Math.max(12, Math.round((lap.durationMs / maxDur) * 52));
|
|
19
|
+
const y = 80 - barHeight;
|
|
20
|
+
const x = 16 + idx * 26;
|
|
21
|
+
const fillClass = lap.isBest ? 'fpv-lap-timer-bar-best' : 'fpv-lap-timer-bar-normal';
|
|
22
|
+
return `<g class="fpv-lap-timer-bar-group" data-lap="${lap.lapIndex}">
|
|
23
|
+
<rect x="${x}" y="${y}" width="18" height="${barHeight}" rx="3" class="${fillClass}" />
|
|
24
|
+
<text x="${x + 9}" y="95" class="fpv-lap-timer-bar-label">${lap.lapIndex}</text>
|
|
25
|
+
</g>`;
|
|
26
|
+
})
|
|
27
|
+
.join('');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderDroneRacerSvg(status: 'idle' | 'countdown' | 'running' | 'paused' | 'finished'): string {
|
|
31
|
+
const isRacing = status === 'running';
|
|
32
|
+
const motionTag = isRacing
|
|
33
|
+
? `<animateMotion
|
|
34
|
+
path="M 160 25 L 230 25 A 65 65 0 0 1 230 155 L 90 155 A 65 65 0 0 1 90 25 Z"
|
|
35
|
+
dur="6s"
|
|
36
|
+
repeatCount="indefinite"
|
|
37
|
+
rotate="auto"
|
|
38
|
+
/>`
|
|
39
|
+
: '';
|
|
40
|
+
const initialTransform = isRacing ? '' : 'transform="translate(160, 25)"';
|
|
41
|
+
|
|
42
|
+
return `
|
|
43
|
+
<g class="fpv-lap-timer-drone-racer" ${initialTransform} filter="url(#fpvGlow)">
|
|
44
|
+
${motionTag}
|
|
45
|
+
<line x1="-6" y1="-6" x2="6" y2="6" class="fpv-lap-timer-drone-arm" />
|
|
46
|
+
<line x1="-6" y1="6" x2="6" y2="-6" class="fpv-lap-timer-drone-arm" />
|
|
47
|
+
<circle cx="-6" cy="-6" r="2" class="fpv-lap-timer-drone-motor" />
|
|
48
|
+
<circle cx="6" cy="-6" r="2" class="fpv-lap-timer-drone-motor" />
|
|
49
|
+
<circle cx="-6" cy="6" r="2" class="fpv-lap-timer-drone-motor" />
|
|
50
|
+
<circle cx="6" cy="6" r="2" class="fpv-lap-timer-drone-motor" />
|
|
51
|
+
<circle cx="-6" cy="-6" r="4.2" class="fpv-lap-timer-drone-rotor-blur" />
|
|
52
|
+
<circle cx="6" cy="-6" r="4.2" class="fpv-lap-timer-drone-rotor-blur" />
|
|
53
|
+
<circle cx="-6" cy="6" r="4.2" class="fpv-lap-timer-drone-rotor-blur" />
|
|
54
|
+
<circle cx="6" cy="6" r="4.2" class="fpv-lap-timer-drone-rotor-blur" />
|
|
55
|
+
<rect x="-4" y="-3.5" width="8" height="7" rx="2" class="fpv-lap-timer-drone-body" />
|
|
56
|
+
<circle cx="4" cy="0" r="1.5" class="fpv-lap-timer-drone-cam" />
|
|
57
|
+
<circle cx="-4" cy="0" r="1.5" class="fpv-lap-timer-drone-led" />
|
|
58
|
+
</g>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderGatesSvg(status: 'idle' | 'countdown' | 'running' | 'paused' | 'finished'): string {
|
|
63
|
+
return `
|
|
64
|
+
<rect x="148" y="19" width="24" height="12" rx="3" fill="none" class="fpv-lap-timer-gate fpv-lap-timer-gate-sf" />
|
|
65
|
+
<text x="160" y="11" class="fpv-lap-timer-gate-label">GATE 1 [S/F]</text>
|
|
66
|
+
<circle cx="295" cy="90" r="5" class="fpv-lap-timer-gate fpv-lap-timer-gate-sector" />
|
|
67
|
+
<text x="305" y="94" class="fpv-lap-timer-gate-label">G2</text>
|
|
68
|
+
<circle cx="160" cy="155" r="5" class="fpv-lap-timer-gate fpv-lap-timer-gate-sector" />
|
|
69
|
+
<text x="160" y="174" class="fpv-lap-timer-gate-label">G3</text>
|
|
70
|
+
<circle cx="25" cy="90" r="5" class="fpv-lap-timer-gate fpv-lap-timer-gate-sector" />
|
|
71
|
+
<text x="15" y="94" class="fpv-lap-timer-gate-label">G4</text>
|
|
72
|
+
${renderDroneRacerSvg(status)}
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderRacetrackCenterHud(timeStr: string, fastestHeading: string, speedStr: string): string {
|
|
77
|
+
return `
|
|
78
|
+
<g class="fpv-lap-timer-drone-center">
|
|
79
|
+
<text x="160" y="78" class="fpv-lap-timer-center-time">${timeStr}</text>
|
|
80
|
+
<text x="160" y="96" class="fpv-lap-timer-center-sub">${fastestHeading}</text>
|
|
81
|
+
<text x="160" y="116" class="fpv-lap-timer-center-speed">${speedStr}</text>
|
|
82
|
+
</g>
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderRacetrackSvg(
|
|
87
|
+
metrics: SessionMetrics,
|
|
88
|
+
targetLaps: number,
|
|
89
|
+
status: 'idle' | 'countdown' | 'running' | 'paused' | 'finished',
|
|
90
|
+
ui: FpvDroneLapTimerUI
|
|
91
|
+
): string {
|
|
92
|
+
const progress = targetLaps > 0 ? Math.min(100, Math.round((metrics.completedLaps / targetLaps) * 100)) : 100;
|
|
93
|
+
const offset = 640 - (640 * progress) / 100;
|
|
94
|
+
const timeStr = metrics.fastestLapMs > 0 ? formatLapTime(metrics.fastestLapMs) : '00:00.000';
|
|
95
|
+
const speedStr = metrics.averageSpeedKmh > 0 ? `${metrics.averageSpeedKmh} ${ui.speedUnitKmh}` : '--';
|
|
96
|
+
|
|
97
|
+
return `
|
|
98
|
+
<div class="fpv-lap-timer-racetrack-box">
|
|
99
|
+
<svg class="fpv-lap-timer-racetrack-svg" viewBox="0 0 320 180" role="img" aria-label="Racetrack layout">
|
|
100
|
+
<defs>
|
|
101
|
+
<linearGradient id="fpvTrackGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
102
|
+
<stop offset="0%" stop-color="var(--fpv-cyan)" /><stop offset="100%" stop-color="var(--fpv-emerald)" />
|
|
103
|
+
</linearGradient>
|
|
104
|
+
<filter id="fpvGlow" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="3" result="blur" /><feComposite in="SourceGraphic" in2="blur" operator="over" /></filter>
|
|
105
|
+
</defs>
|
|
106
|
+
<rect x="25" y="25" width="270" height="130" rx="65" fill="none" class="fpv-lap-timer-track-bg" />
|
|
107
|
+
<rect x="25" y="25" width="270" height="130" rx="65" fill="none" stroke="url(#fpvTrackGrad)" class="fpv-lap-timer-track-active" stroke-dasharray="640" stroke-dashoffset="${offset}" />
|
|
108
|
+
${renderGatesSvg(status)}
|
|
109
|
+
${renderRacetrackCenterHud(timeStr, ui.fastestLapHeading, speedStr)}
|
|
110
|
+
</svg>
|
|
111
|
+
</div>
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderPaceChart(metrics: SessionMetrics, laps: LapRecord[], ui: FpvDroneLapTimerUI): string {
|
|
116
|
+
const badgeStr = metrics.fastestLapMs > 0 ? `${ui.fastestLapBadge}: ${formatLapTime(metrics.fastestLapMs)}` : '--';
|
|
117
|
+
const chartWidth = Math.max(140, 16 + laps.length * 26 + 16);
|
|
118
|
+
const axisWidth = Math.max(132, 16 + laps.length * 26 + 8);
|
|
119
|
+
const chartInner = laps.length > 0
|
|
120
|
+
? `<svg class="fpv-lap-timer-bars-svg" viewBox="0 0 ${chartWidth} 104" role="img" aria-label="Lap times bar comparison">
|
|
121
|
+
<line x1="8" y1="80" x2="${axisWidth}" y2="80" class="fpv-lap-timer-chart-axis" />
|
|
122
|
+
${renderPaceBarsSvg(laps)}
|
|
123
|
+
</svg>`
|
|
124
|
+
: `<div class="fpv-lap-timer-empty-notice">${ui.noLapsRecordedNotice}</div>`;
|
|
125
|
+
|
|
126
|
+
return `
|
|
127
|
+
<div class="fpv-lap-timer-pace-box">
|
|
128
|
+
<div class="fpv-lap-timer-pace-header">
|
|
129
|
+
<h4>${ui.lapHistoryHeading}</h4>
|
|
130
|
+
<span class="fpv-lap-timer-fastest-pill">${badgeStr}</span>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="fpv-lap-timer-pace-chart">${chartInner}</div>
|
|
133
|
+
</div>
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderTelemetryScene(props: TelemetrySceneProps): string {
|
|
138
|
+
const { metrics, laps, targetLaps, ui } = props;
|
|
139
|
+
const ratingLabel = getConsistencyRatingLabel(metrics.consistencyRating, ui);
|
|
140
|
+
|
|
141
|
+
return `
|
|
142
|
+
<div class="fpv-lap-timer-telemetry-deck" aria-label="FPV Race Telemetry Scene">
|
|
143
|
+
<div class="fpv-lap-timer-telemetry-status-row">
|
|
144
|
+
<div class="fpv-lap-timer-chip fpv-lap-timer-consistency-chip fpv-lap-timer-rating-${metrics.consistencyRating}">
|
|
145
|
+
<span>${ui.consistencyIndexHeading}: <strong>${metrics.consistencyIndex}%</strong> (${ratingLabel})</span>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="fpv-lap-timer-telemetry-grid">
|
|
149
|
+
${renderRacetrackSvg(metrics, targetLaps, props.status, ui)}
|
|
150
|
+
${renderPaceChart(metrics, laps, ui)}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getDeltaClass(deltaMs: number, isBest: boolean): string {
|
|
157
|
+
if (isBest) return 'fpv-lap-timer-delta-best';
|
|
158
|
+
if (deltaMs < 1000) return 'fpv-lap-timer-delta-good';
|
|
159
|
+
return 'fpv-lap-timer-delta-slow';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderLapRow(lap: LapRecord, ui: FpvDroneLapTimerUI): string {
|
|
163
|
+
const bestClass = lap.isBest ? 'fpv-lap-timer-row-best' : '';
|
|
164
|
+
const deltaFormatted = formatDelta(lap.deltaBestMs, lap.isBest);
|
|
165
|
+
const deltaClass = getDeltaClass(lap.deltaBestMs, lap.isBest);
|
|
166
|
+
const fastestBadge = lap.isBest ? `<span class="fpv-lap-timer-best-mini">${ui.fastestLapBadge}</span>` : '';
|
|
167
|
+
|
|
168
|
+
return `
|
|
169
|
+
<tr class="fpv-lap-timer-tr ${bestClass}">
|
|
170
|
+
<td class="fpv-lap-timer-td-lap">
|
|
171
|
+
<span class="fpv-lap-timer-lap-tag">${ui.lapNumberPrefix} ${lap.lapIndex}</span>
|
|
172
|
+
${fastestBadge}
|
|
173
|
+
</td>
|
|
174
|
+
<td class="fpv-lap-timer-td-time font-mono">${formatLapTime(lap.durationMs)}</td>
|
|
175
|
+
<td class="fpv-lap-timer-td-split"><span class="fpv-lap-timer-delta-pill ${deltaClass}">${deltaFormatted}</span></td>
|
|
176
|
+
<td class="fpv-lap-timer-td-speed font-mono">${lap.speedKmh} ${ui.speedUnitKmh} <small class="fpv-lap-timer-subtle">(${lap.speedMph} ${ui.speedUnitMph})</small></td>
|
|
177
|
+
<td class="fpv-lap-timer-td-battery font-mono">${lap.batteryUsedMah} ${ui.batteryUsedUnit}</td>
|
|
178
|
+
<td class="fpv-lap-timer-td-total font-mono fpv-lap-timer-subtle">${formatLapTime(lap.splitTimeMs)}</td>
|
|
179
|
+
</tr>
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function renderLapTable(laps: LapRecord[], ui: FpvDroneLapTimerUI): string {
|
|
184
|
+
if (laps.length === 0) {
|
|
185
|
+
return `<div class="fpv-lap-timer-table-empty">${ui.noLapsRecordedNotice}</div>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const rows = laps.map((lap) => renderLapRow(lap, ui)).join('');
|
|
189
|
+
return `
|
|
190
|
+
<div class="fpv-lap-timer-table-wrap">
|
|
191
|
+
<table class="fpv-lap-timer-table">
|
|
192
|
+
<thead>
|
|
193
|
+
<tr>
|
|
194
|
+
<th>${ui.lapColumnHeader}</th>
|
|
195
|
+
<th>${ui.timeColumnHeader}</th>
|
|
196
|
+
<th>${ui.splitColumnHeader}</th>
|
|
197
|
+
<th>${ui.speedColumnHeader}</th>
|
|
198
|
+
<th>${ui.batteryColumnHeader}</th>
|
|
199
|
+
<th>Total Split</th>
|
|
200
|
+
</tr>
|
|
201
|
+
</thead>
|
|
202
|
+
<tbody>${rows}</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
</div>
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getConsistencyRatingLabel(rating: 'elite' | 'pro' | 'club' | 'novice', ui: FpvDroneLapTimerUI): string {
|
|
209
|
+
if (rating === 'elite') return ui.consistencyRatingElite;
|
|
210
|
+
if (rating === 'pro') return ui.consistencyRatingPro;
|
|
211
|
+
if (rating === 'club') return ui.consistencyRatingClub;
|
|
212
|
+
return ui.consistencyRatingNovice;
|
|
213
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DronesToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { FpvDroneLapTimerUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { FpvDroneLapTimerUI };
|
|
5
|
+
export type FpvDroneLapTimerLocaleContent = ToolLocaleContent<FpvDroneLapTimerUI>;
|
|
6
|
+
|
|
7
|
+
export const fpvDroneLapTimer: DronesToolEntry<FpvDroneLapTimerUI> = {
|
|
8
|
+
id: 'fpv-drone-lap-timer',
|
|
9
|
+
icons: { bg: 'mdi:timer-outline', fg: 'mdi:quadcopter' },
|
|
10
|
+
i18n: {
|
|
11
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
12
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
13
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
14
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
15
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
16
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
17
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
18
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
19
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
20
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
21
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
22
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
23
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
24
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
25
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SessionConfig, LapRecord, SessionMetrics } from './logic';
|
|
2
|
+
import { computeLapRecords, calculateSessionMetrics, formatLapTime, formatDelta } from './logic';
|
|
3
|
+
|
|
4
|
+
export function exportSessionCsv(durationsMs: number[], config: SessionConfig): void {
|
|
5
|
+
const laps = computeLapRecords(durationsMs, config.trackLengthM, config.batteryCapacityMah);
|
|
6
|
+
if (laps.length === 0) return;
|
|
7
|
+
const headers = ['Lap', 'Time_Ms', 'Formatted_Time', 'Delta_Best_Ms', 'Speed_Kmh', 'Battery_Used_Mah', 'Split_Time_Ms'];
|
|
8
|
+
const rows = laps.map((l) => [
|
|
9
|
+
l.lapIndex, l.durationMs.toFixed(1), formatLapTime(l.durationMs),
|
|
10
|
+
l.deltaBestMs.toFixed(1), l.speedKmh, l.batteryUsedMah, l.splitTimeMs.toFixed(1),
|
|
11
|
+
]);
|
|
12
|
+
const blob = new Blob([[headers.join(','), ...rows.map((r) => r.join(','))].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
|
13
|
+
const url = URL.createObjectURL(blob);
|
|
14
|
+
const link = document.createElement('a');
|
|
15
|
+
link.href = url;
|
|
16
|
+
link.download = `fpv-drone-laps-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
17
|
+
link.click();
|
|
18
|
+
URL.revokeObjectURL(url);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildSummaryLines(laps: LapRecord[], metrics: SessionMetrics, trackLengthM: number): string[] {
|
|
22
|
+
return [
|
|
23
|
+
'FPV DRONE RACE SESSION SUMMARY',
|
|
24
|
+
`Track: ${trackLengthM}m | Laps: ${metrics.completedLaps} | Total: ${formatLapTime(metrics.totalDurationMs)}`,
|
|
25
|
+
`Fastest: ${metrics.fastestLapMs > 0 ? formatLapTime(metrics.fastestLapMs) : '--'} | Speed: ${metrics.averageSpeedKmh} km/h`,
|
|
26
|
+
`Consistency: ${metrics.consistencyIndex}% (${metrics.consistencyRating.toUpperCase()})`,
|
|
27
|
+
...laps.map((l) => `Lap ${l.lapIndex}: ${formatLapTime(l.durationMs)} (Delta: ${formatDelta(l.deltaBestMs, l.isBest)})`),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function copySessionSummary(durationsMs: number[], config: SessionConfig): Promise<boolean> {
|
|
32
|
+
const laps = computeLapRecords(durationsMs, config.trackLengthM, config.batteryCapacityMah);
|
|
33
|
+
const metrics = calculateSessionMetrics(durationsMs, config.trackLengthM, config.batteryCapacityMah);
|
|
34
|
+
const lines = buildSummaryLines(laps, metrics, config.trackLengthM);
|
|
35
|
+
try {
|
|
36
|
+
await navigator.clipboard.writeText(lines.join('\n'));
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|