@jjlmoya/utils-drones 1.35.0 → 1.37.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 (63) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +4 -0
  3. package/src/entries.ts +8 -0
  4. package/src/index.ts +1 -0
  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-mission-battery-reserve-planner/bibliography.astro +6 -0
  8. package/src/tool/drone-mission-battery-reserve-planner/bibliography.ts +7 -0
  9. package/src/tool/drone-mission-battery-reserve-planner/component.astro +229 -0
  10. package/src/tool/drone-mission-battery-reserve-planner/controller.ts +124 -0
  11. package/src/tool/drone-mission-battery-reserve-planner/dom-views.ts +114 -0
  12. package/src/tool/drone-mission-battery-reserve-planner/drone-mission-battery-reserve-planner.css +392 -0
  13. package/src/tool/drone-mission-battery-reserve-planner/entry.ts +27 -0
  14. package/src/tool/drone-mission-battery-reserve-planner/evaluator.ts +23 -0
  15. package/src/tool/drone-mission-battery-reserve-planner/i18n/de.ts +223 -0
  16. package/src/tool/drone-mission-battery-reserve-planner/i18n/en.ts +223 -0
  17. package/src/tool/drone-mission-battery-reserve-planner/i18n/es.ts +223 -0
  18. package/src/tool/drone-mission-battery-reserve-planner/i18n/fr.ts +223 -0
  19. package/src/tool/drone-mission-battery-reserve-planner/i18n/id.ts +223 -0
  20. package/src/tool/drone-mission-battery-reserve-planner/i18n/it.ts +223 -0
  21. package/src/tool/drone-mission-battery-reserve-planner/i18n/ja.ts +223 -0
  22. package/src/tool/drone-mission-battery-reserve-planner/i18n/ko.ts +223 -0
  23. package/src/tool/drone-mission-battery-reserve-planner/i18n/nl.ts +223 -0
  24. package/src/tool/drone-mission-battery-reserve-planner/i18n/pl.ts +223 -0
  25. package/src/tool/drone-mission-battery-reserve-planner/i18n/pt.ts +223 -0
  26. package/src/tool/drone-mission-battery-reserve-planner/i18n/ru.ts +223 -0
  27. package/src/tool/drone-mission-battery-reserve-planner/i18n/sv.ts +223 -0
  28. package/src/tool/drone-mission-battery-reserve-planner/i18n/tr.ts +223 -0
  29. package/src/tool/drone-mission-battery-reserve-planner/i18n/zh.ts +223 -0
  30. package/src/tool/drone-mission-battery-reserve-planner/index.ts +11 -0
  31. package/src/tool/drone-mission-battery-reserve-planner/logic.test.ts +94 -0
  32. package/src/tool/drone-mission-battery-reserve-planner/logic.ts +203 -0
  33. package/src/tool/drone-mission-battery-reserve-planner/seo.astro +15 -0
  34. package/src/tool/drone-mission-battery-reserve-planner/storage.ts +59 -0
  35. package/src/tool/drone-mission-battery-reserve-planner/ui.ts +78 -0
  36. package/src/tool/fpv-drone-speed-calculator/bibliography.astro +6 -0
  37. package/src/tool/fpv-drone-speed-calculator/bibliography.ts +14 -0
  38. package/src/tool/fpv-drone-speed-calculator/component.astro +115 -0
  39. package/src/tool/fpv-drone-speed-calculator/controller.ts +105 -0
  40. package/src/tool/fpv-drone-speed-calculator/dom-views.ts +80 -0
  41. package/src/tool/fpv-drone-speed-calculator/entry.ts +27 -0
  42. package/src/tool/fpv-drone-speed-calculator/fpv-drone-speed-calculator.css +558 -0
  43. package/src/tool/fpv-drone-speed-calculator/i18n/de.ts +20 -0
  44. package/src/tool/fpv-drone-speed-calculator/i18n/en.ts +47 -0
  45. package/src/tool/fpv-drone-speed-calculator/i18n/es.ts +40 -0
  46. package/src/tool/fpv-drone-speed-calculator/i18n/fr.ts +13 -0
  47. package/src/tool/fpv-drone-speed-calculator/i18n/id.ts +13 -0
  48. package/src/tool/fpv-drone-speed-calculator/i18n/it.ts +13 -0
  49. package/src/tool/fpv-drone-speed-calculator/i18n/ja.ts +13 -0
  50. package/src/tool/fpv-drone-speed-calculator/i18n/ko.ts +13 -0
  51. package/src/tool/fpv-drone-speed-calculator/i18n/nl.ts +13 -0
  52. package/src/tool/fpv-drone-speed-calculator/i18n/pl.ts +13 -0
  53. package/src/tool/fpv-drone-speed-calculator/i18n/pt.ts +13 -0
  54. package/src/tool/fpv-drone-speed-calculator/i18n/ru.ts +13 -0
  55. package/src/tool/fpv-drone-speed-calculator/i18n/sv.ts +13 -0
  56. package/src/tool/fpv-drone-speed-calculator/i18n/tr.ts +13 -0
  57. package/src/tool/fpv-drone-speed-calculator/i18n/zh.ts +13 -0
  58. package/src/tool/fpv-drone-speed-calculator/index.ts +11 -0
  59. package/src/tool/fpv-drone-speed-calculator/logic.test.ts +53 -0
  60. package/src/tool/fpv-drone-speed-calculator/logic.ts +126 -0
  61. package/src/tool/fpv-drone-speed-calculator/seo.astro +16 -0
  62. package/src/tool/fpv-drone-speed-calculator/ui.ts +47 -0
  63. package/src/tools.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-drones",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -7,6 +7,8 @@ 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
