@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-drones",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"postinstall": "node scripts/postinstall.mjs",
|
|
37
37
|
"predev": "node scripts/postinstall.mjs",
|
|
38
38
|
"prestart": "node scripts/postinstall.mjs",
|
|
39
|
-
"prebuild": "node scripts/postinstall.mjs",
|
|
39
|
+
"prebuild": "node scripts/postinstall.mjs && node scripts/validate-icons.mjs",
|
|
40
40
|
"qa": "npm run lint && npm run test && npm run build",
|
|
41
41
|
"cf:dry-run": "npm run build && wrangler deploy --dry-run",
|
|
42
42
|
"cf:preview": "npm run build && wrangler deploy --config wrangler.staging.jsonc",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const packageJsonPath = require.resolve('@iconify-json/mdi/package.json');
|
|
10
|
+
const packageRoot = dirname(packageJsonPath);
|
|
11
|
+
const iconSet = JSON.parse(readFileSync(join(packageRoot, 'icons.json'), 'utf8'));
|
|
12
|
+
const availableIcons = new Set([
|
|
13
|
+
...Object.keys(iconSet.icons ?? {}),
|
|
14
|
+
...Object.keys(iconSet.aliases ?? {}),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const sourceRoot = resolve(repoRoot, 'src');
|
|
19
|
+
const extensions = new Set(['.astro', '.js', '.mjs', '.ts', '.tsx']);
|
|
20
|
+
const iconPattern = /\bmdi:([a-z0-9-]+)\b/g;
|
|
21
|
+
const failures = [];
|
|
22
|
+
let references = 0;
|
|
23
|
+
|
|
24
|
+
function walk(directory) {
|
|
25
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const path = join(directory, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== 'tests') {
|
|
30
|
+
walk(path);
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!extensions.has(path.slice(path.lastIndexOf('.')))) continue;
|
|
35
|
+
|
|
36
|
+
const source = readFileSync(path, 'utf8');
|
|
37
|
+
for (const match of source.matchAll(iconPattern)) {
|
|
38
|
+
references += 1;
|
|
39
|
+
const iconName = match[1];
|
|
40
|
+
if (availableIcons.has(iconName)) continue;
|
|
41
|
+
|
|
42
|
+
const line = source.slice(0, match.index).split('\n').length;
|
|
43
|
+
failures.push(`${relative(repoRoot, path)}:${line} — mdi:${iconName}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
walk(sourceRoot);
|
|
49
|
+
|
|
50
|
+
if (failures.length > 0) {
|
|
51
|
+
console.error('Invalid MDI icons found:');
|
|
52
|
+
for (const failure of failures) console.error(`- ${failure}`);
|
|
53
|
+
console.error(`Checked ${references} MDI icon references against @iconify-json/mdi.`);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
} else {
|
|
56
|
+
console.log(`MDI icon validation passed: ${references} references checked.`);
|
|
57
|
+
}
|
package/src/category/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { fpvDroneThrustToWeightRatio } from '../tool/fpv-drone-thrust-to-weight-
|
|
|
9
9
|
import { droneMotorPropellerCalculator } from '../tool/drone-motor-propeller-calculator/entry';
|
|
10
10
|
import { fpvDroneSpeedCalculator } from '../tool/fpv-drone-speed-calculator/entry';
|
|
11
11
|
import { droneMissionBatteryReservePlanner } from '../tool/drone-mission-battery-reserve-planner/entry';
|
|
12
|
+
import { fpvDroneLapTimer } from '../tool/fpv-drone-lap-timer/entry';
|
|
12
13
|
|
|
13
14
|
export const dronesCategory: DronesCategoryEntry = {
|
|
14
15
|
icon: 'mdi:drone',
|
|
@@ -23,6 +24,7 @@ export const dronesCategory: DronesCategoryEntry = {
|
|
|
23
24
|
droneMotorPropellerCalculator,
|
|
24
25
|
fpvDroneSpeedCalculator,
|
|
25
26
|
droneMissionBatteryReservePlanner,
|
|
27
|
+
fpvDroneLapTimer,
|
|
26
28
|
],
|
|
27
29
|
i18n: {
|
|
28
30
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -18,6 +18,8 @@ export { fpvDroneSpeedCalculator } from './tool/fpv-drone-speed-calculator/entry
|
|
|
18
18
|
export type { FpvDroneSpeedUI, FpvDroneSpeedLocaleContent } from './tool/fpv-drone-speed-calculator/entry';
|
|
19
19
|
export { droneMissionBatteryReservePlanner } from './tool/drone-mission-battery-reserve-planner/entry';
|
|
20
20
|
export type { DroneMissionBatteryReservePlannerUI, DroneMissionBatteryReservePlannerLocaleContent } from './tool/drone-mission-battery-reserve-planner/entry';
|
|
21
|
+
export { fpvDroneLapTimer } from './tool/fpv-drone-lap-timer/entry';
|
|
22
|
+
export type { FpvDroneLapTimerUI, FpvDroneLapTimerLocaleContent } from './tool/fpv-drone-lap-timer/entry';
|
|
21
23
|
export { dronesCategory } from './category';
|
|
22
24
|
|
|
23
25
|
import { antennaLengthCalculator } from './tool/antenna-length-calculator/entry';
|
|
@@ -30,6 +32,7 @@ import { fpvDroneThrustToWeightRatio } from './tool/fpv-drone-thrust-to-weight-r
|
|
|
30
32
|
import { droneMotorPropellerCalculator } from './tool/drone-motor-propeller-calculator/entry';
|
|
31
33
|
import { fpvDroneSpeedCalculator } from './tool/fpv-drone-speed-calculator/entry';
|
|
32
34
|
import { droneMissionBatteryReservePlanner } from './tool/drone-mission-battery-reserve-planner/entry';
|
|
35
|
+
import { fpvDroneLapTimer } from './tool/fpv-drone-lap-timer/entry';
|
|
33
36
|
|
|
34
37
|
export const ALL_ENTRIES = [
|
|
35
38
|
antennaLengthCalculator,
|
|
@@ -42,4 +45,5 @@ export const ALL_ENTRIES = [
|
|
|
42
45
|
droneMotorPropellerCalculator,
|
|
43
46
|
fpvDroneSpeedCalculator,
|
|
44
47
|
droneMissionBatteryReservePlanner,
|
|
48
|
+
fpvDroneLapTimer,
|
|
45
49
|
];
|
package/src/index.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { DRONE_POWER_ANALYZER_TOOL } from './tool/drone-power-analyzer/index';
|
|
|
23
23
|
export { GSD_FLIGHT_PLANNER_TOOL } from './tool/gsd-flight-planner/index';
|
|
24
24
|
export { DRONE_MOTOR_PROPELLER_CALCULATOR_TOOL } from './tool/drone-motor-propeller-calculator/index';
|
|
25
25
|
export { FPV_DRONE_SPEED_CALCULATOR_TOOL } from './tool/fpv-drone-speed-calculator/index';
|
|
26
|
+
export { FPV_DRONE_LAP_TIMER_TOOL } from './tool/fpv-drone-lap-timer/index';
|
|
@@ -4,8 +4,8 @@ import { dronesCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 11 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(11);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('dronesCategory should be defined', () => {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export class RaceAudioSynthesizer {
|
|
2
|
+
private audioCtx: AudioContext | null = null;
|
|
3
|
+
private enabled = true;
|
|
4
|
+
private activeTimeouts: number[] = [];
|
|
5
|
+
|
|
6
|
+
constructor(enabled = true) {
|
|
7
|
+
this.enabled = enabled;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public setEnabled(enabled: boolean): void {
|
|
11
|
+
this.enabled = enabled;
|
|
12
|
+
if (!enabled) {
|
|
13
|
+
this.cancelPendingTones();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public isEnabled(): boolean {
|
|
18
|
+
return this.enabled;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private initContext(): AudioContext | null {
|
|
22
|
+
if (typeof window === 'undefined') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (!this.audioCtx) {
|
|
26
|
+
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
27
|
+
if (AudioContextClass) {
|
|
28
|
+
this.audioCtx = new AudioContextClass();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (this.audioCtx && this.audioCtx.state === 'suspended') {
|
|
32
|
+
this.audioCtx.resume().catch(() => {});
|
|
33
|
+
}
|
|
34
|
+
return this.audioCtx;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public playTone(frequency: number, durationSeconds: number, type: OscillatorType = 'sine', peakGain = 0.25): void {
|
|
38
|
+
if (!this.enabled) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const ctx = this.initContext();
|
|
42
|
+
if (!ctx) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const osc = ctx.createOscillator();
|
|
48
|
+
const gain = ctx.createGain();
|
|
49
|
+
|
|
50
|
+
osc.type = type;
|
|
51
|
+
osc.frequency.setValueAtTime(frequency, ctx.currentTime);
|
|
52
|
+
|
|
53
|
+
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
|
54
|
+
gain.gain.exponentialRampToValueAtTime(peakGain, ctx.currentTime + 0.02);
|
|
55
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + durationSeconds);
|
|
56
|
+
|
|
57
|
+
osc.connect(gain);
|
|
58
|
+
gain.connect(ctx.destination);
|
|
59
|
+
|
|
60
|
+
osc.start(ctx.currentTime);
|
|
61
|
+
osc.stop(ctx.currentTime + durationSeconds + 0.05);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public playFaiCountdown(onTick: (label: string) => void, onGo: () => void): void {
|
|
66
|
+
this.cancelPendingTones();
|
|
67
|
+
const steps = ['3', '2', '1'];
|
|
68
|
+
|
|
69
|
+
if (!this.enabled) {
|
|
70
|
+
steps.forEach((label, idx) => {
|
|
71
|
+
const tid = window.setTimeout(() => onTick(label), idx * 1000);
|
|
72
|
+
this.activeTimeouts.push(tid);
|
|
73
|
+
});
|
|
74
|
+
const goTid = window.setTimeout(() => {
|
|
75
|
+
onTick('Go');
|
|
76
|
+
onGo();
|
|
77
|
+
}, steps.length * 1000);
|
|
78
|
+
this.activeTimeouts.push(goTid);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.initContext();
|
|
83
|
+
|
|
84
|
+
steps.forEach((label, idx) => {
|
|
85
|
+
const tid = window.setTimeout(() => {
|
|
86
|
+
this.playTone(440, 0.18, 'sine', 0.3);
|
|
87
|
+
onTick(label);
|
|
88
|
+
}, idx * 1000);
|
|
89
|
+
this.activeTimeouts.push(tid);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const goTid = window.setTimeout(() => {
|
|
93
|
+
this.playTone(880, 0.45, 'sawtooth', 0.4);
|
|
94
|
+
onTick('Go');
|
|
95
|
+
onGo();
|
|
96
|
+
}, steps.length * 1000);
|
|
97
|
+
this.activeTimeouts.push(goTid);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public playCountdownSequence(onGo: () => void, onTick?: (label: string) => void): void {
|
|
101
|
+
this.playFaiCountdown(
|
|
102
|
+
(label) => {
|
|
103
|
+
onTick?.(label);
|
|
104
|
+
},
|
|
105
|
+
() => {
|
|
106
|
+
onGo();
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public playLapSound(isFastest = false): void {
|
|
112
|
+
if (isFastest) {
|
|
113
|
+
this.playFastestLapChime();
|
|
114
|
+
} else {
|
|
115
|
+
this.playLapChirp();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public playLapChirp(): void {
|
|
120
|
+
if (!this.enabled) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.playTone(660, 0.06, 'sine', 0.25);
|
|
124
|
+
const tid = window.setTimeout(() => {
|
|
125
|
+
this.playTone(880, 0.08, 'sine', 0.3);
|
|
126
|
+
}, 70);
|
|
127
|
+
this.activeTimeouts.push(tid);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public playFastestLapChime(): void {
|
|
131
|
+
if (!this.enabled) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const notes = [523.25, 659.25, 783.99, 1046.5];
|
|
135
|
+
notes.forEach((freq, idx) => {
|
|
136
|
+
const tid = window.setTimeout(() => {
|
|
137
|
+
this.playTone(freq, 0.12, 'triangle', 0.35);
|
|
138
|
+
}, idx * 75);
|
|
139
|
+
this.activeTimeouts.push(tid);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public playFinishFanfare(): void {
|
|
144
|
+
if (!this.enabled) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const notes = [440, 554.37, 659.25, 880];
|
|
148
|
+
notes.forEach((freq, idx) => {
|
|
149
|
+
const tid = window.setTimeout(() => {
|
|
150
|
+
this.playTone(freq, 0.22, 'triangle', 0.3);
|
|
151
|
+
}, idx * 110);
|
|
152
|
+
this.activeTimeouts.push(tid);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public cancelPendingTones(): void {
|
|
157
|
+
this.activeTimeouts.forEach((tid) => clearTimeout(tid));
|
|
158
|
+
this.activeTimeouts = [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Fédération Aéronautique Internationale (FAI) - CIAM Sporting Code Section 4: F9U Drone Racing World Championship Regulations',
|
|
6
|
+
url: 'https://www.fai.org/sites/default/files/2026-06/CIAM-F9-Drones-2026-06-01.pdf',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'MultiGP Drone Racing Championship - Official Track Design and Lap Timing Specifications',
|
|
10
|
+
url: 'https://www.multigp.com/class-specifications/',
|
|
11
|
+
}
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const BIBLIOGRAPHY_ITEMS = bibliography;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FpvDroneLapTimerUI } from './ui';
|
|
2
|
+
import { LapTimerSession } from './controller';
|
|
3
|
+
|
|
4
|
+
export function initializeLapTimer(root: HTMLElement, ui: FpvDroneLapTimerUI): () => void {
|
|
5
|
+
const session = new LapTimerSession(root, ui);
|
|
6
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
7
|
+
if (e.code === 'Space') {
|
|
8
|
+
const tag = document.activeElement?.tagName;
|
|
9
|
+
if (tag !== 'INPUT' && tag !== 'BUTTON') {
|
|
10
|
+
e.preventDefault();
|
|
11
|
+
session.recordLap();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
window.addEventListener('keydown', onKeyDown);
|
|
16
|
+
return () => {
|
|
17
|
+
session.destroy();
|
|
18
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { FpvDroneLapTimerUI } from './ui';
|
|
3
|
+
import { DEFAULT_CONFIG, calculateSessionMetrics, computeLapRecords } from './logic';
|
|
4
|
+
import { renderTelemetryScene, renderLapTable } from './dom-views';
|
|
5
|
+
import './fpv-drone-lap-timer.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
ui: FpvDroneLapTimerUI;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { ui } = Astro.props;
|
|
12
|
+
|
|
13
|
+
const initialLaps = computeLapRecords([], DEFAULT_CONFIG.trackLengthM, DEFAULT_CONFIG.batteryCapacityMah);
|
|
14
|
+
const initialMetrics = calculateSessionMetrics([], DEFAULT_CONFIG.trackLengthM, DEFAULT_CONFIG.batteryCapacityMah);
|
|
15
|
+
const initialTelemetryHtml = renderTelemetryScene({
|
|
16
|
+
metrics: initialMetrics,
|
|
17
|
+
laps: initialLaps,
|
|
18
|
+
currentLapNumber: 1,
|
|
19
|
+
targetLaps: DEFAULT_CONFIG.targetLaps,
|
|
20
|
+
status: 'idle',
|
|
21
|
+
ui,
|
|
22
|
+
});
|
|
23
|
+
const initialTableHtml = renderLapTable(initialLaps, ui);
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<div class="fpv-lap-timer-card" data-fpv-lap-timer>
|
|
27
|
+
<script is:inline type="application/json" data-fpv-lap-timer-ui set:html={JSON.stringify(ui)}></script>
|
|
28
|
+
|
|
29
|
+
<aside class="fpv-lap-timer-sidebar" aria-labelledby="fpv-lap-timer-setup-title">
|
|
30
|
+
<div class="fpv-lap-timer-sidebar-header">
|
|
31
|
+
<span class="fpv-lap-timer-kicker">OSD CONFIG</span>
|
|
32
|
+
<h3 id="fpv-lap-timer-setup-title" class="fpv-lap-timer-sidebar-title">{ui.setupHeading}</h3>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="fpv-lap-timer-presets" role="group" aria-label="Session Presets">
|
|
36
|
+
<button type="button" class="fpv-lap-timer-preset active" data-preset="multigp">{ui.presetMultiGpLabel}</button>
|
|
37
|
+
<button type="button" class="fpv-lap-timer-preset" data-preset="whoop">{ui.presetWhoopLabel}</button>
|
|
38
|
+
<button type="button" class="fpv-lap-timer-preset" data-preset="sprint">{ui.presetSprintLabel}</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="fpv-lap-timer-fields">
|
|
42
|
+
<label class="fpv-lap-timer-field">
|
|
43
|
+
<span class="fpv-lap-timer-field-label">{ui.trackLengthLabel} <small>({ui.trackLengthUnit})</small></span>
|
|
44
|
+
<input id="fpv-lap-timer-track-length" type="number" min="10" max="2000" step="5" value={DEFAULT_CONFIG.trackLengthM} />
|
|
45
|
+
</label>
|
|
46
|
+
|
|
47
|
+
<label class="fpv-lap-timer-field">
|
|
48
|
+
<span class="fpv-lap-timer-field-label">{ui.targetLapsLabel} <small>({ui.targetLapsUnit})</small></span>
|
|
49
|
+
<input id="fpv-lap-timer-target-laps" type="number" min="0" max="50" step="1" value={DEFAULT_CONFIG.targetLaps} />
|
|
50
|
+
</label>
|
|
51
|
+
|
|
52
|
+
<label class="fpv-lap-timer-field">
|
|
53
|
+
<span class="fpv-lap-timer-field-label">{ui.batteryCapacityLabel} <small>({ui.batteryCapacityUnit})</small></span>
|
|
54
|
+
<input id="fpv-lap-timer-battery" type="number" min="100" max="10000" step="50" value={DEFAULT_CONFIG.batteryCapacityMah} />
|
|
55
|
+
</label>
|
|
56
|
+
|
|
57
|
+
<label class="fpv-lap-timer-field">
|
|
58
|
+
<span class="fpv-lap-timer-field-label">{ui.debounceThresholdLabel} <small>({ui.debounceThresholdUnit})</small></span>
|
|
59
|
+
<input id="fpv-lap-timer-debounce" type="number" min="1" max="10" step="0.5" value="3.0" />
|
|
60
|
+
</label>
|
|
61
|
+
|
|
62
|
+
<label class="fpv-lap-timer-checkbox">
|
|
63
|
+
<input id="fpv-lap-timer-sound" type="checkbox" checked />
|
|
64
|
+
<span>{ui.soundEnabledLabel}</span>
|
|
65
|
+
</label>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="fpv-lap-timer-specs-card">
|
|
69
|
+
<div class="fpv-lap-timer-spec-row">
|
|
70
|
+
<span>{ui.completedLapsLabel}</span>
|
|
71
|
+
<strong id="fpv-lap-timer-stat-completed" class="font-mono">0</strong>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="fpv-lap-timer-spec-row">
|
|
74
|
+
<span>{ui.averageLapHeading}</span>
|
|
75
|
+
<strong id="fpv-lap-timer-stat-avg" class="font-mono">--</strong>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="fpv-lap-timer-spec-row">
|
|
78
|
+
<span>{ui.estimatedSpeedHeading}</span>
|
|
79
|
+
<strong id="fpv-lap-timer-stat-speed" class="font-mono">--</strong>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</aside>
|
|
83
|
+
|
|
84
|
+
<div class="fpv-lap-timer-stage">
|
|
85
|
+
<section class="fpv-lap-timer-hud" aria-label="Race Stopwatch Display">
|
|
86
|
+
<div class="fpv-lap-timer-hud-bar">
|
|
87
|
+
<div class="fpv-lap-timer-chip fpv-lap-timer-status-chip">
|
|
88
|
+
<span class="fpv-lap-timer-status-dot"></span>
|
|
89
|
+
<span id="fpv-lap-timer-status-text">{ui.statusIdle}</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="fpv-lap-timer-chip fpv-lap-timer-lap-chip">
|
|
92
|
+
<span id="fpv-lap-timer-hud-lap">{ui.lapNumberPrefix} 1 / {DEFAULT_CONFIG.targetLaps}</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="fpv-lap-timer-digits font-mono" aria-live="off">00:00.000</div>
|
|
97
|
+
<div class="fpv-lap-timer-hud-sublabel">{ui.currentLapHeading}</div>
|
|
98
|
+
</section>
|
|
99
|
+
|
|
100
|
+
<section class="fpv-lap-timer-trigger-box" aria-label="Timing Controls">
|
|
101
|
+
<button type="button" class="fpv-lap-timer-giant-btn" disabled aria-keyshortcuts="Space">
|
|
102
|
+
<span class="fpv-lap-timer-btn-caption">{ui.recordLapButton}</span>
|
|
103
|
+
</button>
|
|
104
|
+
<div class="fpv-lap-timer-hint">{ui.spacebarHint}</div>
|
|
105
|
+
|
|
106
|
+
<div class="fpv-lap-timer-controls">
|
|
107
|
+
<button type="button" class="fpv-lap-timer-btn fpv-lap-timer-btn-primary" id="fpv-lap-timer-btn-start">{ui.startCountdownButton}</button>
|
|
108
|
+
<button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-btn-pause" style="display: none;">{ui.pauseTimerButton}</button>
|
|
109
|
+
<button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-btn-reset">{ui.resetTimerButton}</button>
|
|
110
|
+
</div>
|
|
111
|
+
</section>
|
|
112
|
+
|
|
113
|
+
<section class="fpv-lap-timer-telemetry-mount" set:html={initialTelemetryHtml}></section>
|
|
114
|
+
|
|
115
|
+
<section class="fpv-lap-timer-table-mount" set:html={initialTableHtml}></section>
|
|
116
|
+
|
|
117
|
+
<footer class="fpv-lap-timer-footer">
|
|
118
|
+
<button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-export-csv">{ui.exportCsvButton}</button>
|
|
119
|
+
<button type="button" class="fpv-lap-timer-btn" id="fpv-lap-timer-copy-summary">{ui.copySummaryButton}</button>
|
|
120
|
+
<span class="fpv-lap-timer-notice" id="fpv-lap-timer-copy-notice" style="display: none;">{ui.copiedNotice}</span>
|
|
121
|
+
</footer>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<script>
|
|
126
|
+
import { initializeLapTimer } from './controller';
|
|
127
|
+
|
|
128
|
+
const root = document.querySelector<HTMLElement>('[data-fpv-lap-timer]');
|
|
129
|
+
const scriptData = root?.querySelector<HTMLScriptElement>('[data-fpv-lap-timer-ui]');
|
|
130
|
+
if (root && scriptData) {
|
|
131
|
+
try {
|
|
132
|
+
const parsedUi = JSON.parse(scriptData.textContent || '{}');
|
|
133
|
+
initializeLapTimer(root, parsedUi);
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FPV_DRONE_LAP_TIMER_TOOL, fpvDroneLapTimer } from './index';
|
|
3
|
+
|
|
4
|
+
describe('fpv-drone-lap-timer contract suite', () => {
|
|
5
|
+
it('exposes a valid tool definition with lazy component loaders', () => {
|
|
6
|
+
expect(FPV_DRONE_LAP_TIMER_TOOL.entry).toBe(fpvDroneLapTimer);
|
|
7
|
+
expect(typeof FPV_DRONE_LAP_TIMER_TOOL.Component).toBe('function');
|
|
8
|
+
expect(typeof FPV_DRONE_LAP_TIMER_TOOL.SEOComponent).toBe('function');
|
|
9
|
+
expect(typeof FPV_DRONE_LAP_TIMER_TOOL.BibliographyComponent).toBe('function');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('loads English locale content without errors', async () => {
|
|
13
|
+
expect(typeof fpvDroneLapTimer.i18n.en).toBe('function');
|
|
14
|
+
const content = await fpvDroneLapTimer.i18n.en!();
|
|
15
|
+
expect(content.slug).toBe('fpv-drone-lap-timer');
|
|
16
|
+
expect(content.title).toBeDefined();
|
|
17
|
+
expect(content.ui).toBeDefined();
|
|
18
|
+
expect(content.seo.length).toBeGreaterThanOrEqual(4);
|
|
19
|
+
expect(content.faq.length).toBeGreaterThanOrEqual(4);
|
|
20
|
+
expect(content.howTo.length).toBeGreaterThanOrEqual(3);
|
|
21
|
+
expect(content.bibliography.length).toBeGreaterThanOrEqual(2);
|
|
22
|
+
expect(content.schemas.length).toBeGreaterThanOrEqual(3);
|
|
23
|
+
});
|
|
24
|
+
});
|