@jjlmoya/utils-drones 1.30.0 → 1.31.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 +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +4 -0
  4. package/src/index.ts +1 -1
  5. package/src/tests/locale_completeness.test.ts +1 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/drone-motor-propeller-calculator/bibliography.astro +6 -0
  8. package/src/tool/drone-motor-propeller-calculator/bibliography.ts +12 -0
  9. package/src/tool/drone-motor-propeller-calculator/component.astro +109 -0
  10. package/src/tool/drone-motor-propeller-calculator/controller.ts +174 -0
  11. package/src/tool/drone-motor-propeller-calculator/dom-views.ts +78 -0
  12. package/src/tool/drone-motor-propeller-calculator/drone-motor-propeller-calculator.css +562 -0
  13. package/src/tool/drone-motor-propeller-calculator/entry.ts +27 -0
  14. package/src/tool/drone-motor-propeller-calculator/evaluator.ts +14 -0
  15. package/src/tool/drone-motor-propeller-calculator/i18n/de.ts +202 -0
  16. package/src/tool/drone-motor-propeller-calculator/i18n/en.ts +202 -0
  17. package/src/tool/drone-motor-propeller-calculator/i18n/es.ts +202 -0
  18. package/src/tool/drone-motor-propeller-calculator/i18n/fr.ts +202 -0
  19. package/src/tool/drone-motor-propeller-calculator/i18n/id.ts +192 -0
  20. package/src/tool/drone-motor-propeller-calculator/i18n/it.ts +192 -0
  21. package/src/tool/drone-motor-propeller-calculator/i18n/ja.ts +192 -0
  22. package/src/tool/drone-motor-propeller-calculator/i18n/ko.ts +192 -0
  23. package/src/tool/drone-motor-propeller-calculator/i18n/nl.ts +192 -0
  24. package/src/tool/drone-motor-propeller-calculator/i18n/pl.ts +192 -0
  25. package/src/tool/drone-motor-propeller-calculator/i18n/pt.ts +192 -0
  26. package/src/tool/drone-motor-propeller-calculator/i18n/ru.ts +192 -0
  27. package/src/tool/drone-motor-propeller-calculator/i18n/sv.ts +192 -0
  28. package/src/tool/drone-motor-propeller-calculator/i18n/tr.ts +192 -0
  29. package/src/tool/drone-motor-propeller-calculator/i18n/zh.ts +192 -0
  30. package/src/tool/drone-motor-propeller-calculator/index.ts +11 -0
  31. package/src/tool/drone-motor-propeller-calculator/logic.test.ts +56 -0
  32. package/src/tool/drone-motor-propeller-calculator/logic.ts +108 -0
  33. package/src/tool/drone-motor-propeller-calculator/seo.astro +12 -0
  34. package/src/tool/drone-motor-propeller-calculator/storage.ts +26 -0
  35. package/src/tool/drone-motor-propeller-calculator/ui.ts +55 -0
  36. 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.30.0",
3
+ "version": "1.31.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -6,6 +6,7 @@ import { dronePowerAnalyzer } from '../tool/drone-power-analyzer/entry';
6
6
  import { gsdFlightPlanner } from '../tool/gsd-flight-planner/entry';
7
7
  import { droneBatteryCRatingCalculator } from '../tool/drone-battery-c-rating-calculator/entry';
8
8
  import { fpvDroneThrustToWeightRatio } from '../tool/fpv-drone-thrust-to-weight-ratio/entry';
9
+ import { droneMotorPropellerCalculator } from '../tool/drone-motor-propeller-calculator/entry';
9
10
 