9
  import { droneMotorPropellerCalculator } from '../tool/drone-motor-propeller-calculator/entry';
10
+ import { fpvDroneSpeedCalculator } from '../tool/fpv-drone-speed-calculator/entry';
11
+ import { droneMissionBatteryReservePlanner } from '../tool/drone-mission-battery-reserve-planner/entry';
10
12
 
11
13
  export const dronesCategory: DronesCategoryEntry = {
12
14
  icon: 'mdi:drone',
@@ -19,6 +21,8 @@ export const dronesCategory: DronesCategoryEntry = {
19
21
  droneBatteryCRatingCalculator,
20
22
  fpvDroneThrustToWeightRatio,
21
23
  droneMotorPropellerCalculator,
24
+ fpvDroneSpeedCalculator,
25
+ droneMissionBatteryReservePlanner,
22
26
  ],
23
27
  i18n: {
24
28
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -14,6 +14,10 @@ export { fpvDroneThrustToWeightRatio } from './tool/fpv-drone-thrust-to-weight-r
14
14
  export type { FpvDroneThrustToWeightRatioUI, FpvDroneThrustToWeightRatioLocaleContent } from './tool/fpv-drone-thrust-to-weight-ratio/entry';
15
15
  export { droneMotorPropellerCalculator } from './tool/drone-motor-propeller-calculator/entry';
16
16
  export type { DroneMotorPropellerUI, DroneMotorPropellerLocaleContent } from './tool/drone-motor-propeller-calculator/entry';
17
+ export { fpvDroneSpeedCalculator } from './tool/fpv-drone-speed-calculator/entry';
18
+ export type { FpvDroneSpeedUI, FpvDroneSpeedLocaleContent } from './tool/fpv-drone-speed-calculator/entry';
19
+ export { droneMissionBatteryReservePlanner } from './tool/drone-mission-battery-reserve-planner/entry';
20
+ export type { DroneMissionBatteryReservePlannerUI, DroneMissionBatteryReservePlannerLocaleContent } from './tool/drone-mission-battery-reserve-planner/entry';
17
21
  export { dronesCategory } from './category';
18
22
 
19
23
  import { antennaLengthCalculator } from './tool/antenna-length-calculator/entry';
@@ -24,6 +28,8 @@ import { gsdFlightPlanner } from './tool/gsd-flight-planner/entry';
24
28
  import { droneBatteryCRatingCalculator } from './tool/drone-battery-c-rating-calculator/entry';
25
29
  import { fpvDroneThrustToWeightRatio } from './tool/fpv-drone-thrust-to-weight-ratio/entry';
26
30
  import { droneMotorPropellerCalculator } from './tool/drone-motor-propeller-calculator/entry';
31
+ import { fpvDroneSpeedCalculator } from './tool/fpv-drone-speed-calculator/entry';
32
+ import { droneMissionBatteryReservePlanner } from './tool/drone-mission-battery-reserve-planner/entry';
27
33
 
28
34
  export const ALL_ENTRIES = [
29
35
  antennaLengthCalculator,
@@ -34,4 +40,6 @@ export const ALL_ENTRIES = [
34
40
  droneBatteryCRatingCalculator,
35
41
  fpvDroneThrustToWeightRatio,
36
42
  droneMotorPropellerCalculator,
43
+ fpvDroneSpeedCalculator,
44
+ droneMissionBatteryReservePlanner,
37
45
  ];
package/src/index.ts CHANGED
@@ -22,3 +22,4 @@ export { GPS_COORDINATES_CONVERTER_TOOL } from './tool/gps-coordinates-converter
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';
25
+ export { FPV_DRONE_SPEED_CALCULATOR_TOOL } from './tool/fpv-drone-speed-calculator/index';
@@ -17,7 +17,7 @@ describe('Locale Completeness Validation', () => {
17
17
  });
18
18
  });
19
19
 
20
- it('all 7 tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(8);
20
+ it('all 10 tools registered', () => {
21
+ expect(ALL_TOOLS.length).toBe(10);
22
22
  });
23
23
  });
