@jjlmoya/utils-drones 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/src/category/index.ts +14 -6
  3. package/src/category/seo.astro +1 -1
  4. package/src/entries.ts +13 -1
  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/bibliography.astro +17 -0
  8. package/src/tool/drone-battery-c-rating-calculator/bibliography.ts +16 -0
  9. package/src/tool/drone-battery-c-rating-calculator/component.astro +192 -0
  10. package/src/tool/drone-battery-c-rating-calculator/controller.ts +225 -0
  11. package/src/tool/drone-battery-c-rating-calculator/dom-views.ts +119 -0
  12. package/src/tool/drone-battery-c-rating-calculator/drone-battery-c-rating-calculator.css +300 -0
  13. package/src/tool/drone-battery-c-rating-calculator/entry.ts +27 -0
  14. package/src/tool/drone-battery-c-rating-calculator/evaluator.ts +37 -0
  15. package/src/tool/drone-battery-c-rating-calculator/i18n/de.ts +192 -0
  16. package/src/tool/drone-battery-c-rating-calculator/i18n/en.ts +192 -0
  17. package/src/tool/drone-battery-c-rating-calculator/i18n/es.ts +192 -0
  18. package/src/tool/drone-battery-c-rating-calculator/i18n/fr.ts +192 -0
  19. package/src/tool/drone-battery-c-rating-calculator/i18n/id.ts +192 -0
  20. package/src/tool/drone-battery-c-rating-calculator/i18n/it.ts +192 -0
  21. package/src/tool/drone-battery-c-rating-calculator/i18n/ja.ts +192 -0
  22. package/src/tool/drone-battery-c-rating-calculator/i18n/ko.ts +192 -0
  23. package/src/tool/drone-battery-c-rating-calculator/i18n/nl.ts +192 -0
  24. package/src/tool/drone-battery-c-rating-calculator/i18n/pl.ts +192 -0
  25. package/src/tool/drone-battery-c-rating-calculator/i18n/pt.ts +192 -0
  26. package/src/tool/drone-battery-c-rating-calculator/i18n/ru.ts +192 -0
  27. package/src/tool/drone-battery-c-rating-calculator/i18n/sv.ts +192 -0
  28. package/src/tool/drone-battery-c-rating-calculator/i18n/tr.ts +192 -0
  29. package/src/tool/drone-battery-c-rating-calculator/i18n/zh.ts +192 -0
  30. package/src/tool/drone-battery-c-rating-calculator/index.ts +10 -0
  31. package/src/tool/drone-battery-c-rating-calculator/logic.test.ts +84 -0
  32. package/src/tool/drone-battery-c-rating-calculator/logic.ts +112 -0
  33. package/src/tool/drone-battery-c-rating-calculator/seo.astro +16 -0
  34. package/src/tool/drone-battery-c-rating-calculator/storage.ts +19 -0
  35. package/src/tool/drone-battery-c-rating-calculator/ui.ts +43 -0
  36. package/src/tools.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-drones",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,7 +28,7 @@
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
30
  "test": "vitest run",
31
- "preversion": "npm run lint && npm run test",
31
+ "preversion": "npm run lint && npm run test && npm run build",
32
32
  "postversion": "git push && git push --tags",
33
33
  "patch": "npm version patch",
34
34
  "minor": "npm version minor",
@@ -1,12 +1,21 @@
1
1
  import type { DronesCategoryEntry } from '../types';
2
- import { droneFlightTime } from '../tool/drone-flight-time/index';
3
- import { antennaLengthCalculator } from '../tool/antenna-length-calculator/index';
4
- import { gpsCoordinatesConverter } from '../tool/gps-coordinates-converter/index';
5
- import { dronePowerAnalyzer } from '../tool/drone-power-analyzer/index';
2
+ import { droneFlightTime } from '../tool/drone-flight-time/entry';
3
+ import { antennaLengthCalculator } from '../tool/antenna-length-calculator/entry';
4
+ import { gpsCoordinatesConverter } from '../tool/gps-coordinates-converter/entry';
5
+ import { dronePowerAnalyzer } from '../tool/drone-power-analyzer/entry';
6
+ import { gsdFlightPlanner } from '../tool/gsd-flight-planner/entry';
7
+ import { droneBatteryCRatingCalculator } from '../tool/drone-battery-c-rating-calculator/entry';
6
8
 