10
11
  export const dronesCategory: DronesCategoryEntry = {
11
12
  icon: 'mdi:drone',
@@ -17,6 +18,7 @@ export const dronesCategory: DronesCategoryEntry = {
17
18
  gsdFlightPlanner,
18
19
  droneBatteryCRatingCalculator,
19
20
  fpvDroneThrustToWeightRatio,
21
+ droneMotorPropellerCalculator,
20
22
  ],
21
23
  i18n: {
22
24
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -12,6 +12,8 @@ export { droneBatteryCRatingCalculator } from './tool/drone-battery-c-rating-cal
12
12
  export type { DroneBatteryCRatingCalculatorUI, DroneBatteryCRatingCalculatorLocaleContent } from './tool/drone-battery-c-rating-calculator/entry';
13
13
  export { fpvDroneThrustToWeightRatio } from './tool/fpv-drone-thrust-to-weight-ratio/entry';
14
14
  export type { FpvDroneThrustToWeightRatioUI, FpvDroneThrustToWeightRatioLocaleContent } from './tool/fpv-drone-thrust-to-weight-ratio/entry';
15
+ export { droneMotorPropellerCalculator } from './tool/drone-motor-propeller-calculator/entry';
16
+ export type { DroneMotorPropellerUI, DroneMotorPropellerLocaleContent } from './tool/drone-motor-propeller-calculator/entry';
15
17
  export { dronesCategory } from './category';
16
18
 
17
19
  import { antennaLengthCalculator } from './tool/antenna-length-calculator/entry';
@@ -21,6 +23,7 @@ import { dronePowerAnalyzer } from './tool/drone-power-analyzer/entry';
21
23
  import { gsdFlightPlanner } from './tool/gsd-flight-planner/entry';
22
24
  import { droneBatteryCRatingCalculator } from './tool/drone-battery-c-rating-calculator/entry';
23
25
  import { fpvDroneThrustToWeightRatio } from './tool/fpv-drone-thrust-to-weight-ratio/entry';
26
+ import { droneMotorPropellerCalculator } from './tool/drone-motor-propeller-calculator/entry';
24
27
 
25
28
  export const ALL_ENTRIES = [
26
29
  antennaLengthCalculator,
@@ -30,4 +33,5 @@ export const ALL_ENTRIES = [
30
33
  gsdFlightPlanner,
31
34
  droneBatteryCRatingCalculator,
32
35
  fpvDroneThrustToWeightRatio,
36
+ droneMotorPropellerCalculator,
33
37
  ];
package/src/index.ts CHANGED
@@ -21,4 +21,4 @@ export { ANTENNA_LENGTH_CALCULATOR_TOOL } from './tool/antenna-length-calculator
21
21
  export { GPS_COORDINATES_CONVERTER_TOOL } from './tool/gps-coordinates-converter/index';
22
22
  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';
@@ -18,7 +18,6 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
 
20
20
  it('all 7 tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(7);
21
+ expect(ALL_TOOLS.length).toBe(8);
22
22
  });
23
23
  });
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 7 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(7);
7
+ it('should have 8 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(8);
9
9
  });
10
10
 