@@ -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 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(8);
7
+ it('should have 10 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(10);
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 } from './bibliography';
4
+ ---
5
+
6
+ <BibliographyComponent links={bibliography} />
@@ -0,0 +1,7 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'FAA Remote Pilot Study Guide (sUAS Reserve Standards)', url: 'https://www.faa.gov/regulations_policies/handbooks_manuals/aviation/media/remote_pilot_study_guide.pdf' },
5
+ { name: 'EASA Easy Access Rules for Unmanned Aircraft Systems', url: 'https://www.easa.europa.eu/en/domains/civil-drones' },
6
+ { name: 'DGAC France Guide de securite des aeronefs telepilotes', url: 'https://www.ecologie.gouv.fr/drones-aeronefs-telepilotes' },
7
+ ];
@@ -0,0 +1,229 @@
1
+ ---
2
+ import type { DroneMissionBatteryReservePlannerUI } from './ui';
3
+
4
+ interface Props {
5
+ ui?: DroneMissionBatteryReservePlannerUI;
6
+ content?: { ui: DroneMissionBatteryReservePlannerUI };
7
+ }
8
+
9
+ const props = Astro.props;
10
+ const ui: DroneMissionBatteryReservePlannerUI = props.ui || props.content?.ui || ({} as DroneMissionBatteryReservePlannerUI);
11
+ ---
12
+
13
+ <div class="drone-mission-battery-reserve-planner" id="drone-mission-planner-root">
14
+ <div class="planner-top-bar">
15
+ <div class="presets-section">
16
+ <span class="presets-label">{ui.inputs?.presetLabel || 'Quick Mission Presets'}</span>
17
+ <div class="presets-flex">
18
+ <button type="button" class="preset-pill" data-preset="mappingSurvey">
19
+ <span class="preset-name">{ui.presets?.mappingSurvey || 'Mapping Survey'}</span>
20
+ <span class="preset-meta">6S 10000mAh • 8km • 10m {ui.presets?.surveyMeta || 'survey'}</span>
21
+ </button>
22
+ <button type="button" class="preset-pill" data-preset="fpvRecon">
23
+ <span class="preset-name">{ui.presets?.fpvRecon || 'FPV Recon'}</span>
24
+ <span class="preset-meta">4S 3000mAh • 6km • 2m {ui.presets?.scoutMeta || 'scout'}</span>
25
+ </button>
26
+ <button type="button" class="preset-pill" data-preset="cinematicInspection">
27
+ <span class="preset-name">{ui.presets?.cinematicInspection || 'Cinematic Inspection'}</span>
28
+ <span class="preset-meta">4S 5000mAh • 2.5km • 8m {ui.presets?.hoverMeta || 'hover'}</span>
29
+ </button>
30
+ <button type="button" class="preset-pill" data-preset="microRecon">
31
+ <span class="preset-name">{ui.presets?.microRecon || 'Micro Scout'}</span>
32
+ <span class="preset-meta">2S 850mAh • 1km • 3m {ui.presets?.hoverMeta || 'hover'}</span>
33
+ </button>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="unit-switch-group">
38
+ <button type="button" class="unit-btn active" id="unit-btn-metric">{ui.inputs?.metricLabel || 'Metric'}</button>
39
+ <button type="button" class="unit-btn" id="unit-btn-imperial">{ui.inputs?.imperialLabel || 'Imperial'}</button>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="planner-main-grid">
44
+ <div class="planner-controls-panel">
45
+ <h3 class="panel-heading">{ui.sections?.batteryPropulsion || '1. Battery & Propulsion'}</h3>
46
+
47
+ <div class="control-row">
48
+ <div class="control-header">
49
+ <label for="input-capacity">{ui.inputs?.batteryCapacityLabel || 'Battery Capacity'}</label>
50
+ <div class="control-val-display"><span id="val-disp-capacity">10000</span> mAh</div>
51
+ </div>
52
+ <div class="slider-with-number">
53
+ <input type="range" id="slider-capacity" aria-label="Battery Capacity Slider" min="500" max="30000" step="100" value="10000" class="tactile-slider" />
54
+ <input type="number" id="input-capacity" value="10000" min="100" max="100000" step="100" class="tactile-number" />
55
+ </div>
56
+ </div>
57
+
58
+ <div class="control-row">
59
+ <div class="control-header">
60
+ <label for="input-voltage">{ui.inputs?.batteryVoltageLabel || 'Nominal Voltage'}</label>
61
+ <div class="control-val-display"><span id="val-disp-voltage">22.2</span> V</div>
62
+ </div>
63
+ <div class="voltage-chips-row">
64
+ <button type="button" class="voltage-chip" data-v="11.1">3S (11.1V)</button>
65
+ <button type="button" class="voltage-chip" data-v="14.8">4S (14.8V)</button>
66
+ <button type="button" class="voltage-chip active" data-v="22.2">6S (22.2V)</button>
67
+ <button type="button" class="voltage-chip" data-v="44.4">12S (44.4V)</button>
68
+ </div>
69
+ <div class="slider-with-number" style="margin-top: 8px;">
70
+ <input type="range" id="slider-voltage" aria-label="Nominal Voltage Slider" min="3.7" max="60" step="0.1" value="22.2" class="tactile-slider" />
71
+ <input type="number" id="input-voltage" value="22.2" min="1" max="100" step="0.1" class="tactile-number" />
72
+ </div>
73
+ </div>
74
+
75
+ <div class="control-row">
76
+ <div class="control-header">
77
+ <label for="input-current">{ui.inputs?.averageCurrentLabel || 'Average Hover Cruise Current'}</label>
78
+ <div class="control-val-display"><span id="val-disp-current">18.0</span> A</div>
79
+ </div>
80
+ <div class="slider-with-number">
81
+ <input type="range" id="slider-current" aria-label="Average Current Slider" min="1" max="100" step="0.5" value="18" class="tactile-slider" />
82
+ <input type="number" id="input-current" value="18" min="0.1" max="500" step="0.5" class="tactile-number" />
83
+ </div>
84
+ </div>
85
+
86
+ <h3 class="panel-heading" style="margin-top: 24px;">{ui.sections?.flightAtmosphere || '2. Flight Profile & Atmosphere'}</h3>
87
+
88
+ <div class="control-row">
89
+ <div class="control-header">
90
+ <label for="input-speed">{ui.inputs?.cruiseSpeedLabel || 'Airspeed Cruise Speed'}</label>
91
+ <div class="control-val-display"><span id="val-disp-speed">45</span> <span class="unit-label-speed">km/h</span></div>
92
+ </div>
93
+ <div class="slider-with-number">
94
+ <input type="range" id="slider-speed" aria-label="Cruise Speed Slider" min="10" max="150" step="1" value="45" class="tactile-slider" />
95
+ <input type="number" id="input-speed" value="45" min="1" max="300" step="1" class="tactile-number" />
96
+ </div>
97
+ </div>
98
+
99
+ <div class="control-row">
100
+ <div class="control-header">
101
+ <label for="input-distance">{ui.inputs?.oneWayDistanceLabel || 'One-Way Transit Distance'}</label>
102
+ <div class="control-val-display"><span id="val-disp-distance">6.0</span> <span class="unit-label-dist">km</span></div>
103
+ </div>
104
+ <div class="slider-with-number">
105
+ <input type="range" id="slider-distance" aria-label="One-Way Distance Slider" min="0.5" max="30" step="0.5" value="6" class="tactile-slider" />
106
+ <input type="number" id="input-distance" value="6" min="0.1" max="200" step="0.5" class="tactile-number" />
107
+ </div>
108
+ </div>
109
+
110
+ <div class="control-row">
111
+ <div class="control-header">
112
+ <label for="input-target-hover">{ui.inputs?.targetHoverTimeLabel || 'Target Area Operational Duration'}</label>
113
+ <div class="control-val-display"><span id="val-disp-target-hover">5.0</span> min</div>
114
+ </div>
115
+ <div class="slider-with-number">
116
+ <input type="range" id="slider-target-hover" aria-label="Target Hover Duration Slider" min="0" max="30" step="0.5" value="5" class="tactile-slider" />
117
+ <input type="number" id="input-target-hover" value="5" min="0" max="120" step="0.5" class="tactile-number" />
118
+ </div>
119
+ </div>
120
+
121
+ <div class="control-row">
122
+ <div class="control-header">
123
+ <label for="input-wind-speed">{ui.inputs?.windSpeedLabel || 'Ambient Wind Speed'}</label>
124
+ <div class="control-val-display"><span id="val-disp-wind">12</span> <span class="unit-label-speed">km/h</span></div>
125
+ </div>
126
+ <div class="slider-with-number">
127
+ <input type="range" id="slider-wind-speed" aria-label="Wind Speed Slider" min="0" max="60" step="1" value="12" class="tactile-slider" />
128
+ <input type="number" id="input-wind-speed" value="12" min="0" max="120" step="1" class="tactile-number" />
129
+ </div>
130
+ </div>
131
+
132
+ <div class="control-row">
133
+ <label>{ui.inputs?.windDirectionLabel || 'Wind Heading Relative to Outbound'}</label>
134
+ <div class="wind-dir-segmented">
135
+ <button type="button" class="wind-seg-btn active" data-dir="headwind">
136
+ <span class="wind-tag font-mono">HEAD</span>
137
+ <span>{ui.inputs?.windHeadwindLabel || 'Headwind Outbound'}</span>
138
+ </button>
139
+ <button type="button" class="wind-seg-btn" data-dir="tailwind">
140
+ <span class="wind-tag font-mono">TAIL</span>
141
+ <span>{ui.inputs?.windTailwindLabel || 'Tailwind Outbound'}</span>
142
+ </button>
143
+ <button type="button" class="wind-seg-btn" data-dir="crosswind">
144
+ <span class="wind-tag font-mono">CROSS</span>
145
+ <span>{ui.inputs?.windCrosswindLabel || 'Crosswind'}</span>
146
+ </button>
147
+ </div>
148
+ </div>
149
+
150
+ <div class="control-row">
151
+ <div class="control-header">
152
+ <label for="input-reserve">{ui.inputs?.reservePolicyLabel || 'Safety Reserve Buffer'}</label>
153
+ <div class="control-val-display"><span id="val-disp-reserve">20</span>%</div>
154
+ </div>
155
+ <div class="slider-with-number">
156
+ <input type="range" id="slider-reserve" aria-label="Safety Reserve Buffer Slider" min="10" max="40" step="1" value="20" class="tactile-slider" />
157
+ <input type="number" id="input-reserve" value="20" min="5" max="50" step="1" class="tactile-number" />
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ <div class="planner-telemetry-panel">
163
+ <div id="res-status-banner" class="status-banner status-banner-optimal">
164
+ <div class="status-icon-dot"></div>
165
+ <div class="status-banner-text">
166
+ <span class="status-banner-title" id="res-status-title">{ui.statusBadges?.optimalTitle || 'OPTIMAL ENERGY RESERVE MARGIN'}</span>
167
+ <span class="status-banner-subtitle" id="res-status-subtitle">{ui.statusBadges?.optimalSubtitle || 'Safe flight envelope with sufficient landing reserve margin'}</span>
168
+ </div>
169
+ </div>
170
+
171
+ <div class="telemetry-artwork-box" id="res-svg-wrapper"></div>
172
+
173
+ <div class="metrics-grid">
174
+ <div class="metric-card">
175
+ <span class="metric-label">{ui.results?.totalAutonomyTime || 'Total Autonomy'}</span>
176
+ <span class="metric-value" id="res-autonomy">24.0 min</span>
177
+ <span class="metric-sub font-mono" id="res-sag-loss">Voltage sag: -11.1 Wh</span>
178
+ </div>
179
+
180
+ <div class="metric-card highlight-card">
181
+ <span class="metric-label">{ui.results?.maxSafeMissionRadius || 'Point of No Return'}</span>
182
+ <span class="metric-value" id="res-max-radius">12.5 km</span>
183
+ <span class="metric-sub">{ui.results?.maxRadiusSubLabel || 'Max safe radius w/ target hover'}</span>
184
+ </div>
185
+
186
+ <div class="metric-card">
187
+ <span class="metric-label">{ui.results?.outboundLegTime || 'Outbound Duration'}</span>
188
+ <span class="metric-value" id="res-outbound-time">10.9 min</span>
189
+ <span class="metric-sub" id="res-outbound-power">Power: 472 W</span>
190
+ </div>
191
+
192
+ <div class="metric-card">
193
+ <span class="metric-label">{ui.results?.returnLegTime || 'Return Duration'}</span>
194
+ <span class="metric-value" id="res-return-time">7.6 min</span>
195
+ <span class="metric-sub" id="res-return-power">Power: 360 W</span>
196
+ </div>
197
+ </div>
198
+
199
+ <div class="touchdown-energy-card">
200
+ <div class="touchdown-info">
201
+ <span class="touchdown-title">{ui.results?.remainingEnergyLanding || 'Estimated Touchdown Battery'}</span>
202
+ <span class="touchdown-value" id="res-landing-energy">47.7% (105.9 Wh)</span>
203
+ </div>
204
+ <div class="touchdown-progress-track">
205
+ <div class="touchdown-progress-bar" id="res-landing-bar" style="width: 47.7%;"></div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ <script is:inline type="application/json" id="drone-mission-planner-ui-data" set:html={JSON.stringify(ui)} />
213
+
214
+ <script>
215
+ import { loadSavedInputs } from './storage';
216
+ import { bindPlannerController } from './controller';
217
+ import type { DroneMissionBatteryReservePlannerUI } from './ui';
218
+
219
+ const root = document.getElementById('drone-mission-planner-root');
220
+ const dataEl = document.getElementById('drone-mission-planner-ui-data');
221
+
222
+ if (root && dataEl && dataEl.textContent) {
223
+ try {
224
+ const ui: DroneMissionBatteryReservePlannerUI = JSON.parse(dataEl.textContent);
225
+ const initial = loadSavedInputs();
226
+ bindPlannerController(root, initial, ui);
227
+ } catch {}
228
+ }
229
+ </script>
@@ -0,0 +1,124 @@
1
+ import type { MissionInputs, MissionResults } from './logic';
2
+ import { calculateMissionReserve, convertDistance } from './logic';
3
+ import { renderTelemetrySvg } from './dom-views';
4
+ import { saveInputs } from './storage';
5
+ import type { DroneMissionBatteryReservePlannerUI } from './ui';
6
+
7
+ function setElementsText(container: HTMLElement, s: { speedUnit: string; distUnit: string }): void {
8
+ container.querySelectorAll<HTMLElement>('.unit-label-speed').forEach((el) => { el.textContent = s.speedUnit; });
9
+ container.querySelectorAll<HTMLElement>('.unit-label-dist').forEach((el) => { el.textContent = s.distUnit; });
10
+ }
11
+
12
+ function getBannerTitle(statusKey: string, b?: DroneMissionBatteryReservePlannerUI['statusBadges']): string {
13
+ if (statusKey === 'tight') return b ? b.tightTitle : 'TIGHT ENERGY RESERVE MARGIN';
14
+ if (statusKey === 'critical') return b ? b.criticalTitle : 'CRITICAL ENERGY WARNING';
15
+ if (statusKey === 'exceeded') return b ? b.exceededTitle : 'MISSION EXCEEDS SAFE BATTERY CAPACITY';
16
+ return b ? b.optimalTitle : 'OPTIMAL ENERGY RESERVE MARGIN';
17
+ }
18
+
19
+ function getBannerSubtitle(statusKey: string, b?: DroneMissionBatteryReservePlannerUI['statusBadges']): string {
20
+ if (statusKey === 'tight') return b ? b.tightSubtitle : 'Low landing reserve';
21
+ if (statusKey === 'critical') return b ? b.criticalSubtitle : 'Reserve breached';
22
+ if (statusKey === 'exceeded') return b ? b.exceededSubtitle : 'Insufficient battery energy';
23
+ return b ? b.optimalSubtitle : 'Safe flight envelope';
24
+ }
25
+
26
+ function updateStatusBanner(
27
+ container: HTMLElement,
28
+ statusKey: string,
29
+ uiBadges?: DroneMissionBatteryReservePlannerUI['statusBadges']
30
+ ): void {
31
+ const banner = container.querySelector<HTMLElement>('#res-status-banner');
32
+ const title = container.querySelector<HTMLElement>('#res-status-title');
33
+ const subtitle = container.querySelector<HTMLElement>('#res-status-subtitle');
34
+ if (!banner || !title) return;
35
+
36
+ banner.className = `status-banner status-banner-${statusKey}`;
37
+ title.textContent = getBannerTitle(statusKey, uiBadges);
38
+ if (subtitle) subtitle.textContent = getBannerSubtitle(statusKey, uiBadges);
39
+ }
40
+
41
+ function setCardText(container: HTMLElement, id: string, text: string): void {
42
+ const el = container.querySelector<HTMLElement>(id);
43
+ if (el) el.textContent = text;
44
+ }
45
+
46
+ function updateMetricCards(
47
+ container: HTMLElement,
48
+ results: MissionResults,
49
+ unitSystem: MissionInputs['unitSystem'],
50
+ resultsUI?: DroneMissionBatteryReservePlannerUI['results']
51
+ ): void {
52
+ const distUnit = unitSystem === 'imperial' ? 'mi' : 'km';
53
+ const displayRadius = convertDistance(results.maxSafeRadiusKm, unitSystem);
54
+ const powerText = resultsUI ? resultsUI.powerSubLabel : 'Power';
55
+ const sagText = resultsUI ? resultsUI.voltageSagSubLabel : 'Voltage sag';
56
+
57
+ setCardText(container, '#res-autonomy', `${results.totalAutonomyMinutes.toFixed(1)} min`);
58
+ setCardText(container, '#res-sag-loss', `${sagText}: -${results.voltageSagWhLoss.toFixed(1)} Wh`);
59
+ setCardText(container, '#res-max-radius', `${displayRadius.toFixed(1)} ${distUnit}`);
60
+ setCardText(container, '#res-outbound-time', `${results.outboundTimeMinutes.toFixed(1)} min`);
61
+ setCardText(container, '#res-outbound-power', `${powerText}: ${Math.round(results.outboundPowerWatts)} W`);
62
+ setCardText(container, '#res-return-time', `${results.returnTimeMinutes.toFixed(1)} min`);
63
+ setCardText(container, '#res-return-power', `${powerText}: ${Math.round(results.returnPowerWatts)} W`);
64
+ }
65
+
66
+ function updateTouchdownBar(container: HTMLElement, results: MissionResults): void {
67
+ const elLandingEnergy = container.querySelector<HTMLElement>('#res-landing-energy');
68
+ const elLandingBar = container.querySelector<HTMLElement>('#res-landing-bar');
69
+ const remWh = Math.max(0, results.remainingEnergyLandingWh);
70
+ const remPct = Math.max(0, results.remainingEnergyLandingPercent);
71
+
72
+ if (elLandingEnergy) elLandingEnergy.textContent = `${remPct.toFixed(1)}% (${remWh.toFixed(1)} Wh)`;
73
+ if (elLandingBar) {
74
+ elLandingBar.style.width = `${Math.min(100, remPct)}%`;
75
+ const colorMap: Record<string, string> = { optimal: '#10b981', tight: '#f59e0b', critical: '#ef4444', exceeded: '#ef4444' };
76
+ elLandingBar.style.backgroundColor = colorMap[results.statusKey] ?? '#10b981';
77
+ }
78
+ }
79
+
80
+ function bindDual(container: HTMLElement, sliderId: string, inputId: string, onChange: (v: number) => void): void {
81
+ const slider = container.querySelector<HTMLInputElement>(sliderId);
82
+ const numInput = container.querySelector<HTMLInputElement>(inputId);
83
+ slider?.addEventListener('input', () => { const v = parseFloat(slider.value) || 0; if (numInput) numInput.value = String(v); onChange(v); });
84
+ numInput?.addEventListener('input', () => { const v = parseFloat(numInput.value) || 0; if (slider) slider.value = String(v); onChange(v); });
85
+ }
86
+
87
+ function bindAllInputs(container: HTMLElement, state: MissionInputs, update: () => void): void {
88
+ bindDual(container, '#slider-capacity', '#input-capacity', (v) => { state.batteryCapacityMah = Math.max(100, v); update(); });
89
+ bindDual(container, '#slider-voltage', '#input-voltage', (v) => { state.voltageNominal = Math.max(1, v); update(); });
90
+ bindDual(container, '#slider-current', '#input-current', (v) => { state.averageCurrentAmps = Math.max(0.1, v); update(); });
91
+ bindDual(container, '#slider-speed', '#input-speed', (v) => { state.cruiseSpeedKmh = state.unitSystem === 'imperial' ? Math.max(1, v) / 0.621371 : Math.max(1, v); update(); });
92
+ bindDual(container, '#slider-distance', '#input-distance', (v) => { state.oneWayDistanceKm = state.unitSystem === 'imperial' ? Math.max(0.1, v) / 0.621371 : Math.max(0.1, v); update(); });
93
+ bindDual(container, '#slider-target-hover', '#input-target-hover', (v) => { state.targetHoverTimeMin = Math.max(0, v); update(); });
94
+ bindDual(container, '#slider-wind-speed', '#input-wind-speed', (v) => { state.windSpeedKmh = state.unitSystem === 'imperial' ? Math.max(0, v) / 0.621371 : Math.max(0, v); update(); });
95
+ bindDual(container, '#slider-reserve', '#input-reserve', (v) => { state.reservePolicyPercent = Math.min(50, Math.max(5, v)); update(); });
96
+ }
97
+
98
+ export function bindPlannerController(
99
+ container: HTMLElement,
100
+ initialInputs: MissionInputs,
101
+ uiContent?: DroneMissionBatteryReservePlannerUI
102
+ ): void {
103
+ const state: MissionInputs = { ...initialInputs };
104
+ const update = () => {
105
+ const results = calculateMissionReserve(state);
106
+ saveInputs(state);
107
+ const speedUnit = state.unitSystem === 'imperial' ? 'mph' : 'km/h';
108
+ const distUnit = state.unitSystem === 'imperial' ? 'mi' : 'km';
109
+ setElementsText(container, { speedUnit, distUnit });
110
+ updateStatusBanner(container, results.statusKey, uiContent?.statusBadges);
111
+ updateMetricCards(container, results, state.unitSystem, uiContent?.results);
112
+ updateTouchdownBar(container, results);
113
+ const svgWrapper = container.querySelector<HTMLElement>('#res-svg-wrapper');
114
+ if (svgWrapper) svgWrapper.innerHTML = renderTelemetrySvg(state, results, uiContent);
115
+ };
116
+ bindAllInputs(container, state, update);
117
+ container.querySelectorAll<HTMLButtonElement>('.wind-seg-btn').forEach((btn) => {
118
+ btn.addEventListener('click', () => {
119
+ const dir = btn.dataset.dir as MissionInputs['windDirection'];
120
+ if (dir) { state.windDirection = dir; container.querySelectorAll('.wind-seg-btn').forEach((b) => b.classList.toggle('active', b === btn)); update(); }
121
+ });
122
+ });
123
+ update();
124
+ }
@@ -0,0 +1,114 @@
1
+ import type { MissionInputs, MissionResults } from './logic';
2
+ import { convertDistance } from './logic';
3
+ import type { DroneMissionBatteryReservePlannerUI } from './ui';
4
+
5
+ function getWindArrowRotation(dir: MissionInputs['windDirection']): number {
6
+ if (dir === 'headwind') return 180;
7
+ if (dir === 'tailwind') return 0;
8
+ return 90;
9
+ }
10
+
11
+ function renderWindBanner(inputs: MissionInputs, speedUnit: string, chartUI?: DroneMissionBatteryReservePlannerUI['chart']): string {
12
+ const rot = getWindArrowRotation(inputs.windDirection);
13
+ const titleText = chartUI ? chartUI.modelTitle : 'AERODYNAMIC POWER & WIND MODEL';
14
+ const windText = chartUI ? chartUI.windLabel : 'Wind';
15
+
16
+ return `<g transform="translate(40, 30)">
17
+ <rect x="0" y="0" width="520" height="42" rx="8" fill="var(--n-surface-card, #151d30)" stroke="var(--n-border, #2e3d5c)" stroke-dasharray="3,3" />
18
+ <text x="16" y="26" font-size="11" font-weight="700" fill="var(--n-text-primary, #f8fafc)" letter-spacing="0.05em">${titleText}</text>
19
+ <g transform="translate(350, 26)">
20
+ <text x="0" y="0" font-size="10" fill="var(--n-text-secondary, #94a3b8)">${windText}: <tspan font-weight="700" fill="var(--n-text-primary, #f8fafc)">${inputs.windSpeedKmh} ${speedUnit}</tspan> (${inputs.windDirection})</text>
21
+ </g>
22
+ <g transform="rotate(${rot} 300 35)"><path d="M295 35 L300 27 L305 35 L301 35 L301 43 L299 43 L299 35 Z" fill="var(--n-brand, #3b82f6)" /></g>
23
+ </g>`;
24
+ }
25
+
26
+ function renderDiagramNodes(c?: DroneMissionBatteryReservePlannerUI['chart']): string {
27
+ const home = c ? c.homeNode : 'HOME';
28
+ const target = c ? c.targetNode : 'TARGET';
29
+ const land = c ? c.landNode : 'LAND';
30
+ return `<circle cx="40" cy="30" r="18" fill="#151d30" stroke="#3b82f6" stroke-width="3" />
31
+ <text x="40" y="34" font-size="10" font-weight="800" text-anchor="middle" fill="#ffffff">${home}</text>
32
+ <circle cx="260" cy="30" r="18" fill="#151d30" stroke="#06b6d4" stroke-width="3" />
33
+ <text x="260" y="34" font-size="10" font-weight="800" text-anchor="middle" fill="#ffffff">${target}</text>
34
+ <circle cx="480" cy="30" r="18" fill="#151d30" stroke="#10b981" stroke-width="3" />
35
+ <text x="480" y="34" font-size="10" font-weight="800" text-anchor="middle" fill="#ffffff">${land}</text>`;
36
+ }
37
+
38
+ function renderFlightDiagram(
39
+ inputs: MissionInputs,
40
+ results: MissionResults,
41
+ distUnit: string,
42
+ c?: DroneMissionBatteryReservePlannerUI['chart']
43
+ ): string {
44
+ const safeRad = convertDistance(results.maxSafeRadiusKm, inputs.unitSystem).toFixed(1);
45
+ const curDist = convertDistance(inputs.oneWayDistanceKm, inputs.unitSystem).toFixed(1);
46
+ const launchPad = c ? c.launchPadLabel : 'Launch Pad';
47
+ const surveyHover = c ? c.surveyHoverLabel : 'Survey Hover';
48
+ const safeRadius = c ? c.safeRadiusLabel : 'Safe Radius';
49
+
50
+ return `<g transform="translate(40, 95)">
51
+ <line x1="40" y1="30" x2="480" y2="30" stroke="var(--n-border, #334155)" stroke-width="3" stroke-dasharray="6,4" />
52
+ <line x1="40" y1="30" x2="260" y2="30" stroke="url(#outboundGrad)" stroke-width="5" stroke-linecap="round" />
53
+ <line x1="260" y1="30" x2="480" y2="30" stroke="url(#returnGrad)" stroke-width="5" stroke-linecap="round" />
54
+ ${renderDiagramNodes(c)}
55
+ <text x="40" y="65" font-size="11" font-weight="600" text-anchor="middle" fill="var(--n-text-primary, #f8fafc)">${launchPad}</text>
56
+ <text x="40" y="78" font-size="10" text-anchor="middle" fill="var(--n-text-secondary, #94a3b8)">0.0 ${distUnit}</text>
57
+ <text x="260" y="65" font-size="11" font-weight="600" text-anchor="middle" fill="var(--n-text-primary, #f8fafc)">${surveyHover}</text>
58
+ <text x="260" y="78" font-size="10" text-anchor="middle" fill="#06b6d4">${inputs.targetHoverTimeMin} min @ ${curDist} ${distUnit}</text>
59
+ <text x="480" y="65" font-size="11" font-weight="600" text-anchor="middle" fill="var(--n-text-primary, #f8fafc)">${safeRadius}</text>
60
+ <text x="480" y="78" font-size="10" text-anchor="middle" fill="#10b981">${safeRad} ${distUnit}</text>
61
+ </g>`;
62
+ }
63
+
64
+ function renderEnergyLegend(r: MissionResults, c?: DroneMissionBatteryReservePlannerUI['chart']): string {
65
+ const o = c ? c.outboundSegment : 'Outbound';
66
+ const t = c ? c.targetSegment : 'Hover';
67
+ const ret = c ? c.returnSegment : 'Return';
68
+ const res = c ? c.reserveSegment : 'Reserve';
69
+
70
+ return `<g transform="translate(0, 48)">
71
+ <rect x="0" y="0" width="8" height="8" rx="2" fill="#3b82f6" /><text x="12" y="8" font-size="9" font-weight="600" fill="var(--n-text-primary, #f8fafc)">${o} (${r.outboundEnergyWh.toFixed(1)}Wh)</text>
72
+ <rect x="130" y="0" width="8" height="8" rx="2" fill="#06b6d4" /><text x="142" y="8" font-size="9" font-weight="600" fill="var(--n-text-primary, #f8fafc)">${t} (${r.targetHoverEnergyWh.toFixed(1)}Wh)</text>
73
+ <rect x="240" y="0" width="8" height="8" rx="2" fill="#8b5cf6" /><text x="252" y="8" font-size="9" font-weight="600" fill="var(--n-text-primary, #f8fafc)">${ret} (${r.returnEnergyWh.toFixed(1)}Wh)</text>
74
+ <rect x="360" y="0" width="8" height="8" rx="2" fill="#f59e0b" /><text x="372" y="8" font-size="9" font-weight="600" fill="var(--n-text-primary, #f8fafc)">${res} (${r.reserveEnergyWh.toFixed(1)}Wh)</text>
75
+ </g>`;
76
+ }
77
+
78
+ function renderEnergyAllocation(results: MissionResults, c?: DroneMissionBatteryReservePlannerUI['chart']): string {
79
+ const tot = Math.max(1, results.totalEnergyWh);
80
+ const outW = Math.max(2, ((results.outboundEnergyWh / tot)) * 520);
81
+ const hovW = Math.max(2, ((results.targetHoverEnergyWh / tot)) * 520);
82
+ const retW = Math.max(2, ((results.returnEnergyWh / tot)) * 520);
83
+ const resW = Math.max(2, ((results.reserveEnergyWh / tot)) * 520);
84
+ const title = c ? c.batteryProfileTitle : 'NON-LINEAR ENERGY ALLOCATION PROFILE';
85
+
86
+ return `<g transform="translate(40, 205)">
87
+ <text x="0" y="0" font-size="11" font-weight="700" fill="var(--n-text-secondary, #94a3b8)" letter-spacing="0.04em">${title}</text>
88
+ <rect x="0" y="10" width="520" height="24" rx="8" fill="#151d30" stroke="var(--n-border, #1e293b)" />
89
+ <rect x="2" y="12" width="${Math.min(516, outW)}" height="20" rx="4" fill="url(#outboundGrad)" />
90
+ <rect x="${2 + outW}" y="12" width="${Math.min(516, hovW)}" height="20" rx="4" fill="url(#hoverGrad)" />
91
+ <rect x="${2 + outW + hovW}" y="12" width="${Math.min(516, retW)}" height="20" rx="4" fill="url(#returnGrad)" />
92
+ <rect x="${518 - resW}" y="12" width="${Math.min(516, resW)}" height="20" rx="4" fill="url(#reserveGrad)" />
93
+ ${renderEnergyLegend(results, c)}
94
+ </g>`;
95
+ }
96
+
97
+ export function renderTelemetrySvg(inputs: MissionInputs, results: MissionResults, uiContent?: DroneMissionBatteryReservePlannerUI): string {
98
+ const distUnit = inputs.unitSystem === 'imperial' ? 'mi' : 'km';
99
+ const speedUnit = inputs.unitSystem === 'imperial' ? 'mph' : 'km/h';
100
+ const c = uiContent?.chart;
101
+
102
+ return `<svg viewBox="0 0 600 290" class="mission-telemetry-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
103
+ <defs>
104
+ <linearGradient id="outboundGrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#3b82f6" /><stop offset="100%" stop-color="#2563eb" /></linearGradient>
105
+ <linearGradient id="hoverGrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#06b6d4" /><stop offset="100%" stop-color="#0891b2" /></linearGradient>
106
+ <linearGradient id="returnGrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#8b5cf6" /><stop offset="100%" stop-color="#7c3aed" /></linearGradient>
107
+ <linearGradient id="reserveGrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#f59e0b" /><stop offset="100%" stop-color="#d97706" /></linearGradient>
108
+ </defs>
109
+ <rect x="10" y="10" width="580" height="270" rx="16" fill="var(--n-surface-subtle, #0a0f1d)" stroke="var(--n-border, #1e293b)" stroke-width="1.5" />
110
+ ${renderWindBanner(inputs, speedUnit, c)}
111
+ ${renderFlightDiagram(inputs, results, distUnit, c)}
112
+ ${renderEnergyAllocation(results, c)}
113
+ </svg>`;
114
+ }