7
9
  export const dronesCategory: DronesCategoryEntry = {
8
10
  icon: 'mdi:drone',
9
- tools: [droneFlightTime, antennaLengthCalculator, gpsCoordinatesConverter, dronePowerAnalyzer],
11
+ tools: [
12
+ droneFlightTime,
13
+ antennaLengthCalculator,
14
+ gpsCoordinatesConverter,
15
+ dronePowerAnalyzer,
16
+ gsdFlightPlanner,
17
+ droneBatteryCRatingCalculator,
18
+ ],
10
19
  i18n: {
11
20
  es: () => import('./i18n/es').then((m) => m.content),
12
21
  en: () => import('./i18n/en').then((m) => m.content),
@@ -25,4 +34,3 @@ export const dronesCategory: DronesCategoryEntry = {
25
34
  id: () => import('./i18n/id').then((m) => m.content),
26
35
  },
27
36
  };
28
-
@@ -76,7 +76,7 @@ if (!content) return null;
76
76
  );
77
77
  }
78
78
  if (section.type === 'proscons') {
79
- return <SEOProsCons title={section.title} items={section.items} proTitle={section.proTitle} conTitle={section.conTitle} />;
79
+ return <SEOProsCons title={section.title} items={section.items} />;
80
80
  }
81
81
  if (section.type === 'summary') {
82
82
  return <SEOSummary title={section.title} items={section.items} />;
package/src/entries.ts CHANGED
@@ -8,10 +8,22 @@ export { dronePowerAnalyzer } from './tool/drone-power-analyzer/entry';
8
8
  export type { DronePowerAnalyzerUI, DronePowerAnalyzerLocaleContent } from './tool/drone-power-analyzer/entry';
9
9
  export { gsdFlightPlanner } from './tool/gsd-flight-planner/entry';
10
10
  export type { GsdFlightPlannerUI, GsdFlightPlannerLocaleContent } from './tool/gsd-flight-planner/entry';
11
+ export { droneBatteryCRatingCalculator } from './tool/drone-battery-c-rating-calculator/entry';
12
+ export type { DroneBatteryCRatingCalculatorUI, DroneBatteryCRatingCalculatorLocaleContent } from './tool/drone-battery-c-rating-calculator/entry';
11
13
  export { dronesCategory } from './category';
14
+
12
15
  import { antennaLengthCalculator } from './tool/antenna-length-calculator/entry';
13
16
  import { droneFlightTime } from './tool/drone-flight-time/entry';
14
17
  import { gpsCoordinatesConverter } from './tool/gps-coordinates-converter/entry';
15
18
  import { dronePowerAnalyzer } from './tool/drone-power-analyzer/entry';
16
19
  import { gsdFlightPlanner } from './tool/gsd-flight-planner/entry';
17
- export const ALL_ENTRIES = [antennaLengthCalculator, droneFlightTime, gpsCoordinatesConverter, dronePowerAnalyzer, gsdFlightPlanner];
20
+ import { droneBatteryCRatingCalculator } from './tool/drone-battery-c-rating-calculator/entry';
21
+
22
+ export const ALL_ENTRIES = [
23
+ antennaLengthCalculator,
24
+ droneFlightTime,
25
+ gpsCoordinatesConverter,
26
+ dronePowerAnalyzer,
27
+ gsdFlightPlanner,
28
+ droneBatteryCRatingCalculator,
29
+ ];
@@ -17,8 +17,8 @@ describe('Locale Completeness Validation', () => {
17
17
  });
18
18
  });
19
19
 
20
- it('all 5 tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(5);
20
+ it('all 6 tools registered', () => {
21
+ expect(ALL_TOOLS.length).toBe(6);
22
22
  });
23
23
  });
24
24
 