11
11
  it('dronesCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as BibliographyComponent } from '@jjlmoya/utils-shared';
3
+ import { BIBLIOGRAPHY_ITEMS } from './bibliography';
4
+ ---
5
+
6
+ <BibliographyComponent links={BIBLIOGRAPHY_ITEMS} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const BIBLIOGRAPHY_ITEMS: BibliographyEntry[] = [
4
+ {
5
+ name: 'NASA Thrust Coefficient Definition for Propellers',
6
+ url: 'https://ntrs.nasa.gov/api/citations/19900020078/downloads/19900020078.pdf',
7
+ },
8
+ {
9
+ name: 'University of Illinois UIUC Propeller Data Site',
10
+ url: 'https://m-selig.ae.illinois.edu/props/propDB.html',
11
+ },
12
+ ];
@@ -0,0 +1,109 @@
1
+ ---
2
+ import type { DroneMotorPropellerUI } from './ui';
3
+
4
+ interface Props {
5
+ ui: DroneMotorPropellerUI;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="drone-motor-tool" data-drone-motor-calculator data-unit="metric">
12
+ <script is:inline type="application/json" data-drone-motor-ui set:html={JSON.stringify(ui)}></script>
13
+ <div class="tool-controls">
14
+ <section class="setup-card presets-card" aria-labelledby="presets-title">
15
+ <div class="eyebrow">{ui.presetsHeader}</div>
16
+ <div class="preset-row" id="presets-title">
17
+ <button type="button" class="preset-button" data-preset="tiny-cruiser">{ui.presetTinyCruiser}</button>
18
+ <button type="button" class="preset-button is-selected" data-preset="freestyle">{ui.presetFreestyle}</button>
19
+ <button type="button" class="preset-button" data-preset="long-range">{ui.presetLongRange}</button>
20
+ <button type="button" class="preset-button" data-preset="cinelifter">{ui.presetCinelifter}</button>
21
+ </div>
22
+ </section>
23
+
24
+ <section class="setup-card unit-card" aria-labelledby="unit-title">
25
+ <div class="eyebrow" id="unit-title">{ui.unitHeader}</div>
26
+ <div class="unit-toggle" role="group" aria-label={ui.unitHeader}>
27
+ <button type="button" class="unit-button is-selected" data-unit="metric" aria-pressed="true">{ui.metricUnit}</button>
28
+ <button type="button" class="unit-button" data-unit="imperial" aria-pressed="false">{ui.imperialUnit}</button>
29
+ </div>
30
+ </section>
31
+
32
+ <section class="setup-card specs-card" aria-labelledby="setup-title">
33
+ <div class="section-heading">
34
+ <div class="eyebrow">01</div>
35
+ <h2 id="setup-title">{ui.setupHeader}</h2>
36
+ </div>
37
+ <div class="input-grid">
38
+ <label class="field"><span>{ui.motorKvLabel}</span><input data-field="motorKv" type="number" value="1950" min="100" max="10000" step="50" inputmode="decimal" /><small>KV</small></label>
39
+ <label class="field"><span>{ui.batteryVoltageLabel}</span><input data-field="batteryVoltage" type="number" value="22.2" min="1" max="60" step="0.1" inputmode="decimal" /><small>V</small></label>
40
+ <label class="field"><span>{ui.propDiameterLabel}</span><input data-field="propDiameterInches" type="number" value="5.1" min="1" max="30" step="0.1" inputmode="decimal" /><small data-unit-label="length">in</small></label>
41
+ <label class="field"><span>{ui.propPitchLabel}</span><input data-field="propPitchInches" type="number" value="4.3" min="0.5" max="25" step="0.1" inputmode="decimal" /><small data-unit-label="length">in</small></label>
42
+ <div class="choice-field"><span>{ui.bladeCountLabel}</span><div class="choice-row" role="group" aria-label={ui.bladeCountLabel}><button type="button" data-choice="bladeCount" data-value="2" aria-pressed="false">{ui.twoBlades}</button><button type="button" class="is-selected" data-choice="bladeCount" data-value="3" aria-pressed="true">{ui.threeBlades}</button><button type="button" data-choice="bladeCount" data-value="4" aria-pressed="false">{ui.fourBlades}</button></div></div>
43
+ <div class="choice-field"><span>{ui.motorCountLabel}</span><div class="choice-row" role="group" aria-label={ui.motorCountLabel}><button type="button" data-choice="motorCount" data-value="2" aria-pressed="false">{ui.twoMotors}</button><button type="button" class="is-selected" data-choice="motorCount" data-value="4" aria-pressed="true">{ui.fourMotors}</button><button type="button" data-choice="motorCount" data-value="6" aria-pressed="false">{ui.sixMotors}</button><button type="button" data-choice="motorCount" data-value="8" aria-pressed="false">{ui.eightMotors}</button></div></div>
44
+ <label class="field field-wide"><span>{ui.droneWeightLabel}</span><input data-field="droneWeightGrams" type="number" value="700" min="1" max="50000" step="10" inputmode="decimal" /><small data-unit-label="mass">g</small></label>
45
+ </div>
46
+ </section>
47
+
48
+ <section class="setup-card bench-card" aria-labelledby="bench-title">
49
+ <div class="section-heading">
50
+ <div class="eyebrow">02</div>
51
+ <h2 id="bench-title">{ui.benchDataHeader} <span>{ui.optionalLabel}</span></h2>
52
+ </div>
53
+ <div class="input-grid bench-grid">
54
+ <label class="field"><span>{ui.benchThrustLabel}</span><input data-field="benchThrustGrams" type="number" value="1900" min="1" max="30000" step="10" inputmode="decimal" /><small data-unit-label="mass">g</small></label>
55
+ <label class="field"><span>{ui.benchVoltageLabel}</span><input data-field="benchVoltage" type="number" value="22.2" min="1" max="60" step="0.1" inputmode="decimal" /><small>V</small></label>
56
+ </div>
57
+ <div class="bench-foot">
58
+ <p class="fine-print"><span data-output="source-note">{ui.benchBasedLabel}</span></p>
59
+ <button type="button" class="clear-button" data-action="clear-bench">{ui.clearBenchData}</button>
60
+ </div>
61
+ </section>
62
+ </div>
63
+
64
+ <section class="results-card" aria-labelledby="results-title">
65
+ <div class="results-topline">
66
+ <div>
67
+ <div class="eyebrow">03</div>
68
+ <h2 id="results-title">{ui.resultsHeader}</h2>
69
+ </div>
70
+ <div class="status-pill" data-output="status" aria-live="polite">{ui.headroomStatus}</div>
71
+ </div>
72
+ <div class="wind-tunnel">
73
+ <div class="tunnel-label tunnel-label-top">{ui.thrustAxisLabel}</div>
74
+ <div class="thrust-stream stream-left"></div>
75
+ <div class="thrust-stream stream-right"></div>
76
+ <div class="rotor-stage" data-role="rotor-stage">
77
+ <div class="rotor-shadow"></div>
78
+ <div class="rotor-disc">
79
+ <span class="rotor-blade blade-one"></span>
80
+ <span class="rotor-blade blade-two"></span>
81
+ <span class="rotor-blade blade-three"></span>
82
+ <span class="rotor-blade blade-four"></span>
83
+ <span class="rotor-hub"></span>
84
+ </div>
85
+ <div class="rotor-readout"><strong data-output="loaded-rpm">15,000 rpm</strong><span>{ui.sceneCaption}</span></div>
86
+ </div>
87
+ <div class="weight-marker"><span class="marker-line"></span><span>{ui.weightAxisLabel}</span></div>
88
+ <div class="lift-beam"><span></span></div>
89
+ </div>
90
+ <p class="status-advice" data-output="status-advice" aria-live="polite">{ui.headroomAdvice}</p>
91
+ <div class="result-grid">
92
+ <div class="result-cell accent"><span>{ui.thrustPerMotorLabel}</span><strong data-output="thrust-per-motor">1,900 g</strong></div>
93
+ <div class="result-cell accent"><span>{ui.totalThrustLabel}</span><strong data-output="total-thrust">7,600 g</strong></div>
94
+ <div class="result-cell"><span>{ui.pitchSpeedLabel}</span><strong data-output="pitch-speed">1,250 km/h</strong></div>
95
+ <div class="result-cell"><span>{ui.totalPowerLabel}</span><strong data-output="total-power">1,200 W</strong></div>
96
+ <div class="result-cell"><span>{ui.totalCurrentLabel}</span><strong data-output="total-current">54 A</strong></div>
97
+ <div class="result-cell"><span>{ui.thrustMarginLabel}</span><strong data-output="thrust-margin">986%</strong></div>
98
+ <div class="result-cell"><span>{ui.hoverThrottleLabel}</span><strong data-output="hover-throttle">30%</strong></div>
99
+ </div>
100
+ <div class="notes-row"><p>{ui.safetyNote}</p><p>{ui.modelNote}</p></div>
101
+ </section>
102
+ </div>
103
+
104
+ <script>
105
+ import { initDroneMotorPropellerCalculator } from './controller';
106
+
107
+ const root = document.querySelector<HTMLElement>('[data-drone-motor-calculator]');
108
+ if (root) initDroneMotorPropellerCalculator(root);
109
+ </script>
@@ -0,0 +1,174 @@
1
+ import { calculateDroneMotorMetrics, type BladeCount, type DroneMotorInputs, type MotorCount } from './logic';
2
+ import { evaluateSetup } from './evaluator';
3
+ import { formatInputValue, parseInputValue, renderResults, updateInputUnitLabels } from './dom-views';
4
+ import { readStoredState, writeStoredState, type DisplayUnit } from './storage';
5
+ import type { DroneMotorPropellerUI } from './ui';
6
+
7
+ interface Preset {
8
+ id: string;
9
+ inputs: DroneMotorInputs;
10
+ }
11
+
12
+ const DEFAULT_INPUTS: DroneMotorInputs = {
13
+ motorKv: 1950,
14
+ batteryVoltage: 22.2,
15
+ propDiameterInches: 5.1,
16
+ propPitchInches: 4.3,
17
+ bladeCount: 3,
18
+ motorCount: 4,
19
+ droneWeightGrams: 700,
20
+ benchThrustGrams: 1900,
21
+ benchVoltage: 22.2,
22
+ };
23
+
24
+ const PRESETS: Preset[] = [
25
+ { id: 'tiny-cruiser', inputs: { motorKv: 3500, batteryVoltage: 11.1, propDiameterInches: 3.5, propPitchInches: 3, bladeCount: 3, motorCount: 4, droneWeightGrams: 280, benchThrustGrams: 550, benchVoltage: 11.1 } },
26
+ { id: 'freestyle', inputs: DEFAULT_INPUTS },
27
+ { id: 'long-range', inputs: { motorKv: 1500, batteryVoltage: 22.2, propDiameterInches: 7, propPitchInches: 3.5, bladeCount: 2, motorCount: 4, droneWeightGrams: 1200, benchThrustGrams: 1800, benchVoltage: 22.2 } },
28
+ { id: 'cinelifter', inputs: { motorKv: 700, batteryVoltage: 22.2, propDiameterInches: 8, propPitchInches: 4, bladeCount: 3, motorCount: 8, droneWeightGrams: 4200, benchThrustGrams: 1600, benchVoltage: 22.2 } },
29
+ ];
30
+
31
+ function getInput(root: HTMLElement, field: string): HTMLInputElement | null {
32
+ return root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
33
+ }
34
+
35
+ function getNumber(root: HTMLElement, field: string, kind: 'mass' | 'length' | 'plain', unit: DisplayUnit): number {
36
+ const element = getInput(root, field);
37
+ if (!element || element.value.trim() === '') return 0;
38
+ return kind === 'plain' ? Number(element.value) : parseInputValue(element.value, kind, unit);
39
+ }
40
+
41
+ function getChoice(root: HTMLElement, field: string, fallback: number): number {
42
+ const selected = root.querySelector<HTMLElement>(`[data-choice="${field}"].is-selected`);
43
+ return Number(selected?.dataset.value ?? fallback);
44
+ }
45
+
46
+ function readInputs(root: HTMLElement, unit: DisplayUnit): DroneMotorInputs {
47
+ return {
48
+ motorKv: getNumber(root, 'motorKv', 'plain', unit),
49
+ batteryVoltage: getNumber(root, 'batteryVoltage', 'plain', unit),
50
+ propDiameterInches: getNumber(root, 'propDiameterInches', 'length', unit),
51
+ propPitchInches: getNumber(root, 'propPitchInches', 'length', unit),
52
+ bladeCount: getChoice(root, 'bladeCount', 3) as BladeCount,
53
+ motorCount: getChoice(root, 'motorCount', 4) as MotorCount,
54
+ droneWeightGrams: getNumber(root, 'droneWeightGrams', 'mass', unit),
55
+ benchThrustGrams: getOptionalNumber(root, 'benchThrustGrams', 'mass', unit),
56
+ benchVoltage: getOptionalNumber(root, 'benchVoltage', 'plain', unit),
57
+ };
58
+ }
59
+
60
+ function getOptionalNumber(root: HTMLElement, field: string, kind: 'mass' | 'plain', unit: DisplayUnit): number | undefined {
61
+ const value = getNumber(root, field, kind, unit);
62
+ return value > 0 ? value : undefined;
63
+ }
64
+
65
+ function setInput(root: HTMLElement, field: string, value: string): void {
66
+ const element = getInput(root, field);
67
+ if (element) element.value = value;
68
+ }
69
+
70
+ function setPhysicalInputs(root: HTMLElement, inputs: DroneMotorInputs, unit: DisplayUnit): void {
71
+ setInput(root, 'motorKv', String(inputs.motorKv));
72
+ setInput(root, 'batteryVoltage', String(inputs.batteryVoltage));
73
+ setInput(root, 'propDiameterInches', formatInputValue(inputs.propDiameterInches, 'length', unit));
74
+ setInput(root, 'propPitchInches', formatInputValue(inputs.propPitchInches, 'length', unit));
75
+ setInput(root, 'droneWeightGrams', formatInputValue(inputs.droneWeightGrams, 'mass', unit));
76
+ setInput(root, 'benchThrustGrams', inputs.benchThrustGrams ? formatInputValue(inputs.benchThrustGrams, 'mass', unit) : '');
77
+ setInput(root, 'benchVoltage', inputs.benchVoltage ? String(inputs.benchVoltage) : '');
78
+ selectChoice(root, 'bladeCount', inputs.bladeCount);
79
+ selectChoice(root, 'motorCount', inputs.motorCount);
80
+ }
81
+
82
+ function selectChoice(root: HTMLElement, field: string, value: number): void {
83
+ root.querySelectorAll<HTMLElement>(`[data-choice="${field}"]`).forEach((item) => {
84
+ item.classList.toggle('is-selected', Number(item.dataset.value) === value);
85
+ item.setAttribute('aria-pressed', String(Number(item.dataset.value) === value));
86
+ });
87
+ }
88
+
89
+ function selectPreset(root: HTMLElement, id: string): void {
90
+ root.querySelectorAll<HTMLElement>('[data-preset]').forEach((item) => {
91
+ item.classList.toggle('is-selected', item.dataset.preset === id);
92
+ });
93
+ }
94
+
95
+ function clearBenchData(root: HTMLElement, ui: DroneMotorPropellerUI): void {
96
+ setInput(root, 'benchThrustGrams', '');
97
+ setInput(root, 'benchVoltage', '');
98
+ selectPreset(root, '');
99
+ recalculate(root, ui, root.dataset.unit === 'imperial' ? 'imperial' : 'metric');
100
+ }
101
+
102
+ function updateUnitButtons(root: HTMLElement, unit: DisplayUnit): void {
103
+ root.querySelectorAll<HTMLElement>('[data-unit]').forEach((item) => {
104
+ item.classList.toggle('is-selected', item.dataset.unit === unit);
105
+ item.setAttribute('aria-pressed', String(item.dataset.unit === unit));
106
+ });
107
+ }
108
+
109
+ function recalculate(root: HTMLElement, ui: DroneMotorPropellerUI, unit: DisplayUnit): void {
110
+ const inputs = readInputs(root, unit);
111
+ if (inputs.motorKv <= 0 || inputs.batteryVoltage <= 0 || inputs.propDiameterInches <= 0 || inputs.propPitchInches <= 0 || inputs.droneWeightGrams <= 0) return;
112
+ const results = calculateDroneMotorMetrics(inputs);
113
+ const evaluation = evaluateSetup(results);
114
+ renderResults({ root, results, labels: ui, unit }, evaluation.status);
115
+ writeStoredState({ inputs, unit });
116
+ }
117
+
118
+ function changeUnit(root: HTMLElement, ui: DroneMotorPropellerUI, from: DisplayUnit, to: DisplayUnit): void {
119
+ if (from === to) return;
120
+ const inputs = readInputs(root, from);
121
+ setPhysicalInputs(root, inputs, to);
122
+ updateUnitButtons(root, to);
123
+ updateInputUnitLabels(root, to);
124
+ root.dataset.unit = to;
125
+ recalculate(root, ui, to);
126
+ }
127
+
128
+ function restoreState(root: HTMLElement): DisplayUnit {
129
+ const stored = readStoredState();
130
+ const unit = stored.unit === 'imperial' ? 'imperial' : 'metric';
131
+ setPhysicalInputs(root, stored.inputs ?? DEFAULT_INPUTS, unit);
132
+ return unit;
133
+ }
134
+
135
+ function readUi(root: HTMLElement): DroneMotorPropellerUI {
136
+ const element = root.querySelector<HTMLScriptElement>('[data-drone-motor-ui]');
137
+ return JSON.parse(element?.textContent ?? '{}') as DroneMotorPropellerUI;
138
+ }
139
+
140
+ function bindEvents(root: HTMLElement, ui: DroneMotorPropellerUI): void {
141
+ root.querySelectorAll<HTMLElement>('[data-preset]').forEach((item) => item.addEventListener('click', () => {
142
+ const preset = PRESETS.find((entry) => entry.id === item.dataset.preset);
143
+ if (!preset) return;
144
+ const unit = root.dataset.unit === 'imperial' ? 'imperial' : 'metric';
145
+ setPhysicalInputs(root, preset.inputs, unit);
146
+ selectPreset(root, preset.id);
147
+ recalculate(root, ui, unit);
148
+ }));
149
+ root.querySelectorAll<HTMLElement>('[data-choice]').forEach((item) => item.addEventListener('click', () => {
150
+ selectChoice(root, item.dataset.choice ?? '', Number(item.dataset.value));
151
+ selectPreset(root, '');
152
+ recalculate(root, ui, root.dataset.unit === 'imperial' ? 'imperial' : 'metric');
153
+ }));
154
+ root.querySelectorAll<HTMLElement>('[data-unit]').forEach((item) => item.addEventListener('click', () => {
155
+ const from = root.dataset.unit === 'imperial' ? 'imperial' : 'metric';
156
+ const to = item.dataset.unit === 'imperial' ? 'imperial' : 'metric';
157
+ changeUnit(root, ui, from, to);
158
+ }));
159
+ root.querySelectorAll<HTMLInputElement>('input[data-field]').forEach((item) => item.addEventListener('input', () => {
160
+ selectPreset(root, '');
161
+ recalculate(root, ui, root.dataset.unit === 'imperial' ? 'imperial' : 'metric');
162
+ }));
163
+ root.querySelector<HTMLElement>('[data-action="clear-bench"]')?.addEventListener('click', () => clearBenchData(root, ui));
164
+ }
165
+
166
+ export function initDroneMotorPropellerCalculator(root: HTMLElement): void {
167
+ const ui = readUi(root);
168
+ const unit = restoreState(root);
169
+ root.dataset.unit = unit;
170
+ updateUnitButtons(root, unit);
171
+ updateInputUnitLabels(root, unit);
172
+ bindEvents(root, ui);
173
+ recalculate(root, ui, unit);
174
+ }
@@ -0,0 +1,78 @@
1
+ import type { DroneMotorResults } from './logic';
2
+ import type { DisplayUnit } from './storage';
3
+ import type { DroneMotorPropellerUI } from './ui';
4
+
5
+ interface RenderContext {
6
+ root: HTMLElement;
7
+ results: DroneMotorResults;
8
+ labels: DroneMotorPropellerUI;
9
+ unit: DisplayUnit;
10
+ }
11
+
12
+ function formatNumber(value: number, digits = 0): string {
13
+ return new Intl.NumberFormat('en-US', { maximumFractionDigits: digits }).format(value);
14
+ }
15
+
16
+ function formatMass(grams: number, unit: DisplayUnit): string {
17
+ return unit === 'metric' ? `${formatNumber(grams)} g` : `${formatNumber(grams / 28.3495, 1)} oz`;
18
+ }
19
+
20
+ function formatSpeed(kmh: number, unit: DisplayUnit): string {
21
+ return unit === 'metric' ? `${formatNumber(kmh)} km/h` : `${formatNumber(kmh / 1.60934)} mph`;
22
+ }
23
+
24
+ function setText(root: HTMLElement, key: string, value: string): void {
25
+ const element = root.querySelector<HTMLElement>(`[data-output="${key}"]`);
26
+ if (element) element.textContent = value;
27
+ }
28
+
29
+ function getStatusText(status: string, labels: DroneMotorPropellerUI): string {
30
+ if (status === 'underpowered') return labels.underpoweredStatus;
31
+ if (status === 'workable') return labels.workableStatus;
32
+ return labels.headroomStatus;
33
+ }
34
+
35
+ function getStatusAdvice(status: string, labels: DroneMotorPropellerUI): string {
36
+ if (status === 'underpowered') return labels.underpoweredAdvice;
37
+ if (status === 'workable') return labels.workableAdvice;
38
+ return labels.headroomAdvice;
39
+ }
40
+
41
+ export function renderResults(context: RenderContext, status: string): void {
42
+ const { root, results, labels, unit } = context;
43
+ setText(root, 'loaded-rpm', `${formatNumber(results.loadedRpm)} rpm`);
44
+ setText(root, 'pitch-speed', formatSpeed(results.pitchSpeedKmh, unit));
45
+ setText(root, 'thrust-per-motor', formatMass(results.thrustPerMotorGrams, unit));
46
+ setText(root, 'total-thrust', formatMass(results.totalThrustGrams, unit));
47
+ setText(root, 'total-power', `${formatNumber(results.totalPowerWatts)} W`);
48
+ setText(root, 'total-current', `${formatNumber(results.totalCurrentAmps, 1)} A`);
49
+ setText(root, 'thrust-margin', `${formatNumber(results.thrustMarginPercent)}%`);
50
+ setText(root, 'hover-throttle', `${formatNumber(results.hoverThrottlePercent)}%`);
51
+ setText(root, 'status', getStatusText(status, labels));
52
+ setText(root, 'status-advice', getStatusAdvice(status, labels));
53
+ const sourceLabel = results.thrustSource === 'bench'
54
+ ? `${labels.benchBasedLabel} ${labels.sourceNote}`
55
+ : `${labels.estimatedLabel} ${labels.modelSourceNote}`;
56
+ setText(root, 'source-note', sourceLabel);
57
+ root.dataset.status = status;
58
+ root.style.setProperty('--lift-ratio', String(Math.min(results.totalThrustGrams / (results.totalThrustGrams + 1), 1)));
59
+ root.style.setProperty('--rotor-speed', `${Math.max(80, 90000 / Math.max(results.loadedRpm, 1))}ms`);
60
+ }
61
+
62
+ export function updateInputUnitLabels(root: HTMLElement, unit: DisplayUnit): void {
63
+ const massUnit = unit === 'metric' ? 'g' : 'oz';
64
+ const lengthUnit = unit === 'metric' ? 'cm' : 'in';
65
+ root.querySelectorAll<HTMLElement>('[data-unit-label="mass"]').forEach((item) => { item.textContent = massUnit; });
66
+ root.querySelectorAll<HTMLElement>('[data-unit-label="length"]').forEach((item) => { item.textContent = lengthUnit; });
67
+ }
68
+
69
+ export function formatInputValue(value: number, kind: 'mass' | 'length', unit: DisplayUnit): string {
70
+ if (kind === 'mass') return unit === 'metric' ? String(Math.round(value)) : String(Math.round(value / 28.3495 * 10) / 10);
71
+ return unit === 'metric' ? String(Math.round(value * 2.54 * 10) / 10) : String(value);
72
+ }
73
+
74
+ export function parseInputValue(value: string, kind: 'mass' | 'length', unit: DisplayUnit): number {
75
+ const parsed = Number(value);
76
+ if (kind === 'mass') return unit === 'metric' ? parsed : parsed * 28.3495;
77
+ return unit === 'metric' ? parsed / 2.54 : parsed;
78
+ }