@jjlmoya/utils-drones 1.23.0 → 1.25.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +4 -0
  4. package/src/tests/diacritics_density.test.ts +8 -8
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/drone-battery-c-rating-calculator/i18n/es.ts +49 -49
  8. package/src/tool/fpv-drone-thrust-to-weight-ratio/bibliography.astro +6 -0
  9. package/src/tool/fpv-drone-thrust-to-weight-ratio/bibliography.ts +18 -0
  10. package/src/tool/fpv-drone-thrust-to-weight-ratio/component.astro +203 -0
  11. package/src/tool/fpv-drone-thrust-to-weight-ratio/controller.ts +240 -0
  12. package/src/tool/fpv-drone-thrust-to-weight-ratio/dom-views.ts +148 -0
  13. package/src/tool/fpv-drone-thrust-to-weight-ratio/entry.ts +27 -0
  14. package/src/tool/fpv-drone-thrust-to-weight-ratio/evaluator.ts +30 -0
  15. package/src/tool/fpv-drone-thrust-to-weight-ratio/fpv-drone-thrust-to-weight-ratio.css +404 -0
  16. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/de.ts +208 -0
  17. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/en.ts +208 -0
  18. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/es.ts +208 -0
  19. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/fr.ts +208 -0
  20. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/id.ts +208 -0
  21. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/it.ts +208 -0
  22. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/ja.ts +208 -0
  23. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/ko.ts +208 -0
  24. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/nl.ts +208 -0
  25. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/pl.ts +208 -0
  26. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/pt.ts +208 -0
  27. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/ru.ts +208 -0
  28. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/sv.ts +208 -0
  29. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/tr.ts +208 -0
  30. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/zh.ts +208 -0
  31. package/src/tool/fpv-drone-thrust-to-weight-ratio/index.ts +10 -0
  32. package/src/tool/fpv-drone-thrust-to-weight-ratio/logic.test.ts +93 -0
  33. package/src/tool/fpv-drone-thrust-to-weight-ratio/logic.ts +147 -0
  34. package/src/tool/fpv-drone-thrust-to-weight-ratio/seo.astro +16 -0
  35. package/src/tool/fpv-drone-thrust-to-weight-ratio/storage.ts +19 -0
  36. package/src/tool/fpv-drone-thrust-to-weight-ratio/ui.ts +58 -0
  37. package/src/tools.ts +3 -0