@@ -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 5 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(5);
7
+ it('should have 6 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(6);
9
9
  });
10
10
 
11
11
  it('dronesCategory should be defined', () => {
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { BIBLIOGRAPHY_ITEMS } from './bibliography';
3
+ ---
4
+
5
+ <div class="sc-bibliography">
6
+ <ul>
7
+ {
8
+ BIBLIOGRAPHY_ITEMS.map((item) => (
9
+ <li>
10
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
11
+ {item.name}
12
+ </a>
13
+ </li>
14
+ ))
15
+ }
16
+ </ul>
17
+ </div>
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const BIBLIOGRAPHY_ITEMS: BibliographyEntry[] = [
4
+ {
5
+ name: 'Battery University - BU-409: Charging Lithium-ion',
6
+ url: 'http://www.batteryuniversity.com/article/bu-409-charging-lithium-ion/',
7
+ },
8
+ {
9
+ name: 'IEEE Xplore - Internal Resistance and C-Rating Analysis of High Discharge LiPo Cells',
10
+ url: 'https://ieeexplore.ieee.org/iel7/6287639/10380310/10445151.pdf',
11
+ },
12
+ {
13
+ name: 'Betaflight Firmware Documentation - Battery & Power Management',
14
+ url: 'https://betaflight.com/docs/wiki/guides/current/Battery',
15
+ },
16
+ ];
@@ -0,0 +1,192 @@
1
+ ---
2
+ import type { DroneBatteryCRatingCalculatorUI } from './ui';
3
+ import { calculateBatteryMetrics, type BatteryInputs } from './logic';
4
+ import { renderBatterySVG, formatTimeSeconds } from './dom-views';
5
+ import './drone-battery-c-rating-calculator.css';
6
+
7
+ interface Props {
8
+ ui: DroneBatteryCRatingCalculatorUI;
9
+ }
10
+
11
+ const { ui } = Astro.props;
12
+
13
+ const defaultInputs: BatteryInputs = {
14
+ capacitymAh: 1300,
15
+ claimedCRating: 100,
16
+ cellCount: 6,
17
+ chemistry: 'lipo',
18
+ internalResistanceMOm: 4,
19
+ motorCount: 4,
20
+ peakMotorCurrentA: 35,
21
+ auxCurrentA: 1.5,
22
+ };
23
+
24
+ const initialResults = calculateBatteryMetrics(defaultInputs);
25
+ const initialSvg = renderBatterySVG(defaultInputs, initialResults);
26
+ ---
27
+
28
+ <div class="sc-main-card" id="drone-lipo-c-rating-calculator-root">
29
+ <div class="sc-sidebar">
30
+ <div>
31
+ <div class="sc-section-title">{ui.presetSelectLabel}</div>
32
+ <div class="sc-preset-group">
33
+ <button type="button" class="sc-preset-chip active" data-preset="freestyle5">{ui.freestyle5Preset}</button>
34
+ <button type="button" class="sc-preset-chip" data-preset="racing5">{ui.racing5Preset}</button>
35
+ <button type="button" class="sc-preset-chip" data-preset="cinewhoop">{ui.cinewhoopPreset}</button>
36
+ <button type="button" class="sc-preset-chip" data-preset="whoop">{ui.whoopPreset}</button>
37
+ <button type="button" class="sc-preset-chip" data-preset="longrange7">{ui.longRange7Preset}</button>
38
+ </div>
39
+ </div>
40
+
41
+ <div>
42
+ <div class="sc-section-title">{ui.lipoSpecsHeader}</div>
43
+ <div class="sc-field-group">
44
+ <div class="sc-field-header">
45
+ <label for="input-capacity">{ui.capacityLabel}</label>
46
+ </div>
47
+ <input type="number" id="input-capacity" class="sc-input" value="1300" min="50" max="25000" step="50" />
48
+ <input type="range" id="range-capacity" class="sc-slider" value="1300" min="300" max="5000" step="50" aria-label={ui.capacityLabel} />
49
+ </div>
50
+
51
+ <div class="sc-field-group" style="margin-top: 0.8rem;">
52
+ <div class="sc-field-header">
53
+ <label for="input-claimed-c">{ui.claimedCRatingLabel}</label>
54
+ </div>
55
+ <input type="number" id="input-claimed-c" class="sc-input" value="100" min="5" max="200" step="5" />
56
+ <input type="range" id="range-claimed-c" class="sc-slider" value="100" min="20" max="180" step="5" aria-label={ui.claimedCRatingLabel} />
57
+ </div>
58
+
59
+ <div class="sc-field-group" style="margin-top: 0.8rem;">
60
+ <label>{ui.cellCountLabel}</label>
61
+ <div class="sc-custom-select" data-custom-select="cell-count">
62
+ <div class="sc-select-trigger">
63
+ <span>6S (22.2V)</span>
64
+ <svg width="12" height="12" viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
65
+ </div>
66
+ <div class="sc-select-dropdown">
67
+ <div class="sc-select-option" data-value="1">1S (3.7V)</div>
68
+ <div class="sc-select-option" data-value="2">2S (7.4V)</div>
69
+ <div class="sc-select-option" data-value="3">3S (11.1V)</div>
70
+ <div class="sc-select-option" data-value="4">4S (14.8V)</div>
71
+ <div class="sc-select-option active" data-value="6">6S (22.2V)</div>
72
+ <div class="sc-select-option" data-value="8">8S (29.6V)</div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="sc-field-group" style="margin-top: 0.8rem;">
78
+ <label>{ui.chemistryLabel}</label>
79
+ <div class="sc-custom-select" data-custom-select="chemistry">
80
+ <div class="sc-select-trigger">
81
+ <span>LiPo (3.7V / 4.2V)</span>
82
+ <svg width="12" height="12" viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
83
+ </div>
84
+ <div class="sc-select-dropdown">
85
+ <div class="sc-select-option active" data-value="lipo">LiPo (3.7V / 4.2V)</div>
86
+ <div class="sc-select-option" data-value="lihv">LiHV (3.8V / 4.35V)</div>
87
+ <div class="sc-select-option" data-value="liion">Li-Ion (3.6V / 4.2V)</div>
88
+ <div class="sc-select-option" data-value="life">LiFePO4 (3.3V / 3.65V)</div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="sc-field-group" style="margin-top: 0.8rem;">
94
+ <div class="sc-field-header">
95
+ <label for="input-ir">{ui.internalResistanceLabel}</label>
96
+ </div>
97
+ <input type="number" id="input-ir" class="sc-input" value="4" min="0.5" max="50" step="0.5" />
98
+ <input type="range" id="range-ir" class="sc-slider" value="4" min="1" max="25" step="0.5" aria-label={ui.internalResistanceLabel} />
99
+ </div>
100
+ </div>
101
+
102
+ <div>
103
+ <div class="sc-section-title">{ui.quadSpecsHeader}</div>
104
+ <div class="sc-field-group">
105
+ <label for="input-motor-count">{ui.motorCountLabel}</label>
106
+ <input type="number" id="input-motor-count" class="sc-input" value="4" min="1" max="8" step="1" />
107
+ </div>
108
+
109
+ <div class="sc-field-group" style="margin-top: 0.8rem;">
110
+ <div class="sc-field-header">
111
+ <label for="input-peak-current">{ui.peakMotorCurrentLabel}</label>
112
+ </div>
113
+ <input type="number" id="input-peak-current" class="sc-input" value="35" min="1" max="100" step="1" />
114
+ <input type="range" id="range-peak-current" class="sc-slider" value="35" min="5" max="75" step="1" aria-label={ui.peakMotorCurrentLabel} />
115
+ </div>
116
+
117
+ <div class="sc-field-group" style="margin-top: 0.8rem;">
118
+ <label for="input-aux-current">{ui.auxCurrentLabel}</label>
119
+ <input type="number" id="input-aux-current" class="sc-input" value="1.5" min="0" max="10" step="0.1" />
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <div class="sc-display-panel">
125
+ <div class="sc-header-bar">
126
+ <div class="sc-section-title" style="margin-bottom: 0;">{ui.resultsHeader}</div>
127
+ <div class="sc-safety-badge status-optimal" data-metric="safetyBadge">{ui.statusOptimalTitle}</div>
128
+ </div>
129
+
130
+ <div class="sc-svg-wrapper" data-element="svgContainer" set:html={initialSvg}></div>
131
+
132
+ <div class="sc-results-grid">
133
+ <div class="sc-metric-card">
134
+ <div class="sc-metric-label">{ui.realisticMaxCurrentLabel}</div>
135
+ <div class="sc-metric-value" data-metric="realisticMaxCurrent">{initialResults.realisticMaxCurrentA.toFixed(1)} A</div>
136
+ </div>
137
+ <div class="sc-metric-card">
138
+ <div class="sc-metric-label">{ui.realisticCRatingLabel}</div>
139
+ <div class="sc-metric-value" data-metric="realisticCRating">{initialResults.realisticCRating} C</div>
140
+ </div>
141
+ <div class="sc-metric-card">
142
+ <div class="sc-metric-label">{ui.totalPeakDrawLabel}</div>
143
+ <div class="sc-metric-value" data-metric="totalPeakDraw">{initialResults.totalPeakDrawA.toFixed(1)} A</div>
144
+ </div>
145
+ <div class="sc-metric-card">
146
+ <div class="sc-metric-label">{ui.voltageSagLabel}</div>
147
+ <div class="sc-metric-value" data-metric="voltageSag">-{initialResults.voltageSagV.toFixed(2)} V</div>
148
+ </div>
149
+ <div class="sc-metric-card">
150
+ <div class="sc-metric-label">{ui.sagNominalVoltageLabel}</div>
151
+ <div class="sc-metric-value" data-metric="sagNominalVoltage">{initialResults.sagNominalVoltageV.toFixed(1)} V</div>
152
+ </div>
153
+ <div class="sc-metric-card">
154
+ <div class="sc-metric-label">{ui.flightTimeFullThrottleLabel}</div>
155
+ <div class="sc-metric-value" data-metric="flightTimeFullThrottle">{formatTimeSeconds(initialResults.flightTimeFullThrottleSec)}</div>
156
+ </div>
157
+ <div class="sc-metric-card">
158
+ <div class="sc-metric-label">{ui.flightTimeHoverLabel}</div>
159
+ <div class="sc-metric-value" data-metric="flightTimeHover">{formatTimeSeconds(initialResults.flightTimeHoverSec)}</div>
160
+ </div>
161
+ <div class="sc-metric-card">
162
+ <div class="sc-metric-label">{ui.claimedMaxCurrentLabel}</div>
163
+ <div class="sc-metric-value" data-metric="claimedMaxCurrent">{initialResults.claimedMaxCurrentA.toFixed(1)} A</div>
164
+ </div>
165
+ </div>
166
+
167
+ <div class="sc-status-box">
168
+ <h4 data-metric="statusTitle">{ui.statusOptimalTitle}</h4>
169
+ <p data-metric="statusDesc">{ui.statusOptimalDesc}</p>
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <script is:inline type="application/json" id="lipo-c-rating-ui-data" set:html={JSON.stringify(ui)}></script>
175
+
176
+ <script>
177
+ import { DroneBatteryCRatingCalculatorController } from './controller';
178
+
179
+ function initController() {
180
+ const root = document.getElementById('drone-lipo-c-rating-calculator-root');
181
+ if (!root) return;
182
+ const uiEl = document.getElementById('lipo-c-rating-ui-data');
183
+ const uiData = uiEl ? JSON.parse(uiEl.textContent || '{}') : {};
184
+ new DroneBatteryCRatingCalculatorController(root, uiData);
185
+ }
186
+
187
+ if (document.readyState === 'loading') {
188
+ document.addEventListener('DOMContentLoaded', initController);
189
+ } else {
190
+ initController();
191
+ }
192
+ </script>
@@ -0,0 +1,225 @@
1
+ import type { BatteryInputs, BatteryChemistry } from './logic';
2
+ import { calculateBatteryMetrics } from './logic';
3
+ import { evaluateSafetyPresentation } from './evaluator';
4
+ import { updateDOMMetrics, updateDOMSafety, renderBatterySVG, type DOMResultElements } from './dom-views';
5
+ import { loadSavedInputs, saveInputs } from './storage';
6
+ import type { DroneBatteryCRatingCalculatorUI } from './ui';
7
+
8
+ export interface PresetConfig {
9
+ capacitymAh: number;
10
+ claimedCRating: number;
11
+ cellCount: number;
12
+ chemistry: BatteryChemistry;
13
+ internalResistanceMOm: number;
14
+ motorCount: number;
15
+ peakMotorCurrentA: number;
16
+ auxCurrentA: number;
17
+ }
18
+
19
+ export const PRESETS: Record<string, PresetConfig> = {
20
+ whoop: { capacitymAh: 300, claimedCRating: 80, cellCount: 1, chemistry: 'lihv', internalResistanceMOm: 15, motorCount: 4, peakMotorCurrentA: 4, auxCurrentA: 0.5 },
21
+ freestyle5: { capacitymAh: 1300, claimedCRating: 100, cellCount: 6, chemistry: 'lipo', internalResistanceMOm: 4, motorCount: 4, peakMotorCurrentA: 35, auxCurrentA: 1.5 },
22
+ cinewhoop: { capacitymAh: 850, claimedCRating: 95, cellCount: 4, chemistry: 'lipo', internalResistanceMOm: 6, motorCount: 4, peakMotorCurrentA: 20, auxCurrentA: 2.0 },
23
+ longrange7: { capacitymAh: 3000, claimedCRating: 35, cellCount: 6, chemistry: 'liion', internalResistanceMOm: 12, motorCount: 4, peakMotorCurrentA: 22, auxCurrentA: 2.5 },
24
+ racing5: { capacitymAh: 1400, claimedCRating: 150, cellCount: 6, chemistry: 'lipo', internalResistanceMOm: 2.5, motorCount: 4, peakMotorCurrentA: 55, auxCurrentA: 1.2 },
25
+ };
26
+
27
+ export class DroneBatteryCRatingCalculatorController {
28
+ private container: HTMLElement;
29
+ private ui: DroneBatteryCRatingCalculatorUI;
30
+ private elements!: DOMResultElements;
31
+ private inputs!: BatteryInputs;
32
+ private activePreset: string | null = 'freestyle5';
33
+
34
+ constructor(container: HTMLElement, ui: DroneBatteryCRatingCalculatorUI) {
35
+ this.container = container;
36
+ this.ui = ui;
37
+ this.initElements();
38
+ this.initInputs();
39
+ this.bindEvents();
40
+ this.recalculate();
41
+ }
42
+
43
+ private initElements(): void {
44
+ this.elements = {
45
+ claimedMaxCurrent: this.container.querySelector('[data-metric="claimedMaxCurrent"]')!,
46
+ realisticCRating: this.container.querySelector('[data-metric="realisticCRating"]')!,
47
+ realisticMaxCurrent: this.container.querySelector('[data-metric="realisticMaxCurrent"]')!,
48
+ totalPeakDraw: this.container.querySelector('[data-metric="totalPeakDraw"]')!,
49
+ voltageSag: this.container.querySelector('[data-metric="voltageSag"]')!,
50
+ sagNominalVoltage: this.container.querySelector('[data-metric="sagNominalVoltage"]')!,
51
+ flightTimeFullThrottle: this.container.querySelector('[data-metric="flightTimeFullThrottle"]')!,
52
+ flightTimeHover: this.container.querySelector('[data-metric="flightTimeHover"]')!,
53
+ burstRatingRequired: this.container.querySelector('[data-metric="burstRatingRequired"]')!,
54
+ safetyBadge: this.container.querySelector('[data-metric="safetyBadge"]')!,
55
+ statusTitle: this.container.querySelector('[data-metric="statusTitle"]')!,
56
+ statusDesc: this.container.querySelector('[data-metric="statusDesc"]')!,
57
+ svgContainer: this.container.querySelector('[data-element="svgContainer"]')!,
58
+ };
59
+ }
60
+
61
+ private initInputs(): void {
62
+ const saved = loadSavedInputs();
63
+ this.inputs = Object.assign({}, PRESETS.freestyle5, saved);
64
+ this.syncInputElements();
65
+ }
66
+
67
+ private parseVal(selector: string): number {
68
+ const el = this.container.querySelector(selector) as HTMLInputElement;
69
+ if (!el || !el.value) return 0;
70
+ const cleanStr = el.value.replace(',', '.');
71
+ const val = parseFloat(cleanStr);
72
+ return Number.isFinite(val) ? val : 0;
73
+ }
74
+
75
+ private syncInputElements(): void {
76
+ this.setInputValue('#input-capacity', String(this.inputs.capacitymAh));
77
+ this.setInputValue('#range-capacity', String(this.inputs.capacitymAh));
78
+ this.setInputValue('#input-claimed-c', String(this.inputs.claimedCRating));
79
+ this.setInputValue('#range-claimed-c', String(this.inputs.claimedCRating));
80
+ this.setInputValue('#input-ir', String(this.inputs.internalResistanceMOm));
81
+ this.setInputValue('#range-ir', String(this.inputs.internalResistanceMOm));
82
+ this.setInputValue('#input-motor-count', String(this.inputs.motorCount));
83
+ this.setInputValue('#input-peak-current', String(this.inputs.peakMotorCurrentA));
84
+ this.setInputValue('#range-peak-current', String(this.inputs.peakMotorCurrentA));
85
+ this.setInputValue('#input-aux-current', String(this.inputs.auxCurrentA));
86
+
87
+ this.updateCustomSelect('cell-count', String(this.inputs.cellCount));
88
+ this.updateCustomSelect('chemistry', this.inputs.chemistry);
89
+ this.updatePresetChips();
90
+ }
91
+
92
+ private setInputValue(selector: string, val: string): void {
93
+ const el = this.container.querySelector(selector) as HTMLInputElement;
94
+ if (el) el.value = val;
95
+ }
96
+
97
+ private updatePresetChips(): void {
98
+ this.container.querySelectorAll('[data-preset]').forEach((btn) => {
99
+ const key = btn.getAttribute('data-preset');
100
+ btn.classList.toggle('active', key === this.activePreset);
101
+ });
102
+ }
103
+
104
+ private updateCustomSelect(name: string, value: string): void {
105
+ const wrapper = this.container.querySelector(`[data-custom-select="${name}"]`);
106
+ if (!wrapper) return;
107
+ const triggerText = wrapper.querySelector('.sc-select-trigger span');
108
+ const options = wrapper.querySelectorAll('.sc-select-option');
109
+ options.forEach((opt) => {
110
+ const isSelected = opt.getAttribute('data-value') === value;
111
+ opt.classList.toggle('active', isSelected);
112
+ if (isSelected && triggerText) triggerText.textContent = opt.textContent;
113
+ });
114
+ }
115
+
116
+ private bindEvents(): void {
117
+ this.bindNumberAndRangeInputs();
118
+ this.bindCustomSelects();
119
+ this.bindPresets();
120
+ document.addEventListener('click', () => this.closeAllSelects());
121
+ }
122
+
123
+ private bindNumberAndRangeInputs(): void {
124
+ this.bindRangeSync('#input-capacity', '#range-capacity', (v) => { this.inputs.capacitymAh = v; });
125
+ this.bindRangeSync('#input-claimed-c', '#range-claimed-c', (v) => { this.inputs.claimedCRating = v; });
126
+ this.bindRangeSync('#input-ir', '#range-ir', (v) => { this.inputs.internalResistanceMOm = v; });
127
+ this.bindRangeSync('#input-peak-current', '#range-peak-current', (v) => { this.inputs.peakMotorCurrentA = v; });
128
+
129
+ const motorInput = this.container.querySelector('#input-motor-count');
130
+ motorInput?.addEventListener('input', () => {
131
+ this.inputs.motorCount = Math.max(1, parseInt((motorInput as HTMLInputElement).value, 10) || 1);
132
+ this.onInputChanged();
133
+ });
134
+
135
+ const auxInput = this.container.querySelector('#input-aux-current');
136
+ auxInput?.addEventListener('input', () => {
137
+ this.inputs.auxCurrentA = this.parseVal('#input-aux-current');
138
+ this.onInputChanged();
139
+ });
140
+ }
141
+
142
+ private bindCustomSelects(): void {
143
+ this.container.querySelectorAll('.sc-custom-select').forEach((wrapper) => {
144
+ const trigger = wrapper.querySelector('.sc-select-trigger');
145
+ trigger?.addEventListener('click', (e) => {
146
+ e.stopPropagation();
147
+ this.closeAllSelects(wrapper);
148
+ wrapper.classList.toggle('open');
149
+ });
150
+
151
+ wrapper.querySelectorAll('.sc-select-option').forEach((opt) => {
152
+ opt.addEventListener('click', (e) => {
153
+ e.stopPropagation();
154
+ this.handleSelectOptionClick(wrapper, opt);
155
+ });
156
+ });
157
+ });
158
+ }
159
+
160
+ private handleSelectOptionClick(wrapper: Element, opt: Element): void {
161
+ const val = opt.getAttribute('data-value')!;
162
+ const name = wrapper.getAttribute('data-custom-select')!;
163
+ if (name === 'cell-count') this.inputs.cellCount = parseInt(val, 10);
164
+ if (name === 'chemistry') this.inputs.chemistry = val as BatteryChemistry;
165
+ this.updateCustomSelect(name, val);
166
+ wrapper.classList.remove('open');
167
+ this.onInputChanged();
168
+ }
169
+
170
+ private bindPresets(): void {
171
+ this.container.querySelectorAll('[data-preset]').forEach((btn) => {
172
+ btn.addEventListener('click', () => {
173
+ const key = btn.getAttribute('data-preset')!;
174
+ if (PRESETS[key]) {
175
+ this.activePreset = key;
176
+ this.inputs = { ...PRESETS[key] };
177
+ this.syncInputElements();
178
+ this.recalculate();
179
+ }
180
+ });
181
+ });
182
+ }
183
+
184
+ private onInputChanged(): void {
185
+ this.activePreset = null;
186
+ this.updatePresetChips();
187
+ this.recalculate();
188
+ }
189
+
190
+ private bindRangeSync(textSel: string, rangeSel: string, setter: (val: number) => void): void {
191
+ const textEl = this.container.querySelector(textSel) as HTMLInputElement;
192
+ const rangeEl = this.container.querySelector(rangeSel) as HTMLInputElement;
193
+
194
+ textEl?.addEventListener('input', () => {
195
+ const val = this.parseVal(textSel);
196
+ if (rangeEl) rangeEl.value = String(val);
197
+ setter(val);
198
+ this.onInputChanged();
199
+ });
200
+
201
+ rangeEl?.addEventListener('input', () => {
202
+ const val = parseFloat(rangeEl.value);
203
+ if (textEl) textEl.value = String(val);
204
+ setter(val);
205
+ this.onInputChanged();
206
+ });
207
+ }
208
+
209
+ private closeAllSelects(except?: Element): void {
210
+ this.container.querySelectorAll('.sc-custom-select').forEach((w) => {
211
+ if (w !== except) w.classList.remove('open');
212
+ });
213
+ }
214
+
215
+ private recalculate(): void {
216
+ const results = calculateBatteryMetrics(this.inputs);
217
+ const safetyEval = evaluateSafetyPresentation(results.safetyStatus, this.ui);
218
+ updateDOMMetrics(this.elements, results);
219
+ updateDOMSafety(this.elements, safetyEval);
220
+ if (this.elements.svgContainer) {
221
+ this.elements.svgContainer.innerHTML = renderBatterySVG(this.inputs, results);
222
+ }
223
+ saveInputs(this.inputs);
224
+ }
225
+ }