@@ -0,0 +1,240 @@
1
+ import type { DroneTwrInputs } from './logic';
2
+ import { calculateTwrMetrics } from './logic';
3
+ import { evaluateAgilityPresentation } from './evaluator';
4
+ import { updateDOMMetrics, updateDOMSafety, renderTwrSVG, type DOMResultElements } from './dom-views';
5
+ import { loadSavedInputs, saveInputs } from './storage';
6
+ import type { FpvDroneThrustToWeightRatioUI } from './ui';
7
+
8
+ export interface PresetConfig {
9
+ auwGrams: number;
10
+ motorCount: number;
11
+ thrustPerMotorGrams: number;
12
+ propellerSizeInches: number;
13
+ propellerPitchInches: number;
14
+ bladeCount: number;
15
+ throttleStickPercent: number;
16
+ }
17
+
18
+ export const PRESETS: Record<string, PresetConfig> = {
19
+ whoop1s: { auwGrams: 32, motorCount: 4, thrustPerMotorGrams: 38, propellerSizeInches: 1.6, propellerPitchInches: 1.5, bladeCount: 4, throttleStickPercent: 50 },
20
+ freestyle35: { auwGrams: 250, motorCount: 4, thrustPerMotorGrams: 750, propellerSizeInches: 3.5, propellerPitchInches: 2.8, bladeCount: 3, throttleStickPercent: 65 },
21
+ freestyle5: { auwGrams: 680, motorCount: 4, thrustPerMotorGrams: 1950, propellerSizeInches: 5.1, propellerPitchInches: 4.3, bladeCount: 3, throttleStickPercent: 70 },
22
+ longrange7: { auwGrams: 1150, motorCount: 4, thrustPerMotorGrams: 2400, propellerSizeInches: 7.0, propellerPitchInches: 4.0, bladeCount: 2, throttleStickPercent: 45 },
23
+ cinelifter8: { auwGrams: 4200, motorCount: 8, thrustPerMotorGrams: 3200, propellerSizeInches: 9.0, propellerPitchInches: 5.0, bladeCount: 3, throttleStickPercent: 55 },
24
+ };
25
+
26
+ export class FpvDroneThrustToWeightRatioController {
27
+ private container: HTMLElement;
28
+ private ui: FpvDroneThrustToWeightRatioUI;
29
+ private elements!: DOMResultElements;
30
+ private inputs!: DroneTwrInputs;
31
+ private activePreset: string | null = 'freestyle5';
32
+
33
+ constructor(container: HTMLElement, ui: FpvDroneThrustToWeightRatioUI) {
34
+ this.container = container;
35
+ this.ui = ui;
36
+ this.initElements();
37
+ this.initInputs();
38
+ this.bindEvents();
39
+ this.recalculate();
40
+ }
41
+
42
+ private initElements(): void {
43
+ this.elements = {
44
+ twrRatio: this.container.querySelector('[data-metric="twrRatio"]')!,
45
+ hoverThrottle: this.container.querySelector('[data-metric="hoverThrottle"]')!,
46
+ currentThrust: this.container.querySelector('[data-metric="currentThrust"]')!,
47
+ instantGForce: this.container.querySelector('[data-metric="instantGForce"]')!,
48
+ zeroToHundred: this.container.querySelector('[data-metric="zeroToHundred"]')!,
49
+ recommendedCamAngle: this.container.querySelector('[data-metric="recommendedCamAngle"]')!,
50
+ windResistance: this.container.querySelector('[data-metric="windResistance"]')!,
51
+ totalMaxThrust: this.container.querySelector('[data-metric="totalMaxThrust"]')!,
52
+ maxPitchAngle: this.container.querySelector('[data-metric="maxPitchAngle"]')!,
53
+ tpaSetting: this.container.querySelector('[data-metric="tpaSetting"]') || undefined,
54
+ dynamicIdleSetting: this.container.querySelector('[data-metric="dynamicIdleSetting"]') || undefined,
55
+ propwashRisk: this.container.querySelector('[data-metric="propwashRisk"]') || undefined,
56
+ agilityBadge: this.container.querySelector('[data-metric="agilityBadge"]')!,
57
+ statusTitle: this.container.querySelector('[data-metric="statusTitle"]')!,
58
+ statusDesc: this.container.querySelector('[data-metric="statusDesc"]')!,
59
+ svgContainer: this.container.querySelector('[data-element="svgContainer"]')!,
60
+ stickPercentBadge: this.container.querySelector('[data-metric="stickPercentBadge"]') || undefined,
61
+ };
62
+ }
63
+
64
+ private initInputs(): void {
65
+ const saved = loadSavedInputs();
66
+ this.inputs = Object.assign({}, PRESETS.freestyle5, saved);
67
+ this.syncInputElements();
68
+ }
69
+
70
+ private parseVal(selector: string): number {
71
+ const el = this.container.querySelector(selector) as HTMLInputElement;
72
+ if (!el || !el.value) return 0;
73
+ const cleanStr = el.value.replace(',', '.');
74
+ const val = parseFloat(cleanStr);
75
+ return Number.isFinite(val) ? val : 0;
76
+ }
77
+
78
+ private syncInputElements(): void {
79
+ this.setInputValue('#input-auw', String(this.inputs.auwGrams));
80
+ this.setInputValue('#range-auw', String(this.inputs.auwGrams));
81
+ this.setInputValue('#input-thrust-motor', String(this.inputs.thrustPerMotorGrams));
82
+ this.setInputValue('#range-thrust-motor', String(this.inputs.thrustPerMotorGrams));
83
+ this.setInputValue('#input-prop-size', String(this.inputs.propellerSizeInches));
84
+ this.setInputValue('#range-prop-size', String(this.inputs.propellerSizeInches));
85
+ this.setInputValue('#input-prop-pitch', String(this.inputs.propellerPitchInches));
86
+ this.setInputValue('#range-prop-pitch', String(this.inputs.propellerPitchInches));
87
+ this.setInputValue('#range-throttle-stick', String(this.inputs.throttleStickPercent));
88
+
89
+ this.updateCustomSelect('motor-count', String(this.inputs.motorCount));
90
+ this.updateCustomSelect('blade-count', String(this.inputs.bladeCount));
91
+ this.updatePresetChips();
92
+ }
93
+
94
+ private setInputValue(selector: string, val: string): void {
95
+ const el = this.container.querySelector(selector) as HTMLInputElement;
96
+ if (el) el.value = val;
97
+ }
98
+
99
+ private updatePresetChips(): void {
100
+ this.container.querySelectorAll('[data-preset]').forEach((btn) => {
101
+ const key = btn.getAttribute('data-preset');
102
+ btn.classList.toggle('active', key === this.activePreset);
103
+ });
104
+ }
105
+
106
+ private updateCustomSelect(name: string, value: string): void {
107
+ const wrapper = this.container.querySelector(`[data-custom-select="${name}"]`);
108
+ if (!wrapper) return;
109
+ const triggerText = wrapper.querySelector('.sc-select-trigger span');
110
+ const options = wrapper.querySelectorAll('.sc-select-option');
111
+ options.forEach((opt) => {
112
+ const isSelected = opt.getAttribute('data-value') === value;
113
+ opt.classList.toggle('active', isSelected);
114
+ if (isSelected && triggerText) triggerText.textContent = opt.textContent;
115
+ });
116
+ }
117
+
118
+ private bindEvents(): void {
119
+ this.bindNumberAndRangeInputs();
120
+ this.bindCustomSelects();
121
+ this.bindPresets();
122
+ this.bindThrottleStick();
123
+ this.bindQuickSnapButtons();
124
+ document.addEventListener('click', () => this.closeAllSelects());
125
+ }
126
+
127
+ private bindNumberAndRangeInputs(): void {
128
+ this.bindRangeSync('#input-auw', '#range-auw', (v) => { this.inputs.auwGrams = v; });
129
+ this.bindRangeSync('#input-thrust-motor', '#range-thrust-motor', (v) => { this.inputs.thrustPerMotorGrams = v; });
130
+ this.bindRangeSync('#input-prop-size', '#range-prop-size', (v) => { this.inputs.propellerSizeInches = v; });
131
+ this.bindRangeSync('#input-prop-pitch', '#range-prop-pitch', (v) => { this.inputs.propellerPitchInches = v; });
132
+ }
133
+
134
+ private bindThrottleStick(): void {
135
+ const stick = this.container.querySelector('#range-throttle-stick') as HTMLInputElement;
136
+ stick?.addEventListener('input', () => {
137
+ this.inputs.throttleStickPercent = parseFloat(stick.value) || 0;
138
+ this.recalculate();
139
+ });
140
+ }
141
+
142
+ private bindQuickSnapButtons(): void {
143
+ this.container.querySelectorAll('[data-snap]').forEach((btn) => {
144
+ btn.addEventListener('click', () => {
145
+ const snapType = btn.getAttribute('data-snap');
146
+ const results = calculateTwrMetrics(this.inputs);
147
+ if (snapType === 'idle') this.inputs.throttleStickPercent = 0;
148
+ if (snapType === 'hover') this.inputs.throttleStickPercent = results.hoverThrottlePercent;
149
+ if (snapType === 'cruise') this.inputs.throttleStickPercent = 50;
150
+ if (snapType === 'punch') this.inputs.throttleStickPercent = 100;
151
+ this.setInputValue('#range-throttle-stick', String(this.inputs.throttleStickPercent));
152
+ this.recalculate();
153
+ });
154
+ });
155
+ }
156
+
157
+ private bindCustomSelects(): void {
158
+ this.container.querySelectorAll('.sc-custom-select').forEach((wrapper) => {
159
+ const trigger = wrapper.querySelector('.sc-select-trigger');
160
+ trigger?.addEventListener('click', (e) => {
161
+ e.stopPropagation();
162
+ this.closeAllSelects(wrapper);
163
+ wrapper.classList.toggle('open');
164
+ });
165
+
166
+ wrapper.querySelectorAll('.sc-select-option').forEach((opt) => {
167
+ opt.addEventListener('click', (e) => {
168
+ e.stopPropagation();
169
+ this.handleSelectOptionClick(wrapper, opt);
170
+ });
171
+ });
172
+ });
173
+ }
174
+
175
+ private handleSelectOptionClick(wrapper: Element, opt: Element): void {
176
+ const val = opt.getAttribute('data-value')!;
177
+ const name = wrapper.getAttribute('data-custom-select')!;
178
+ if (name === 'motor-count') this.inputs.motorCount = parseInt(val, 10);
179
+ if (name === 'blade-count') this.inputs.bladeCount = parseInt(val, 10);
180
+ this.updateCustomSelect(name, val);
181
+ wrapper.classList.remove('open');
182
+ this.onInputChanged();
183
+ }
184
+
185
+ private bindPresets(): void {
186
+ this.container.querySelectorAll('[data-preset]').forEach((btn) => {
187
+ btn.addEventListener('click', () => {
188
+ const key = btn.getAttribute('data-preset')!;
189
+ if (PRESETS[key]) {
190
+ this.activePreset = key;
191
+ this.inputs = { ...PRESETS[key] };
192
+ this.syncInputElements();
193
+ this.recalculate();
194
+ }
195
+ });
196
+ });
197
+ }
198
+
199
+ private onInputChanged(): void {
200
+ this.activePreset = null;
201
+ this.updatePresetChips();
202
+ this.recalculate();
203
+ }
204
+
205
+ private bindRangeSync(textSel: string, rangeSel: string, setter: (val: number) => void): void {
206
+ const textEl = this.container.querySelector(textSel) as HTMLInputElement;
207
+ const rangeEl = this.container.querySelector(rangeSel) as HTMLInputElement;
208
+
209
+ textEl?.addEventListener('input', () => {
210
+ const val = this.parseVal(textSel);
211
+ if (rangeEl) rangeEl.value = String(val);
212
+ setter(val);
213
+ this.onInputChanged();
214
+ });
215
+
216
+ rangeEl?.addEventListener('input', () => {
217
+ const val = parseFloat(rangeEl.value);
218
+ if (textEl) textEl.value = String(val);
219
+ setter(val);
220
+ this.onInputChanged();
221
+ });
222
+ }
223
+
224
+ private closeAllSelects(except?: Element): void {
225
+ this.container.querySelectorAll('.sc-custom-select').forEach((w) => {
226
+ if (w !== except) w.classList.remove('open');
227
+ });
228
+ }
229
+
230
+ private recalculate(): void {
231
+ const results = calculateTwrMetrics(this.inputs);
232
+ const agilityEval = evaluateAgilityPresentation(results.agilityTier, this.ui);
233
+ updateDOMMetrics(this.elements, results);
234
+ updateDOMSafety(this.elements, agilityEval);
235
+ if (this.elements.svgContainer) {
236
+ this.elements.svgContainer.innerHTML = renderTwrSVG(this.inputs, results, this.ui);
237
+ }
238
+ saveInputs(this.inputs);
239
+ }
240
+ }
@@ -0,0 +1,148 @@
1
+ import type { DroneTwrInputs, DroneTwrResults, AgilityTier } from './logic';
2
+ import type { AgilityEvaluation } from './evaluator';
3
+ import type { FpvDroneThrustToWeightRatioUI } from './ui';
4
+
5
+ export interface DOMResultElements {
6
+ twrRatio: HTMLElement;
7
+ hoverThrottle: HTMLElement;
8
+ currentThrust: HTMLElement;
9
+ instantGForce: HTMLElement;
10
+ zeroToHundred: HTMLElement;
11
+ recommendedCamAngle: HTMLElement;
12
+ windResistance: HTMLElement;
13
+ totalMaxThrust: HTMLElement;
14
+ maxPitchAngle: HTMLElement;
15
+ tpaSetting?: HTMLElement;
16
+ dynamicIdleSetting?: HTMLElement;
17
+ propwashRisk?: HTMLElement;
18
+ agilityBadge: HTMLElement;
19
+ statusTitle: HTMLElement;
20
+ statusDesc: HTMLElement;
21
+ svgContainer: HTMLElement;
22
+ stickPercentBadge?: HTMLElement;
23
+ }
24
+
25
+ export function getTierColor(tier: AgilityTier): string {
26
+ if (tier === 'underpowered') return '#ef4444';
27
+ if (tier === 'cinematic') return '#0284c7';
28
+ if (tier === 'freestyle') return '#10b981';
29
+ if (tier === 'acro_pro') return '#8b5cf6';
30
+ return '#f59e0b';
31
+ }
32
+
33
+ function renderDroneVectors(stickPercent: number, tierColor: string, camAngle: number): string {
34
+ const vHeight = Math.max(10, (stickPercent / 100) * 60);
35
+ const opacity = Math.min(1, Math.max(0.25, stickPercent / 100));
36
+ const tiltAngle = (camAngle - 20) * 0.4;
37
+ return `<g transform="translate(130, 115) rotate(${tiltAngle})">
38
+ <polygon points="0,-18 55,${-18 - camAngle * 0.6} 55,${-18 + camAngle * 0.6}" fill="var(--n-primary, #38bdf8)" fill-opacity="0.08" stroke="var(--n-primary, #38bdf8)" stroke-dasharray="2,2" stroke-width="1"/>
39
+ <line x1="-80" y1="0" x2="80" y2="0" stroke="var(--n-svg-stroke, #334155)" stroke-width="4.5" stroke-linecap="round"/>
40
+ <line x1="-35" y1="0" x2="0" y2="-18" stroke="var(--n-primary, #38bdf8)" stroke-width="3"/>
41
+ <circle cx="0" cy="-18" r="8" fill="var(--n-primary, #38bdf8)" fill-opacity="0.35" stroke="var(--n-primary, #38bdf8)" stroke-width="2"/>
42
+ <rect x="-24" y="-8" width="48" height="16" rx="4" fill="var(--n-svg-card, #0f172a)" stroke="var(--n-svg-stroke, #334155)" stroke-width="2"/>
43
+ <circle cx="0" cy="0" r="4" fill="${tierColor}"/>
44
+
45
+ <g transform="translate(-80, 0)">
46
+ <ellipse cx="0" cy="-3" rx="20" ry="4" fill="${tierColor}" fill-opacity="0.2" stroke="${tierColor}" stroke-width="1"/>
47
+ <rect x="-10" y="-4" width="20" height="8" rx="2" fill="var(--n-svg-inner, #1e293b)" stroke="${tierColor}" stroke-width="1.5"/>
48
+ <line x1="0" y1="-4" x2="0" y2="${-4 - vHeight}" stroke="${tierColor}" stroke-width="3.5" stroke-linecap="round" opacity="${opacity}"/>
49
+ <polygon points="-5,${-4 - vHeight + 5} 5,${-4 - vHeight + 5} 0,${-4 - vHeight}" fill="${tierColor}" opacity="${opacity}"/>
50
+ </g>
51
+
52
+ <g transform="translate(80, 0)">
53
+ <ellipse cx="0" cy="-3" rx="20" ry="4" fill="${tierColor}" fill-opacity="0.2" stroke="${tierColor}" stroke-width="1"/>
54
+ <rect x="-10" y="-4" width="20" height="8" rx="2" fill="var(--n-svg-inner, #1e293b)" stroke="${tierColor}" stroke-width="1.5"/>
55
+ <line x1="0" y1="-4" x2="0" y2="${-4 - vHeight}" stroke="${tierColor}" stroke-width="3.5" stroke-linecap="round" opacity="${opacity}"/>
56
+ <polygon points="-5,${-4 - vHeight + 5} 5,${-4 - vHeight + 5} 0,${-4 - vHeight}" fill="${tierColor}" opacity="${opacity}"/>
57
+ </g>
58
+ </g>`;
59
+ }
60
+
61
+ function renderDroneStage(inputs: DroneTwrInputs, results: DroneTwrResults, ui: FpvDroneThrustToWeightRatioUI): string {
62
+ const tierColor = getTierColor(results.agilityTier);
63
+ const vectorsHtml = renderDroneVectors(inputs.throttleStickPercent, tierColor, results.recommendedCamAngleDeg);
64
+ return `<g transform="translate(15, 15)">
65
+ <rect x="0" y="0" width="250" height="180" rx="14" fill="var(--n-svg-card, #0f172a)" stroke="var(--n-svg-stroke, #334155)" stroke-width="2" />
66
+ <text x="15" y="24" fill="var(--n-text-muted, #94a3b8)" font-size="10" font-weight="700" letter-spacing="0.5">${ui.hudVectorPowerLabel.toUpperCase()}</text>
67
+ <text x="235" y="24" fill="${tierColor}" font-size="11" font-weight="800" text-anchor="end">${results.instantGForce.toFixed(2)}G</text>
68
+ ${vectorsHtml}
69
+ <text x="125" y="152" fill="var(--n-text, #f8fafc)" font-size="15" font-weight="900" text-anchor="middle">${results.currentThrustGrams.toLocaleString()} g</text>
70
+ <text x="125" y="168" fill="var(--n-text-muted, #94a3b8)" font-size="9" font-weight="600" text-anchor="middle">STICK @ ${inputs.throttleStickPercent}% | CAM ${results.recommendedCamAngleDeg}°</text>
71
+ </g>`;
72
+ }
73
+
74
+ function renderCurvePoints(results: DroneTwrResults, inputs: DroneTwrInputs): string {
75
+ const hoverX = 25 + (results.hoverThrottlePercent / 100) * 190;
76
+ const hoverY = 145 - Math.pow(results.hoverThrottlePercent / 100, 1.8) * 105;
77
+ const stickX = 25 + (inputs.throttleStickPercent / 100) * 190;
78
+ const stickY = 145 - Math.pow(inputs.throttleStickPercent / 100, 1.8) * 105;
79
+ return `<g>
80
+ <rect x="${25 + 0.25 * 190}" y="40" width="${0.35 * 190}" height="105" fill="var(--n-primary, #38bdf8)" fill-opacity="0.06"/>
81
+ <rect x="${25 + 0.70 * 190}" y="40" width="${0.30 * 190}" height="105" fill="#f59e0b" fill-opacity="0.08"/>
82
+ <line x1="${hoverX}" y1="40" x2="${hoverX}" y2="145" stroke="#38bdf8" stroke-width="1.5" stroke-dasharray="3,3" opacity="0.7"/>
83
+ <circle cx="${hoverX}" cy="${hoverY}" r="4.5" fill="#38bdf8" stroke="#fff" stroke-width="1.5"/>
84
+ <circle cx="${stickX}" cy="${stickY}" r="6" fill="#f59e0b" stroke="#fff" stroke-width="2"/>
85
+ <circle cx="${stickX}" cy="${stickY}" r="11" fill="none" stroke="#f59e0b" stroke-width="1.5" opacity="0.4"/>
86
+ </g>`;
87
+ }
88
+
89
+ function renderCurveStage(inputs: DroneTwrInputs, results: DroneTwrResults, ui: FpvDroneThrustToWeightRatioUI): string {
90
+ const pointsHtml = renderCurvePoints(results, inputs);
91
+ return `<g transform="translate(280, 15)">
92
+ <rect x="0" y="0" width="265" height="180" rx="14" fill="var(--n-svg-card, #0f172a)" stroke="var(--n-svg-stroke, #334155)" stroke-width="2" />
93
+ <text x="15" y="24" fill="var(--n-text-muted, #94a3b8)" font-size="10" font-weight="700" letter-spacing="0.5">${ui.hudThrustCurveTitle.toUpperCase()}</text>
94
+ <path d="M 25 145 L 215 145" stroke="var(--n-svg-stroke, #334155)" stroke-width="1.5" />
95
+ <path d="M 25 40 L 25 145" stroke="var(--n-svg-stroke, #334155)" stroke-width="1.5" />
96
+ <path d="M 25 145 Q 120 135 215 40" fill="none" stroke="var(--n-primary, #38bdf8)" stroke-width="3.5" stroke-linecap="round" />
97
+ ${pointsHtml}
98
+ <text x="25" y="165" fill="var(--n-text-muted, #94a3b8)" font-size="8" font-weight="700">0% IDLE</text>
99
+ <text x="120" y="165" fill="#38bdf8" font-size="8" font-weight="700" text-anchor="middle">HOVER ${results.hoverThrottlePercent}%</text>
100
+ <text x="215" y="165" fill="#f59e0b" font-size="8" font-weight="700" text-anchor="end">PUNCH ${results.twrRatio.toFixed(1)}:1</text>
101
+ </g>`;
102
+ }
103
+
104
+ export function renderTwrSVG(inputs: DroneTwrInputs, results: DroneTwrResults, ui: FpvDroneThrustToWeightRatioUI): string {
105
+ const droneSvg = renderDroneStage(inputs, results, ui);
106
+ const curveSvg = renderCurveStage(inputs, results, ui);
107
+ return `<svg viewBox="0 0 560 210" width="100%" height="210" xmlns="http://www.w3.org/2000/svg">
108
+ ${droneSvg}
109
+ ${curveSvg}
110
+ </svg>`;
111
+ }
112
+
113
+ function updateCoreTelemetry(elements: DOMResultElements, results: DroneTwrResults): void {
114
+ if (elements.twrRatio) elements.twrRatio.textContent = `${results.twrRatio.toFixed(2)} : 1`;
115
+ if (elements.hoverThrottle) elements.hoverThrottle.textContent = `${results.hoverThrottlePercent.toFixed(1)} %`;
116
+ if (elements.currentThrust) elements.currentThrust.textContent = `${results.currentThrustGrams.toLocaleString()} g`;
117
+ if (elements.instantGForce) elements.instantGForce.textContent = `${results.instantGForce.toFixed(2)} G`;
118
+ }
119
+
120
+ function updateFlightDynamics(elements: DOMResultElements, results: DroneTwrResults): void {
121
+ if (elements.zeroToHundred) elements.zeroToHundred.textContent = `${results.zeroToHundredTimeSec.toFixed(2)} s`;
122
+ if (elements.recommendedCamAngle) elements.recommendedCamAngle.textContent = `${results.recommendedCamAngleDeg}°`;
123
+ if (elements.windResistance) elements.windResistance.textContent = `${results.windResistanceKmh} km/h`;
124
+ if (elements.totalMaxThrust) elements.totalMaxThrust.textContent = `${results.totalMaxThrustGrams.toLocaleString()} g`;
125
+ if (elements.maxPitchAngle) elements.maxPitchAngle.textContent = `${results.maxPitchAngleDeg}°`;
126
+ if (elements.stickPercentBadge) elements.stickPercentBadge.textContent = `${results.currentThrottlePercent}%`;
127
+ }
128
+
129
+ function updateTuningMetrics(elements: DOMResultElements, results: DroneTwrResults): void {
130
+ if (elements.tpaSetting) elements.tpaSetting.textContent = results.tuneAdvice.tpaSetting;
131
+ if (elements.dynamicIdleSetting) elements.dynamicIdleSetting.textContent = results.tuneAdvice.dynamicIdleSetting;
132
+ if (elements.propwashRisk) elements.propwashRisk.textContent = results.tuneAdvice.propwashRisk;
133
+ }
134
+
135
+ export function updateDOMMetrics(elements: DOMResultElements, results: DroneTwrResults): void {
136
+ updateCoreTelemetry(elements, results);
137
+ updateFlightDynamics(elements, results);
138
+ updateTuningMetrics(elements, results);
139
+ }
140
+
141
+ export function updateDOMSafety(elements: DOMResultElements, evalData: AgilityEvaluation): void {
142
+ if (elements.agilityBadge) {
143
+ elements.agilityBadge.className = `sc-safety-badge ${evalData.badgeClass}`;
144
+ elements.agilityBadge.textContent = evalData.title;
145
+ }
146
+ if (elements.statusTitle) elements.statusTitle.textContent = evalData.title;
147
+ if (elements.statusDesc) elements.statusDesc.textContent = evalData.description;
148
+ }
@@ -0,0 +1,27 @@
1
+ import type { DronesToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { FpvDroneThrustToWeightRatioUI } from './ui';
3
+
4
+ export type { FpvDroneThrustToWeightRatioUI };
5
+ export type FpvDroneThrustToWeightRatioLocaleContent = ToolLocaleContent<FpvDroneThrustToWeightRatioUI>;
6
+
7
+ export const fpvDroneThrustToWeightRatio: DronesToolEntry<FpvDroneThrustToWeightRatioUI> = {
8
+ id: 'fpv-drone-thrust-to-weight-ratio',
9
+ icons: { bg: 'mdi:quadcopter', fg: 'mdi:gauge' },
10
+ i18n: {
11
+ es: () => import('./i18n/es').then((m) => m.content),
12
+ en: () => import('./i18n/en').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,30 @@
1
+ import type { AgilityTier } from './logic';
2
+ import type { FpvDroneThrustToWeightRatioUI } from './ui';
3
+
4
+ export interface AgilityEvaluation {
5
+ tier: AgilityTier;
6
+ badgeClass: string;
7
+ title: string;
8
+ description: string;
9
+ }
10
+
11
+ const TIER_CONFIG: Record<AgilityTier, { badgeClass: string; titleKey: keyof FpvDroneThrustToWeightRatioUI; descKey: keyof FpvDroneThrustToWeightRatioUI }> = {
12
+ underpowered: { badgeClass: 'status-danger', titleKey: 'tierUnderpoweredTitle', descKey: 'tierUnderpoweredDesc' },
13
+ cinematic: { badgeClass: 'status-info', titleKey: 'tierCinematicTitle', descKey: 'tierCinematicDesc' },
14
+ freestyle: { badgeClass: 'status-optimal', titleKey: 'tierFreestyleTitle', descKey: 'tierFreestyleDesc' },
15
+ acro_pro: { badgeClass: 'status-accent', titleKey: 'tierAcroProTitle', descKey: 'tierAcroProDesc' },
16
+ racing_extreme: { badgeClass: 'status-warning', titleKey: 'tierRacingExtremeTitle', descKey: 'tierRacingExtremeDesc' },
17
+ };
18
+
19
+ export function evaluateAgilityPresentation(
20
+ tier: AgilityTier,
21
+ ui: FpvDroneThrustToWeightRatioUI
22
+ ): AgilityEvaluation {
23
+ const cfg = TIER_CONFIG[tier] || TIER_CONFIG.freestyle;
24
+ return {
25
+ tier,
26
+ badgeClass: cfg.badgeClass,
27
+ title: ui[cfg.titleKey],
28
+ description: ui[cfg.descKey],
29
+ };
30
+ }