poe-svelte-ui-lib 1.86.0 → 1.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/WidgetMap/README.md +8 -1
- package/dist/WidgetMap/WidgetMap.svelte +41 -5
- package/dist/WidgetMap/mission/MapAgro.svelte +89 -22
- package/dist/WidgetMap/mission/MapAgro.svelte.d.ts +6 -0
- package/dist/WidgetMap/mission/MapRoute.svelte +32 -12
- package/dist/WidgetMap/mission/MapRoute.svelte.d.ts +6 -0
- package/dist/WidgetMap/mission/MissionSim.svelte +189 -0
- package/dist/WidgetMap/mission/MissionSim.svelte.d.ts +16 -0
- package/dist/WidgetMap/mission/SpinnerIcon.svelte +10 -0
- package/dist/WidgetMap/mission/SpinnerIcon.svelte.d.ts +18 -0
- package/dist/WidgetMap/mission/coverage.d.ts +9 -0
- package/dist/WidgetMap/mission/coverage.js +61 -2
- package/dist/WidgetMap/mission/plan.js +2 -2
- package/dist/WidgetMap/mission/progress.d.ts +15 -0
- package/dist/WidgetMap/mission/progress.js +39 -0
- package/dist/WidgetMap/mission/route.d.ts +7 -0
- package/dist/WidgetMap/mission/route.js +11 -4
- package/dist/locales/translations.js +22 -6
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/WidgetMap/README.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
| `Sat` | Число спутников |
|
|
25
25
|
| `DevName` | Имя объекта, подпись маркера. Без него подписью будет имя группы |
|
|
26
26
|
| `DevSN` | Серийный номер |
|
|
27
|
+
| `WPNum` | Номер текущей команды миссии. Нужен только режимам «Маршрут» и «Агро» для прогресса |
|
|
27
28
|
|
|
28
29
|
Имена ключей общие с `WidgetPFD` и `WidgetDeviceInfo`. Одна отправка телеметрии кормит и карту, и пилотажный дисплей.
|
|
29
30
|
|
|
@@ -64,6 +65,8 @@ Telemetry_SendObject(g_telemetry_stream, g_mcfg.DevSN, "Data", dev, sizeof(dev)
|
|
|
64
65
|
|
|
65
66
|
`isTest` включает имитацию шести аппаратов `SIM-1`-`SIM-6` без устройства. Они летают над одним районом Минска, в пределах пары километров, и обновляются раз в полсекунды. На связи всегда три аппарата. Через 40-100 секунд один из них пропадает, и его сразу сменяет тот, кто молчит дольше всех. Поэтому видно все состояния маркера: свежий, красный устаревший, скрытый и снова появившийся. При включении карта переходит к этому району. Пока режим включён, `value` и `groups` не учитываются. В конструкторе все остальные свойства становятся бледными и недоступными, пока имитацию не выключат.
|
|
66
67
|
|
|
68
|
+
|
|
69
|
+
В режимах «Маршрут» и «Агро» `isTest` включает имитацию полёта по рассчитанной траектории. Аппарат летит по маршруту со скоростями его точек или по проходам «Агро» и оставляет голубой след. В «Агро» маркер — квадрокоптер, он зелёный, пока идёт распыление. Панель поверх карты показывает время полёта, процент пройденного пути, высоту, скорость и распыление; там же запуск и пауза, ускорение ×1, ×5, ×10 или ×25 (по умолчанию ×5) и полоса прокрутки пути: её перетаскивание ставит полёт на паузу, чтобы рассмотреть любое место траектории. Полёт запускается вручную кнопкой ▶ и останавливается в конце пути; повторный запуск начинает его заново. При любом изменении маршрута или полей аппарат возвращается в начало и встаёт на паузу. Аппараты из `groups` в это время не рисуются.
|
|
67
70
|
## Режим «Маршрут»
|
|
68
71
|
|
|
69
72
|
`mode="route"` — обычный планировщик миссии: точки по порядку облёта, у каждой своя высота и скорость. Аппараты из `groups` рисуются поверх маршрута, как в режиме «Объекты».
|
|
@@ -82,6 +85,10 @@ Telemetry_SendObject(g_telemetry_stream, g_mcfg.DevSN, "Data", dev, sizeof(dev)
|
|
|
82
85
|
| `points` | Точки `{ point: [lng, lat], alt, speed }`: высота в метрах над точкой взлёта, скорость на пути к точке в м/с |
|
|
83
86
|
| `returnHome` | Вернуться и сесть в конце (RTL) |
|
|
84
87
|
|
|
88
|
+
### Прогресс в шапке
|
|
89
|
+
|
|
90
|
+
В «Маршруте» и «Агро» шапка показывает полоску и процент выполнения миссии. В режиме имитации это прогресс имитации. Без имитации прогресс считается по телеметрии первой группы карты: `WPNum` (номер команды миссии, которую выполняет автопилот) и положение аппарата. Если текущая команда служебная (скорость, распылитель), целью считается следующая точка. Внутри отрезка учитывается, сколько аппарат уже пролетел к цели. В «Маршруте» рядом видно «к точке 3/6» или «возврат», в «Агро» — сколько гектаров обработано. Прогресс верен, только если на борту та же миссия, что построена на карте. Без `WPNum` полоски нет.
|
|
91
|
+
|
|
85
92
|
## Режим «Агро»
|
|
86
93
|
|
|
87
94
|
`mode="agro"` превращает карту в генератор миссии для агродрона. Оператор обводит на карте одно или несколько полей и запретные зоны, задаёт угол проходов и ширину захвата. Карта строит траекторию, которая покрывает все поля, обходит зоны и выключает распылитель на разворотах и переходах. Готовую миссию можно скачать, скопировать или отправить на борт в формате QGC WPL 110 для автопилота ArduPilot.
|
|
@@ -93,7 +100,7 @@ Telemetry_SendObject(g_telemetry_stream, g_mcfg.DevSN, "Data", dev, sizeof(dev)
|
|
|
93
100
|
1. **«Нарисовать поле»**: кликами по карте обведите участок. Контур замыкается кликом по первой точке, двойным кликом, Enter или кнопкой «Замкнуть». Backspace убирает последнюю точку, Esc сбрасывает начатый контур. Когда поле есть, кнопка называется **«Поля»**: в этом режиме клики по карте обводят ещё одно поле.
|
|
94
101
|
2. **«Запретные зоны»**: обведите места, где нельзя лететь и распылять: деревья, опоры ЛЭП, постройки, водоёмы. Зон может быть сколько угодно.
|
|
95
102
|
3. **Точка взлёта** (синяя) видна всегда, её можно перетащить в любой момент.
|
|
96
|
-
4. В блоке **«Маршрут и внесение»** задайте ширину захвата, отступ от границ, высоту, скорость, норму внесения и объём бака. У каждого поля свой угол проходов: новое поле сразу получает лучший угол, «Подобрать» пересчитывает углы всех
|
|
103
|
+
4. В блоке **«Маршрут и внесение»** задайте ширину захвата, отступ от границ, высоту, скорость, норму внесения и объём бака. У каждого поля свой угол проходов: новое поле сразу получает лучший угол, «Подобрать» пересчитывает углы всех полей приближениями по полному расчёту траектории: шаг 5° по всем направлениям и вдоль сторон поля, затем 1° около трёх лучших, затем 0,1° около победителя; лучший угол даёт наименьшее время облёта с учётом разворотов. Пока идёт подбор, на кнопке и карте видно колесо ожидания с процентом (на большом поле это несколько секунд), а ползунок «Угол для всех полей» ставит всем один угол, например чтобы проходы шли вдоль рядов посадки. Угол отдельного поля правится в его строке в блоке «Итог».
|
|
97
104
|
5. Блок **«Итог»** показывает контуры, площадь к обработке, покрытие, длину пути, время, расход раствора и число дозаправок.
|
|
98
105
|
6. **«Скачать»**, **«Копировать»** или **«Отправить»** миссию.
|
|
99
106
|
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import FitIcon from "./FitIcon.svelte"
|
|
16
16
|
import MapAgro from "./mission/MapAgro.svelte"
|
|
17
17
|
import MapRoute from "./mission/MapRoute.svelte"
|
|
18
|
+
import { DEFAULT_MISSION_PLAN } from "./mission/plan"
|
|
19
|
+
import { DEFAULT_ROUTE_PLAN } from "./mission/route"
|
|
18
20
|
import * as UI from ".."
|
|
19
21
|
import { MapLibre, NavigationControl, ScaleControl, GeolocateControl, FullScreenControl, Marker, Popup, Line, CustomControl } from "./mapWrapper"
|
|
20
22
|
|
|
@@ -33,9 +35,10 @@
|
|
|
33
35
|
collapsed = $bindable(false),
|
|
34
36
|
persistKey,
|
|
35
37
|
mode = "objects",
|
|
36
|
-
|
|
38
|
+
/* Значения по умолчанию обязательны: режимы получают их через bind:, а bind к undefined при запасном значении Svelte запрещает */
|
|
39
|
+
plan = $bindable(structuredClone(DEFAULT_MISSION_PLAN)),
|
|
37
40
|
onPlanChange,
|
|
38
|
-
route = $bindable(),
|
|
41
|
+
route = $bindable(structuredClone(DEFAULT_ROUTE_PLAN)),
|
|
39
42
|
onRouteChange,
|
|
40
43
|
onSend,
|
|
41
44
|
}: IWidgetMapProps = $props()
|
|
@@ -97,7 +100,8 @@
|
|
|
97
100
|
const randomSec = (min: number, max: number) => (min + Math.random() * (max - min)) * 1000
|
|
98
101
|
let simValue: Record<string, Partial<IWidgetMapData>> | undefined = $state()
|
|
99
102
|
$effect(() => {
|
|
100
|
-
|
|
103
|
+
/* В «Маршруте» и «Агро» имитация — полёт по своей траектории, аппараты SIM не нужны */
|
|
104
|
+
if (!isTest || mode !== "objects") return
|
|
101
105
|
/* На связи всегда три аппарата: когда один пропадает, его сменяет тот, кто молчит дольше всех; молчащий маркер успевает покраснеть и скрыться */
|
|
102
106
|
const fleet = SIM_GROUPS.map((group, i) => ({
|
|
103
107
|
group,
|
|
@@ -143,6 +147,12 @@
|
|
|
143
147
|
})
|
|
144
148
|
let value = $derived(isTest ? simValue : deviceValue)
|
|
145
149
|
let groupList = $derived(isTest ? SIM_GROUPS : groups.map((g) => g.trim()).filter(Boolean))
|
|
150
|
+
/* Аппарат для прогресса миссии в «Маршруте» и «Агро»: первая группа, положение и номер текущей команды */
|
|
151
|
+
let missionVehicle = $derived.by(() => {
|
|
152
|
+
const d = deviceValue?.[groupList[0]]
|
|
153
|
+
if (isTest || !d || !d.Lat || !d.Lon) return undefined
|
|
154
|
+
return { point: [d.Lon, d.Lat] as [number, number], wpNum: d.WPNum }
|
|
155
|
+
})
|
|
146
156
|
let boards: Board[] = $state([])
|
|
147
157
|
let now = $state(Date.now())
|
|
148
158
|
let mapInstance: any = $state()
|
|
@@ -446,9 +456,35 @@
|
|
|
446
456
|
|
|
447
457
|
<!-- Режим «Агро» — генератор миссии для агродрона, «Маршрут» — обычный планировщик миссии, остальное — карта объектов -->
|
|
448
458
|
{#if mode === "agro"}
|
|
449
|
-
<MapAgro
|
|
459
|
+
<MapAgro
|
|
460
|
+
{wrapperClass}
|
|
461
|
+
{componentClass}
|
|
462
|
+
{label}
|
|
463
|
+
height={height ?? 520}
|
|
464
|
+
bind:plan
|
|
465
|
+
bind:collapsed
|
|
466
|
+
{persistKey}
|
|
467
|
+
{onPlanChange}
|
|
468
|
+
{onSend}
|
|
469
|
+
overlay={isTest ? undefined : vehicles}
|
|
470
|
+
simulate={isTest}
|
|
471
|
+
vehicle={missionVehicle}
|
|
472
|
+
/>
|
|
450
473
|
{:else if mode === "route"}
|
|
451
|
-
<MapRoute
|
|
474
|
+
<MapRoute
|
|
475
|
+
{wrapperClass}
|
|
476
|
+
{componentClass}
|
|
477
|
+
{label}
|
|
478
|
+
height={height ?? 520}
|
|
479
|
+
bind:route
|
|
480
|
+
bind:collapsed
|
|
481
|
+
{persistKey}
|
|
482
|
+
{onRouteChange}
|
|
483
|
+
{onSend}
|
|
484
|
+
overlay={isTest ? undefined : vehicles}
|
|
485
|
+
simulate={isTest}
|
|
486
|
+
vehicle={missionVehicle}
|
|
487
|
+
/>
|
|
452
488
|
{:else}
|
|
453
489
|
<div
|
|
454
490
|
bind:this={rootEl}
|
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
import * as UI from "../.."
|
|
12
12
|
import { MapLibre, NavigationControl, ScaleControl, FullScreenControl } from "../mapWrapper"
|
|
13
13
|
import MissionLayers from "./MissionLayers.svelte"
|
|
14
|
+
import MissionSim from "./MissionSim.svelte"
|
|
15
|
+
import SpinnerIcon from "./SpinnerIcon.svelte"
|
|
16
|
+
import { missionProgress } from "./progress"
|
|
14
17
|
import MissionIcon from "./MissionIcon.svelte"
|
|
15
18
|
import ArrowIcon from "../../libIcons/ArrowIcon.svelte"
|
|
16
|
-
import { planFields, bestFieldAngle,
|
|
19
|
+
import { planFields, bestFieldAngle, searchFieldAngle, toMission, fieldArea, fieldsArea, type LngLat, type SprayControl } from "./coverage"
|
|
17
20
|
import { DEFAULT_MISSION_PLAN, type IMissionPlan } from "./plan"
|
|
18
21
|
|
|
19
22
|
/* Режим «Агро» карты: часть пропсов WidgetMap */
|
|
@@ -26,6 +29,8 @@
|
|
|
26
29
|
collapsed?: boolean
|
|
27
30
|
persistKey?: string
|
|
28
31
|
overlay?: Snippet /* Аппараты карты поверх миссии */
|
|
32
|
+
simulate?: boolean /* Имитация полёта по рассчитанной траектории */
|
|
33
|
+
vehicle?: { point: LngLat; wpNum?: number } /* Аппарат из первой группы карты: положение и номер текущей команды миссии */
|
|
29
34
|
onPlanChange?: (plan: IMissionPlan) => void
|
|
30
35
|
onSend?: (mission: string, plan: IMissionPlan) => void | Promise<void>
|
|
31
36
|
}
|
|
@@ -35,10 +40,12 @@
|
|
|
35
40
|
componentClass = "",
|
|
36
41
|
label = {},
|
|
37
42
|
height = 520,
|
|
38
|
-
plan = $bindable(
|
|
43
|
+
plan = $bindable(structuredClone(DEFAULT_MISSION_PLAN)),
|
|
39
44
|
collapsed = $bindable(false),
|
|
40
45
|
persistKey,
|
|
41
46
|
overlay,
|
|
47
|
+
simulate = false,
|
|
48
|
+
vehicle,
|
|
42
49
|
onPlanChange,
|
|
43
50
|
onSend,
|
|
44
51
|
}: Props = $props()
|
|
@@ -89,11 +96,11 @@
|
|
|
89
96
|
}
|
|
90
97
|
const finishDraw = () => {
|
|
91
98
|
if (draft.length >= 3) {
|
|
92
|
-
if (mode === "field")
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
if (mode === "field") {
|
|
100
|
+
const contour = draft
|
|
101
|
+
update({ fields: [...fieldList, { contour, angle: bestFieldAngle(contour, plan.holes, plan.spacing, plan.margin) }], start: plan.start ?? contour[0] })
|
|
102
|
+
searchAngles([contour])
|
|
103
|
+
}
|
|
97
104
|
if (mode === "hole") update({ holes: [...plan.holes, draft] })
|
|
98
105
|
}
|
|
99
106
|
draft = []
|
|
@@ -183,9 +190,22 @@
|
|
|
183
190
|
}),
|
|
184
191
|
)
|
|
185
192
|
|
|
186
|
-
|
|
187
|
-
|
|
193
|
+
/* Подбор угла полным расчётом приближениями (searchFieldAngle): поля по очереди, угол поля обновляется сразу после его подбора; на кнопке и карте колесо ожидания с процентом */
|
|
194
|
+
let searching: number | null = $state(null)
|
|
195
|
+
const searchAngles = async (contours: LngLat[][]) => {
|
|
196
|
+
if (searching !== null || !contours.length) return
|
|
197
|
+
searching = 0
|
|
198
|
+
try {
|
|
199
|
+
for (const [k, contour] of contours.entries()) {
|
|
200
|
+
const angle = await searchFieldAngle(contour, plan.holes, plan.spacing, plan.margin, plan.speed, (f) => (searching = (k + f) / contours.length))
|
|
201
|
+
/* Контур могли изменить или удалить, пока шёл подбор — тогда угол не ставится */
|
|
202
|
+
if (fieldList.some((f) => f.contour === contour)) update({ fields: fieldList.map((f) => (f.contour === contour ? { ...f, angle } : f)) })
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
searching = null
|
|
206
|
+
}
|
|
188
207
|
}
|
|
208
|
+
const autoAngle = () => searchAngles(fieldList.filter((f) => f.contour.length >= 3).map((f) => f.contour))
|
|
189
209
|
|
|
190
210
|
let stats = $derived.by(() => {
|
|
191
211
|
const area = fieldsArea(fields, plan.holes)
|
|
@@ -210,6 +230,20 @@
|
|
|
210
230
|
}
|
|
211
231
|
})
|
|
212
232
|
|
|
233
|
+
/* Путь для имитации: точка взлёта, затем все отрезки траектории; распыление — на проходах */
|
|
234
|
+
let simProgress = $state(0)
|
|
235
|
+
let simPath = $derived.by(() => {
|
|
236
|
+
if (!result?.waypoints.length) return []
|
|
237
|
+
const out = [{ point: result.waypoints[0].point, alt: plan.alt, speed: plan.speed, spray: false }]
|
|
238
|
+
for (const seg of result.segments)
|
|
239
|
+
seg.points.forEach((pt, i) => {
|
|
240
|
+
const prev = out[out.length - 1].point
|
|
241
|
+
if (i === 0 && Math.abs(prev[0] - pt[0]) < 1e-9 && Math.abs(prev[1] - pt[1]) < 1e-9) return
|
|
242
|
+
out.push({ point: pt, alt: plan.alt, speed: plan.speed, spray: seg.kind === "spray" && i > 0 })
|
|
243
|
+
})
|
|
244
|
+
return out
|
|
245
|
+
})
|
|
246
|
+
|
|
213
247
|
/* ********************************************************** */
|
|
214
248
|
/* GeoJSON для слоёв */
|
|
215
249
|
|
|
@@ -283,13 +317,22 @@
|
|
|
283
317
|
}
|
|
284
318
|
})
|
|
285
319
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
320
|
+
/* Миссия и номера её точек: выгрузка и прогресс реального полёта по WPNum */
|
|
321
|
+
let mission = $derived(
|
|
322
|
+
result && hasField
|
|
323
|
+
? toMission(result, plan.start ?? fields[0][0], {
|
|
324
|
+
alt: plan.alt,
|
|
325
|
+
speed: plan.speed,
|
|
326
|
+
spray: plan.sprayControl,
|
|
327
|
+
servo: { channel: plan.servoChannel, on: plan.servoOn, off: plan.servoOff },
|
|
328
|
+
})
|
|
329
|
+
: null,
|
|
330
|
+
)
|
|
331
|
+
const missionText = () => mission?.text ?? ""
|
|
332
|
+
let live = $derived(mission && !simulate ? missionProgress(mission.nav, vehicle?.wpNum, vehicle?.point) : null)
|
|
333
|
+
/* Обработано: доля пройденных проходов от площади к обработке */
|
|
334
|
+
let treatedHa = $derived(live && stats && live.sprayTotal > 0 ? (stats.ha * live.sprayDone) / live.sprayTotal : 0)
|
|
335
|
+
let headerProgress = $derived(simulate ? simProgress : (live?.fraction ?? null))
|
|
293
336
|
const download = () => {
|
|
294
337
|
const text = missionText()
|
|
295
338
|
if (!text) return
|
|
@@ -392,11 +435,22 @@
|
|
|
392
435
|
>
|
|
393
436
|
<WidgetHeader icon={MissionIcon} label={label?.name || $T("widget.mission.title")} bind:collapsed {hints}>
|
|
394
437
|
{#snippet right()}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
438
|
+
<span class="flex items-center gap-3 text-[12px] font-semibold text-(--muted-color) tabular-nums">
|
|
439
|
+
{#if headerProgress !== null}
|
|
440
|
+
<span class="flex items-center gap-1.5" title={simulate ? $T("widget.sim.title") : $T("widget.mission.progressHint")}>
|
|
441
|
+
<span class="h-1.5 w-14 overflow-hidden rounded-full bg-(--hairline-color)"
|
|
442
|
+
><span class="block h-full rounded-full bg-(--accent-color)" style="width: {Math.round(headerProgress * 100)}%"></span></span
|
|
443
|
+
>
|
|
444
|
+
{Math.round(headerProgress * 100)}%{#if live}
|
|
445
|
+
· {$T("widget.mission.treated")} {fmt(treatedHa, 2)} {$T("widget.mission.ha")}{/if}
|
|
446
|
+
</span>
|
|
447
|
+
{/if}
|
|
448
|
+
{#if stats}
|
|
449
|
+
<span class="text-[12px] font-semibold text-(--muted-color) tabular-nums"
|
|
450
|
+
>{fmt(stats.ha, 2)} {$T("widget.mission.ha")} · {fmt(stats.total, 2)} {$T("widget.mission.km")}</span
|
|
451
|
+
>
|
|
452
|
+
{/if}
|
|
453
|
+
</span>
|
|
400
454
|
{/snippet}
|
|
401
455
|
</WidgetHeader>
|
|
402
456
|
|
|
@@ -477,6 +531,19 @@
|
|
|
477
531
|
onCursor={(ll) => (cursor = ll)}
|
|
478
532
|
/>
|
|
479
533
|
{@render overlay?.()}
|
|
534
|
+
{#if searching !== null}
|
|
535
|
+
<!-- Колесо ожидания поверх карты, пока подбирается угол -->
|
|
536
|
+
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
|
537
|
+
<div class="flex items-center gap-2 rounded-xl bg-white/90 px-4 py-2 text-[13px] font-semibold text-black shadow-lg tabular-nums">
|
|
538
|
+
<span class="size-6 text-(--accent-color)"><SpinnerIcon /></span>
|
|
539
|
+
{$T("widget.mission.searching")}
|
|
540
|
+
{Math.round(searching * 100)}%
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
{/if}
|
|
544
|
+
{#if simulate}
|
|
545
|
+
<MissionSim path={simPath} showSpray icon="quad" bind:progress={simProgress} />
|
|
546
|
+
{/if}
|
|
480
547
|
</MapLibre>
|
|
481
548
|
</div>
|
|
482
549
|
|
|
@@ -512,7 +579,7 @@
|
|
|
512
579
|
wrapperClass="w-auto"
|
|
513
580
|
componentClass="bg-blue"
|
|
514
581
|
content={{ name: $T("widget.mission.auto"), info: { text: $T("widget.mission.autoHint"), side: "top" } }}
|
|
515
|
-
disabled={!hasField}
|
|
582
|
+
disabled={!hasField || searching !== null}
|
|
516
583
|
onClick={autoAngle}
|
|
517
584
|
/>
|
|
518
585
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Snippet } from "svelte";
|
|
2
|
+
import { type LngLat } from "./coverage";
|
|
2
3
|
import { type IMissionPlan } from "./plan";
|
|
3
4
|
interface Props {
|
|
4
5
|
wrapperClass?: string;
|
|
@@ -11,6 +12,11 @@ interface Props {
|
|
|
11
12
|
collapsed?: boolean;
|
|
12
13
|
persistKey?: string;
|
|
13
14
|
overlay?: Snippet;
|
|
15
|
+
simulate?: boolean;
|
|
16
|
+
vehicle?: {
|
|
17
|
+
point: LngLat;
|
|
18
|
+
wpNum?: number;
|
|
19
|
+
};
|
|
14
20
|
onPlanChange?: (plan: IMissionPlan) => void;
|
|
15
21
|
onSend?: (mission: string, plan: IMissionPlan) => void | Promise<void>;
|
|
16
22
|
}
|
|
@@ -11,19 +11,12 @@
|
|
|
11
11
|
import * as UI from "../.."
|
|
12
12
|
import { MapLibre, NavigationControl, ScaleControl, FullScreenControl } from "../mapWrapper"
|
|
13
13
|
import MissionLayers from "./MissionLayers.svelte"
|
|
14
|
+
import MissionSim from "./MissionSim.svelte"
|
|
15
|
+
import { missionProgress } from "./progress"
|
|
14
16
|
import MapIcon from "../MapIcon.svelte"
|
|
15
17
|
import ArrowIcon from "../../libIcons/ArrowIcon.svelte"
|
|
16
18
|
import type { LngLat } from "./coverage"
|
|
17
|
-
import {
|
|
18
|
-
DEFAULT_ROUTE_PLAN,
|
|
19
|
-
ROUTE_FIRST_ALT,
|
|
20
|
-
ROUTE_FIRST_SPEED,
|
|
21
|
-
routeLegs,
|
|
22
|
-
routeStats,
|
|
23
|
-
routeToWaypointsFile,
|
|
24
|
-
type IRoutePlan,
|
|
25
|
-
type IRouteWaypoint,
|
|
26
|
-
} from "./route"
|
|
19
|
+
import { DEFAULT_ROUTE_PLAN, ROUTE_FIRST_ALT, ROUTE_FIRST_SPEED, routeLegs, routeStats, routeMission, type IRoutePlan, type IRouteWaypoint } from "./route"
|
|
27
20
|
|
|
28
21
|
/* Режим «Маршрут» карты: часть пропсов WidgetMap */
|
|
29
22
|
interface Props {
|
|
@@ -35,6 +28,8 @@
|
|
|
35
28
|
collapsed?: boolean
|
|
36
29
|
persistKey?: string
|
|
37
30
|
overlay?: Snippet /* Аппараты карты поверх миссии */
|
|
31
|
+
simulate?: boolean /* Имитация полёта по маршруту */
|
|
32
|
+
vehicle?: { point: LngLat; wpNum?: number } /* Аппарат из первой группы карты: положение и номер текущей команды миссии */
|
|
38
33
|
onRouteChange?: (route: IRoutePlan) => void
|
|
39
34
|
onSend?: (mission: string, route: IRoutePlan) => void | Promise<void>
|
|
40
35
|
}
|
|
@@ -48,6 +43,8 @@
|
|
|
48
43
|
collapsed = $bindable(false),
|
|
49
44
|
persistKey,
|
|
50
45
|
overlay,
|
|
46
|
+
simulate = false,
|
|
47
|
+
vehicle,
|
|
51
48
|
onRouteChange,
|
|
52
49
|
onSend,
|
|
53
50
|
}: Props = $props()
|
|
@@ -127,6 +124,7 @@
|
|
|
127
124
|
/* Расчёт и GeoJSON */
|
|
128
125
|
|
|
129
126
|
let stats = $derived(routeStats(route))
|
|
127
|
+
let simProgress = $state(0)
|
|
130
128
|
let legs = $derived(routeLegs(route))
|
|
131
129
|
|
|
132
130
|
let pathGeo = $derived({
|
|
@@ -175,7 +173,11 @@
|
|
|
175
173
|
}
|
|
176
174
|
})
|
|
177
175
|
|
|
178
|
-
|
|
176
|
+
/* Миссия и номера её точек: выгрузка и прогресс реального полёта по WPNum */
|
|
177
|
+
let mission = $derived(routeMission(route))
|
|
178
|
+
const missionText = () => mission.text
|
|
179
|
+
let live = $derived(simulate ? null : missionProgress(mission.nav, vehicle?.wpNum, vehicle?.point))
|
|
180
|
+
let headerProgress = $derived(simulate ? simProgress : (live?.fraction ?? null))
|
|
179
181
|
const download = () => {
|
|
180
182
|
const text = missionText()
|
|
181
183
|
if (!text) return
|
|
@@ -261,7 +263,22 @@
|
|
|
261
263
|
>
|
|
262
264
|
<WidgetHeader icon={MapIcon} label={label?.name || $T("widget.route.title")} bind:collapsed {hints}>
|
|
263
265
|
{#snippet right()}
|
|
264
|
-
<span class="text-[12px] font-semibold text-(--muted-color) tabular-nums">
|
|
266
|
+
<span class="flex items-center gap-3 text-[12px] font-semibold text-(--muted-color) tabular-nums">
|
|
267
|
+
{#if headerProgress !== null}
|
|
268
|
+
<span class="flex items-center gap-1.5" title={simulate ? $T("widget.sim.title") : $T("widget.mission.progressHint")}>
|
|
269
|
+
<span class="h-1.5 w-14 overflow-hidden rounded-full bg-(--hairline-color)"
|
|
270
|
+
><span class="block h-full rounded-full bg-(--accent-color)" style="width: {Math.round(headerProgress * 100)}%"></span></span
|
|
271
|
+
>
|
|
272
|
+
{Math.round(headerProgress * 100)}%{#if live}
|
|
273
|
+
· {live.finished
|
|
274
|
+
? $T("widget.route.finished")
|
|
275
|
+
: live.target > route.points.length
|
|
276
|
+
? $T("widget.route.returning")
|
|
277
|
+
: `${$T("widget.route.toPoint")} ${live.target}/${route.points.length}`}{/if}
|
|
278
|
+
</span>
|
|
279
|
+
{/if}
|
|
280
|
+
<span>{stats.points} {$T("widget.route.pts")} · {km(stats.length)}</span>
|
|
281
|
+
</span>
|
|
265
282
|
{/snippet}
|
|
266
283
|
</WidgetHeader>
|
|
267
284
|
|
|
@@ -319,6 +336,9 @@
|
|
|
319
336
|
onCursor={() => {}}
|
|
320
337
|
/>
|
|
321
338
|
{@render overlay?.()}
|
|
339
|
+
{#if simulate}
|
|
340
|
+
<MissionSim path={legs.nodes} bind:progress={simProgress} />
|
|
341
|
+
{/if}
|
|
322
342
|
</MapLibre>
|
|
323
343
|
</div>
|
|
324
344
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Snippet } from "svelte";
|
|
2
|
+
import type { LngLat } from "./coverage";
|
|
2
3
|
import { type IRoutePlan } from "./route";
|
|
3
4
|
interface Props {
|
|
4
5
|
wrapperClass?: string;
|
|
@@ -11,6 +12,11 @@ interface Props {
|
|
|
11
12
|
collapsed?: boolean;
|
|
12
13
|
persistKey?: string;
|
|
13
14
|
overlay?: Snippet;
|
|
15
|
+
simulate?: boolean;
|
|
16
|
+
vehicle?: {
|
|
17
|
+
point: LngLat;
|
|
18
|
+
wpNum?: number;
|
|
19
|
+
};
|
|
14
20
|
onRouteChange?: (route: IRoutePlan) => void;
|
|
15
21
|
onSend?: (mission: string, route: IRoutePlan) => void | Promise<void>;
|
|
16
22
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<!-- $lib/WidgetMap/mission/MissionSim.svelte — имитация полёта по рассчитанной траектории режимов «Маршрут» и «Агро»: аппарат летит по пути с его скоростями, оставляет след, распыление видно цветом; пауза, ускорение и перезапуск на панели поверх карты -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { onDestroy } from "svelte"
|
|
4
|
+
import { T } from "../../locales/i18n"
|
|
5
|
+
import { Marker, Line, CustomControl } from "../mapWrapper"
|
|
6
|
+
import type { LngLat } from "./coverage"
|
|
7
|
+
|
|
8
|
+
/* Вершина пути: speed и spray относятся к отрезку, который в неё приходит */
|
|
9
|
+
interface ISimVertex {
|
|
10
|
+
point: LngLat
|
|
11
|
+
alt: number
|
|
12
|
+
speed: number
|
|
13
|
+
spray?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* icon: arrow — стрелка, quad — квадрокоптер сверху */
|
|
17
|
+
let {
|
|
18
|
+
path,
|
|
19
|
+
showSpray = false,
|
|
20
|
+
icon = "arrow",
|
|
21
|
+
progress = $bindable(0),
|
|
22
|
+
}: { path: ISimVertex[]; showSpray?: boolean; icon?: "arrow" | "quad"; progress?: number /* Доля пройденного пути 0..1, для шапки */ } = $props()
|
|
23
|
+
|
|
24
|
+
const R = 6371000
|
|
25
|
+
const toRad = Math.PI / 180
|
|
26
|
+
const legLen = (a: LngLat, b: LngLat) => {
|
|
27
|
+
const dLat = (b[1] - a[1]) * toRad
|
|
28
|
+
const dLon = (b[0] - a[0]) * toRad
|
|
29
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[1] * toRad) * Math.cos(b[1] * toRad) * Math.sin(dLon / 2) ** 2
|
|
30
|
+
return 2 * R * Math.asin(Math.sqrt(h))
|
|
31
|
+
}
|
|
32
|
+
const bearing = (a: LngLat, b: LngLat) => {
|
|
33
|
+
const y = Math.sin((b[0] - a[0]) * toRad) * Math.cos(b[1] * toRad)
|
|
34
|
+
const x = Math.cos(a[1] * toRad) * Math.sin(b[1] * toRad) - Math.sin(a[1] * toRad) * Math.cos(b[1] * toRad) * Math.cos((b[0] - a[0]) * toRad)
|
|
35
|
+
return (Math.atan2(y, x) / toRad + 360) % 360
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Время прибытия в каждую вершину, с */
|
|
39
|
+
let times = $derived.by(() => {
|
|
40
|
+
const out = [0]
|
|
41
|
+
for (let i = 1; i < path.length; i++) {
|
|
42
|
+
const len = Math.hypot(legLen(path[i - 1].point, path[i].point), path[i].alt - path[i - 1].alt)
|
|
43
|
+
out.push(out[i - 1] + len / Math.max(0.1, path[i].speed))
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
})
|
|
47
|
+
let duration = $derived(times[times.length - 1] ?? 0)
|
|
48
|
+
|
|
49
|
+
let t = $state(0)
|
|
50
|
+
/* Запуск вручную; в конце пути полёт останавливается */
|
|
51
|
+
let playing = $state(false)
|
|
52
|
+
let rate = $state(5)
|
|
53
|
+
const RATES = [1, 5, 10, 25]
|
|
54
|
+
|
|
55
|
+
$effect(() => {
|
|
56
|
+
progress = duration > 0 ? t / duration : 0
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/* Новый путь — полёт заново */
|
|
60
|
+
$effect(() => {
|
|
61
|
+
path
|
|
62
|
+
t = 0
|
|
63
|
+
playing = false
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
let raf = 0
|
|
67
|
+
let last = 0
|
|
68
|
+
const tick = (now: number) => {
|
|
69
|
+
if (last && playing && duration > 0) {
|
|
70
|
+
t += ((now - last) / 1000) * rate
|
|
71
|
+
if (t >= duration) {
|
|
72
|
+
t = duration
|
|
73
|
+
playing = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
last = now
|
|
77
|
+
raf = requestAnimationFrame(tick)
|
|
78
|
+
}
|
|
79
|
+
raf = requestAnimationFrame(tick)
|
|
80
|
+
onDestroy(() => cancelAnimationFrame(raf))
|
|
81
|
+
|
|
82
|
+
/* Положение на пути в момент t */
|
|
83
|
+
let pos = $derived.by(() => {
|
|
84
|
+
if (path.length < 2) return null
|
|
85
|
+
let i = 1
|
|
86
|
+
while (i < times.length - 1 && times[i] < t) i++
|
|
87
|
+
const a = path[i - 1]
|
|
88
|
+
const b = path[i]
|
|
89
|
+
const span = times[i] - times[i - 1]
|
|
90
|
+
const k = span > 0 ? Math.min(1, Math.max(0, (t - times[i - 1]) / span)) : 1
|
|
91
|
+
const point: LngLat = [a.point[0] + (b.point[0] - a.point[0]) * k, a.point[1] + (b.point[1] - a.point[1]) * k]
|
|
92
|
+
return {
|
|
93
|
+
point,
|
|
94
|
+
alt: a.alt + (b.alt - a.alt) * k,
|
|
95
|
+
speed: b.speed,
|
|
96
|
+
spray: !!b.spray,
|
|
97
|
+
heading: bearing(a.point, b.point),
|
|
98
|
+
trail: [...path.slice(0, i).map((v) => v.point), point],
|
|
99
|
+
index: i,
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
let fill = $derived(showSpray && pos?.spray ? "#16a34a" : "#0ea5e9")
|
|
104
|
+
/* Кнопки-иконки без рамки: стили группы кнопок MapLibre перебиваются */
|
|
105
|
+
const iconBtn =
|
|
106
|
+
"flex h-7! w-7! cursor-pointer items-center justify-center rounded-md border-0! bg-transparent p-0 text-gray-700 hover:bg-black/5 hover:text-black"
|
|
107
|
+
/* Запуск после конца пути начинает полёт заново */
|
|
108
|
+
const togglePlay = () => {
|
|
109
|
+
if (!playing && t >= duration) t = 0
|
|
110
|
+
playing = !playing
|
|
111
|
+
}
|
|
112
|
+
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
{#if pos}
|
|
116
|
+
<Line id="msn-sim-trail" coordinates={pos.trail} color="#0ea5e9" width={4} opacity={0.6} />
|
|
117
|
+
<Marker lnglat={{ lng: pos.point[0], lat: pos.point[1] }}>
|
|
118
|
+
{#snippet content()}
|
|
119
|
+
<div class="flex size-9 items-center justify-center" style="rotate: {pos.heading}deg">
|
|
120
|
+
{#if icon === "quad"}
|
|
121
|
+
<svg viewBox="0 0 32 32" width="34" height="34">
|
|
122
|
+
<g stroke="#ffffff" stroke-width="1.2">
|
|
123
|
+
<path d="M9 9 L23 23 M23 9 L9 23" stroke={fill} stroke-width="2.5" stroke-linecap="round" />
|
|
124
|
+
{#each [[8, 8], [24, 8], [8, 24], [24, 24]] as [cx, cy]}
|
|
125
|
+
<circle {cx} {cy} r="5.5" {fill} fill-opacity="0.35" stroke={fill} stroke-width="1.5" />
|
|
126
|
+
{/each}
|
|
127
|
+
<rect x="12" y="12" width="8" height="8" rx="2" {fill} />
|
|
128
|
+
<path d="M16 4.5 L18.5 9 L13.5 9 Z" {fill} />
|
|
129
|
+
</g>
|
|
130
|
+
</svg>
|
|
131
|
+
{:else}
|
|
132
|
+
<svg viewBox="0 0 24 24" width="28" height="28">
|
|
133
|
+
<path d="M12 2 L19 20 L12 16 L5 20 Z" {fill} stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round" />
|
|
134
|
+
</svg>
|
|
135
|
+
{/if}
|
|
136
|
+
</div>
|
|
137
|
+
{/snippet}
|
|
138
|
+
</Marker>
|
|
139
|
+
|
|
140
|
+
<CustomControl position="top-left">
|
|
141
|
+
<div class="flex w-max max-w-[calc(100vw-6rem)] flex-col gap-1 px-3 py-1.5 text-[12px] whitespace-nowrap text-black tabular-nums">
|
|
142
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
143
|
+
<span class="font-semibold">{$T("widget.sim.title")}</span>
|
|
144
|
+
<button
|
|
145
|
+
class={iconBtn}
|
|
146
|
+
onclick={togglePlay}
|
|
147
|
+
title={playing ? $T("widget.sim.pause") : $T("widget.sim.play")}
|
|
148
|
+
aria-label={playing ? $T("widget.sim.pause") : $T("widget.sim.play")}
|
|
149
|
+
>
|
|
150
|
+
{#if playing}
|
|
151
|
+
<svg viewBox="0 0 24 24" width="18" height="18"
|
|
152
|
+
><rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor" /><rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor" /></svg
|
|
153
|
+
>
|
|
154
|
+
{:else}
|
|
155
|
+
<svg viewBox="0 0 24 24" width="18" height="18"
|
|
156
|
+
><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z" fill="currentColor" /></svg
|
|
157
|
+
>
|
|
158
|
+
{/if}
|
|
159
|
+
</button>
|
|
160
|
+
<select class="rounded px-1 py-0.5" bind:value={rate}>
|
|
161
|
+
{#each RATES as r}
|
|
162
|
+
<option value={r}>×{r}</option>
|
|
163
|
+
{/each}
|
|
164
|
+
</select>
|
|
165
|
+
<span>{clock(t)} / {clock(duration)} · {Math.round((t / Math.max(1, duration)) * 100)}%</span>
|
|
166
|
+
<span>{pos.alt.toFixed(0)} {$T("widget.mission.m")} · {pos.speed.toFixed(1)} {$T("widget.mission.ms")}</span>
|
|
167
|
+
{#if showSpray}
|
|
168
|
+
<span class="min-w-[7.5rem] font-semibold {pos.spray ? 'text-green-700' : 'text-gray-500'}"
|
|
169
|
+
>{pos.spray ? $T("widget.sim.sprayOn") : $T("widget.sim.sprayOff")}</span
|
|
170
|
+
>
|
|
171
|
+
{/if}
|
|
172
|
+
</div>
|
|
173
|
+
<!-- Прокрутка пути: перетаскивание ставит полёт на паузу, чтобы рассмотреть любое место траектории -->
|
|
174
|
+
<input
|
|
175
|
+
type="range"
|
|
176
|
+
class="w-full cursor-pointer accent-sky-500"
|
|
177
|
+
min="0"
|
|
178
|
+
max={duration}
|
|
179
|
+
step={Math.max(0.1, duration / 2000)}
|
|
180
|
+
value={t}
|
|
181
|
+
aria-label={$T("widget.sim.scrub")}
|
|
182
|
+
oninput={(e) => {
|
|
183
|
+
playing = false
|
|
184
|
+
t = Number(e.currentTarget.value)
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
</CustomControl>
|
|
189
|
+
{/if}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LngLat } from "./coverage";
|
|
2
|
+
interface ISimVertex {
|
|
3
|
+
point: LngLat;
|
|
4
|
+
alt: number;
|
|
5
|
+
speed: number;
|
|
6
|
+
spray?: boolean;
|
|
7
|
+
}
|
|
8
|
+
type $$ComponentProps = {
|
|
9
|
+
path: ISimVertex[];
|
|
10
|
+
showSpray?: boolean;
|
|
11
|
+
icon?: "arrow" | "quad";
|
|
12
|
+
progress?: number;
|
|
13
|
+
};
|
|
14
|
+
declare const MissionSim: import("svelte").Component<$$ComponentProps, {}, "progress">;
|
|
15
|
+
type MissionSim = ReturnType<typeof MissionSim>;
|
|
16
|
+
export default MissionSim;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!-- $lib/WidgetMap/mission/SpinnerIcon.svelte — колесо ожидания размером с родителя: подбор угла проходов -->
|
|
2
|
+
<script lang="ts"></script>
|
|
3
|
+
|
|
4
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24"
|
|
5
|
+
><path fill="currentColor" d="M12 2A10 10 0 1 0 22 12A10 10 0 0 0 12 2Zm0 18a8 8 0 1 1 8-8A8 8 0 0 1 12 20Z" opacity=".35" /><path
|
|
6
|
+
fill="currentColor"
|
|
7
|
+
d="M20 12h2A10 10 0 0 0 12 2V4A8 8 0 0 1 20 12Z"
|
|
8
|
+
><animateTransform attributeName="transform" dur="0.8s" from="0 12 12" repeatCount="indefinite" to="360 12 12" type="rotate" /></path
|
|
9
|
+
></svg
|
|
10
|
+
>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const SpinnerIcon: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type SpinnerIcon = InstanceType<typeof SpinnerIcon>;
|
|
18
|
+
export default SpinnerIcon;
|
|
@@ -44,6 +44,7 @@ export declare const holesOf: (field: LngLat[], holes: LngLat[][]) => LngLat[][]
|
|
|
44
44
|
export declare const fieldsArea: (fields: LngLat[][], holes: LngLat[][]) => number;
|
|
45
45
|
export declare const planFields: (input: IFieldsInput) => ICoverageResult | null;
|
|
46
46
|
export declare const bestFieldAngle: (contour: LngLat[], holes: LngLat[][], spacing: number, margin: number) => number;
|
|
47
|
+
export declare const searchFieldAngle: (contour: LngLat[], holes: LngLat[][], spacing: number, margin: number, speed: number, onProgress?: (fraction: number) => void) => Promise<number>;
|
|
47
48
|
export type SprayControl = "none" | "sprayer" | "servo";
|
|
48
49
|
export interface IMissionExport {
|
|
49
50
|
alt: number;
|
|
@@ -56,3 +57,11 @@ export interface IMissionExport {
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
export declare const toWaypointsFile: (res: ICoverageResult, home: LngLat, opt: IMissionExport) => string;
|
|
60
|
+
export declare const toMission: (res: ICoverageResult, home: LngLat, opt: IMissionExport) => {
|
|
61
|
+
text: string;
|
|
62
|
+
nav: {
|
|
63
|
+
point: LngLat;
|
|
64
|
+
seq: number;
|
|
65
|
+
spray?: boolean;
|
|
66
|
+
}[];
|
|
67
|
+
};
|
|
@@ -726,8 +726,64 @@ export const planFields = (input) => {
|
|
|
726
726
|
};
|
|
727
727
|
/* Лучший угол для одного поля с учётом его зон, округлён до 0.5° */
|
|
728
728
|
export const bestFieldAngle = (contour, holes, spacing, margin) => Math.round(optimizeAngle({ field: contour, holes: holesOf(contour, holes), angle: 0, spacing, margin }) * 2) / 2;
|
|
729
|
-
|
|
729
|
+
/* Подбор угла поля приближениями по полному расчёту траектории: шаг 5° по всем 180° и направления сторон поля, затем шаг 1° около трёх лучших, затем 0.1° около победителя. Мерило — время облёта: путь со скоростью плюс около 4 с на разворот, недопокрытая площадь штрафуется. Считается частями, чтобы интерфейс не замирал */
|
|
730
|
+
export const searchFieldAngle = async (contour, holes, spacing, margin, speed, onProgress) => {
|
|
731
|
+
const own = holesOf(contour, holes);
|
|
732
|
+
const area = fieldArea(contour, own);
|
|
733
|
+
const norm = (a) => Math.round((((a % 180) + 180) % 180) * 10) / 10;
|
|
734
|
+
const cache = new Map();
|
|
735
|
+
const cost = (a) => {
|
|
736
|
+
const key = norm(a);
|
|
737
|
+
const hit = cache.get(key);
|
|
738
|
+
if (hit !== undefined)
|
|
739
|
+
return hit;
|
|
740
|
+
const r = planCoverage({ field: contour, holes: own, angle: key, spacing, margin });
|
|
741
|
+
/* Недопокрытая площадь штрафуется: недолитый метр прохода стоит как трёхкратный полёт по нему — за ним пришлось бы возвращаться */
|
|
742
|
+
const missed = r ? Math.max(0, area / spacing - r.sprayLength) : 0;
|
|
743
|
+
const c = r ? (r.sprayLength + r.turnLength + r.transitLength + missed * 3) / Math.max(0.1, speed) + r.turns * 4 + r.unreachable * 1e6 : Infinity;
|
|
744
|
+
cache.set(key, c);
|
|
745
|
+
return c;
|
|
746
|
+
};
|
|
747
|
+
const yieldUi = () => new Promise((r) => setTimeout(r, 0));
|
|
748
|
+
/* Направления сторон поля: вдоль длинной стороны проходы часто ложатся лучше всего */
|
|
749
|
+
const kLon = Math.cos(((contour[0]?.[1] ?? 0) * Math.PI) / 180);
|
|
750
|
+
const edges = contour.map((p, i) => {
|
|
751
|
+
const q = contour[(i + 1) % contour.length];
|
|
752
|
+
return norm((Math.atan2((q[0] - p[0]) * kLon, q[1] - p[1]) * 180) / Math.PI);
|
|
753
|
+
});
|
|
754
|
+
const coarse = [...new Set([...Array.from({ length: 36 }, (_, i) => i * 5), ...edges])];
|
|
755
|
+
const stages = coarse.length + 3 * 9 + 11;
|
|
756
|
+
let done = 0;
|
|
757
|
+
const run = async (angles) => {
|
|
758
|
+
for (const a of angles) {
|
|
759
|
+
cost(a);
|
|
760
|
+
done++;
|
|
761
|
+
onProgress?.(Math.min(1, done / stages));
|
|
762
|
+
await yieldUi();
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
await run(coarse);
|
|
766
|
+
/* Три лучших диапазона, не ближе 5° друг к другу */
|
|
767
|
+
const ranked = [...coarse].sort((a, b) => cost(a) - cost(b));
|
|
768
|
+
const seeds = [];
|
|
769
|
+
for (const a of ranked) {
|
|
770
|
+
if (seeds.every((b) => Math.min(Math.abs(a - b), 180 - Math.abs(a - b)) >= 5))
|
|
771
|
+
seeds.push(a);
|
|
772
|
+
if (seeds.length === 3)
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
await run(seeds.flatMap((sd) => Array.from({ length: 9 }, (_, i) => norm(sd - 4 + i))));
|
|
776
|
+
let best = [...cache.entries()].reduce((m, e) => (e[1] < m[1] ? e : m))[0];
|
|
777
|
+
await run(Array.from({ length: 11 }, (_, i) => norm(best - 0.5 + i * 0.1)));
|
|
778
|
+
best = [...cache.entries()].reduce((m, e) => (e[1] < m[1] ? e : m))[0];
|
|
779
|
+
onProgress?.(1);
|
|
780
|
+
return best;
|
|
781
|
+
};
|
|
782
|
+
export const toWaypointsFile = (res, home, opt) => toMission(res, home, opt).text;
|
|
783
|
+
/* Миссия и номера команд её точек маршрута: по ним прогресс считается из WPNum телеметрии */
|
|
784
|
+
export const toMission = (res, home, opt) => {
|
|
730
785
|
const lines = ["QGC WPL 110"];
|
|
786
|
+
const nav = [];
|
|
731
787
|
let seq = 0;
|
|
732
788
|
const row = (current, frame, cmd, p, ll, alt) => lines.push([seq++, current, frame, cmd, ...p, ll[1].toFixed(8), ll[0].toFixed(8), alt.toFixed(2), 1].join("\t"));
|
|
733
789
|
const sprayRow = (on) => {
|
|
@@ -741,7 +797,10 @@ export const toWaypointsFile = (res, home, opt) => {
|
|
|
741
797
|
/* Перед полётом распыление явно выключено, чтобы не лить по дороге к полю */
|
|
742
798
|
sprayRow(false);
|
|
743
799
|
let spraying = false;
|
|
800
|
+
let sprayOnLeg = false;
|
|
744
801
|
for (const wp of res.waypoints) {
|
|
802
|
+
nav.push({ point: wp.point, seq, spray: sprayOnLeg });
|
|
803
|
+
sprayOnLeg = wp.spray;
|
|
745
804
|
row(0, 3, 16, [0, 0, 0, 0], wp.point, opt.alt);
|
|
746
805
|
if (wp.spray !== spraying) {
|
|
747
806
|
spraying = wp.spray;
|
|
@@ -750,5 +809,5 @@ export const toWaypointsFile = (res, home, opt) => {
|
|
|
750
809
|
}
|
|
751
810
|
if (spraying)
|
|
752
811
|
sprayRow(false);
|
|
753
|
-
return lines.join("\n") + "\n";
|
|
812
|
+
return { text: lines.join("\n") + "\n", nav };
|
|
754
813
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const DEFAULT_MISSION_PLAN = {
|
|
3
3
|
fields: [
|
|
4
4
|
{
|
|
5
|
-
angle:
|
|
5
|
+
angle: 22.7,
|
|
6
6
|
contour: [
|
|
7
7
|
[27.458515, 53.888433],
|
|
8
8
|
[27.458569, 53.888949],
|
|
@@ -23,7 +23,7 @@ export const DEFAULT_MISSION_PLAN = {
|
|
|
23
23
|
],
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
|
-
angle:
|
|
26
|
+
angle: 12.1,
|
|
27
27
|
contour: [
|
|
28
28
|
[27.463107, 53.888261],
|
|
29
29
|
[27.464298, 53.888191],
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LngLat } from "./coverage";
|
|
2
|
+
export interface IMissionNav {
|
|
3
|
+
point: LngLat;
|
|
4
|
+
seq: number;
|
|
5
|
+
spray?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface IMissionProgress {
|
|
8
|
+
fraction: number;
|
|
9
|
+
sprayDone: number;
|
|
10
|
+
sprayTotal: number;
|
|
11
|
+
target: number;
|
|
12
|
+
total: number;
|
|
13
|
+
finished: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare const missionProgress: (nav: IMissionNav[], wpNum: number | undefined, pos: LngLat | undefined) => IMissionProgress | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { distance } from "./route";
|
|
2
|
+
/* wpNum — номер команды, которую сейчас выполняет автопилот; если это служебная команда (скорость, распылитель), целью считается следующая точка */
|
|
3
|
+
export const missionProgress = (nav, wpNum, pos) => {
|
|
4
|
+
if (nav.length < 2 || !wpNum || wpNum < 0)
|
|
5
|
+
return null;
|
|
6
|
+
const cum = [0];
|
|
7
|
+
const cumSpray = [0];
|
|
8
|
+
for (let i = 1; i < nav.length; i++) {
|
|
9
|
+
const d = distance(nav[i - 1].point, nav[i].point);
|
|
10
|
+
cum.push(cum[i - 1] + d);
|
|
11
|
+
cumSpray.push(cumSpray[i - 1] + (nav[i].spray ? d : 0));
|
|
12
|
+
}
|
|
13
|
+
const total = cum[cum.length - 1];
|
|
14
|
+
if (total <= 0)
|
|
15
|
+
return null;
|
|
16
|
+
let k = nav.findIndex((n) => n.seq >= wpNum);
|
|
17
|
+
const sprayTotal = cumSpray[cumSpray.length - 1];
|
|
18
|
+
if (k === -1)
|
|
19
|
+
return { fraction: 1, sprayDone: sprayTotal, sprayTotal, target: nav.length, total: nav.length, finished: true };
|
|
20
|
+
if (k === 0)
|
|
21
|
+
return { fraction: 0, sprayDone: 0, sprayTotal, target: 1, total: nav.length, finished: false };
|
|
22
|
+
/* Часть текущего отрезка: проекция положения аппарата на отрезок от прошлой точки к цели */
|
|
23
|
+
const a = nav[k - 1].point;
|
|
24
|
+
const b = nav[k].point;
|
|
25
|
+
const leg = cum[k] - cum[k - 1];
|
|
26
|
+
let part = 0;
|
|
27
|
+
if (pos && leg > 0) {
|
|
28
|
+
const kLon = Math.cos((a[1] * Math.PI) / 180);
|
|
29
|
+
const abx = (b[0] - a[0]) * kLon;
|
|
30
|
+
const aby = b[1] - a[1];
|
|
31
|
+
const apx = (pos[0] - a[0]) * kLon;
|
|
32
|
+
const apy = pos[1] - a[1];
|
|
33
|
+
const len2 = abx * abx + aby * aby;
|
|
34
|
+
part = len2 > 0 ? Math.min(1, Math.max(0, (apx * abx + apy * aby) / len2)) : 0;
|
|
35
|
+
}
|
|
36
|
+
const done = cum[k - 1] + part * leg;
|
|
37
|
+
const sprayDone = cumSpray[k - 1] + (nav[k].spray ? part * leg : 0);
|
|
38
|
+
return { fraction: done / total, sprayDone, sprayTotal, target: k, total: nav.length, finished: false };
|
|
39
|
+
};
|
|
@@ -31,3 +31,10 @@ export declare const routeStats: (plan: IRoutePlan) => {
|
|
|
31
31
|
maxAlt: number;
|
|
32
32
|
};
|
|
33
33
|
export declare const routeToWaypointsFile: (plan: IRoutePlan) => string;
|
|
34
|
+
export declare const routeMission: (plan: IRoutePlan) => {
|
|
35
|
+
text: string;
|
|
36
|
+
nav: {
|
|
37
|
+
point: LngLat;
|
|
38
|
+
seq: number;
|
|
39
|
+
}[];
|
|
40
|
+
};
|
|
@@ -54,14 +54,18 @@ export const routeStats = (plan) => {
|
|
|
54
54
|
};
|
|
55
55
|
};
|
|
56
56
|
/* Миссия: дом, взлёт на высоту первой точки, точки маршрута; DO_CHANGE_SPEED перед точкой, где меняется скорость; при необходимости возврат (RTL) */
|
|
57
|
-
export const routeToWaypointsFile = (plan) =>
|
|
57
|
+
export const routeToWaypointsFile = (plan) => routeMission(plan).text;
|
|
58
|
+
/* Миссия и номера команд её точек: взлёт (у точки взлёта), точки маршрута, возврат RTL — к точке взлёта */
|
|
59
|
+
export const routeMission = (plan) => {
|
|
60
|
+
const nav = [];
|
|
58
61
|
const home = plan.start ?? plan.points[0]?.point;
|
|
59
62
|
if (!home || !plan.points.length)
|
|
60
|
-
return "";
|
|
63
|
+
return { text: "", nav };
|
|
61
64
|
const lines = ["QGC WPL 110"];
|
|
62
65
|
let seq = 0;
|
|
63
66
|
const row = (current, frame, cmd, p, ll, alt) => lines.push([seq++, current, frame, cmd, ...p, ll[1].toFixed(8), ll[0].toFixed(8), alt.toFixed(2), 1].join("\t"));
|
|
64
67
|
row(1, 0, 16, [0, 0, 0, 0], home, 0);
|
|
68
|
+
nav.push({ point: home, seq });
|
|
65
69
|
row(0, 3, 22, [0, 0, 0, 0], [0, 0], plan.points[0].alt);
|
|
66
70
|
let speed = NaN;
|
|
67
71
|
for (const wp of plan.points) {
|
|
@@ -69,9 +73,12 @@ export const routeToWaypointsFile = (plan) => {
|
|
|
69
73
|
speed = wp.speed;
|
|
70
74
|
row(0, 3, 178, [1, speed, -1, 0], [0, 0], 0);
|
|
71
75
|
}
|
|
76
|
+
nav.push({ point: wp.point, seq });
|
|
72
77
|
row(0, 3, 16, [0, 0, 0, 0], wp.point, wp.alt);
|
|
73
78
|
}
|
|
74
|
-
if (plan.returnHome)
|
|
79
|
+
if (plan.returnHome) {
|
|
80
|
+
nav.push({ point: home, seq });
|
|
75
81
|
row(0, 3, 20, [0, 0, 0, 0], [0, 0], 0);
|
|
76
|
-
|
|
82
|
+
}
|
|
83
|
+
return { text: lines.join("\n") + "\n", nav };
|
|
77
84
|
};
|
|
@@ -121,9 +121,9 @@ const translations = {
|
|
|
121
121
|
zh: "🎯 **用途**\n主飞行显示器(PFD)—— 飞行监控的主要仪表:姿态、速度、高度、航向和系统状态。\n\n📦 **数据包**\n- `MAV` 分组的字段 —— MAVLink 自驾仪遥测(可通过设置中的前缀更改),无需绑定变量\n- 遥测流:`Roll`、`Pitch`、`Heading`、`Load`、`Slip` 约每秒 10–20 次,其余每秒 1–2 次\n- 仅显示,不发送任何数据\n\n🔑 **键**\n- `Roll`、`Pitch` —— 数字,°:坡度(向右 +)和俯仰(向上 +)\n- `Heading` —— 数字,0–360°:航向\n- `GSpeed`、`ASpeed` —— 数字,m/s:地速和空速\n- `Alt` —— 数字,m:相对起飞点高度;`AltMSL` —— 海拔高度\n- `Climb` —— 数字,m/s:升降速度(向上 +)\n- `Load` —— 数字,g:法向过载(1 —— 平飞);`Slip` —— 侧向过载,g(向右 +);非数字 —— 不显示 G 和侧滑球\n- `Thr` —— 数字,%:油门\n- `WindDir`、`WindSpd` —— 风:来向,°,和风速,m/s\n- `Lat`、`Lon` —— 数字,°:坐标\n- `Vx`、`Vy` —— 数字,m/s:向北和向东的速度 —— 用于航迹\n- `Fix` —— 数字:GPS 定位类型,`0`–`1` 无、`2` 2D、`3` 3D、`4`–`6` DGPS/RTK;`Sat` —— 卫星数\n- `BV` —— V;`BC` —— A(`-1` 未测量);`BRem` —— %(`-1` 未知)\n- `Mode` —— 数字:自驾仪模式;`VType` —— 机型(决定模式名称);`Armed` —— `1` 已解锁\n- `HomeLat`、`HomeLon` —— 数字,°:起飞点,`0` —— 未知\n- `Press` —— hPa,`Temp` —— °C\n- `WPNum` —— 航点编号(`0` —— 无任务);`WPDist`、`WPBrg` —— 到航点的距离,m,和方位,°\n- `AltErr`、`SpdErr` —— 目标减当前:高度,m,空速,m/s;`XTE` —— 偏航距离,m\n- `RSSI`、`RemRSSI` —— 本地和远端信号,%(`-1` 未知);`Drop` —— 丢包率,%\n- `HDOP` —— GPS 精度(1 以下优秀,大于 2 不准确);`EKF` —— 导航滤波器方差(0.5 起注意,0.8 起告警);`SensErr` —— 故障传感器数\n- `Msg`、`MsgSev`、`MsgSeq` —— 自驾仪消息、严重程度(`0`–`3` 错误、`4` 警告、`5`–`7` 信息)、编号\n\n✨ **特点**\n- 数据包之间的姿态经过平滑\n- 将鼠标悬停在仪表或行中的任意元素上 —— 在地平线底部的信息行中显示含义和当前值;可通过标题栏中的「?」按钮关闭\n- 速度带和高度带下方的角落:空速(相对空气)和升降速度(+ 爬升、− 下降)\n- 地平线上方两角:`G` —— 过载(超出限值为红色,最小和最大值见提示)和 `THR` —— 油门\n- 风 —— 位于 `G` 下方:箭头为相对机头的风吹去方向(向下 —— 逆风,向上 —— 顺风),旁边为风速\n- 「航线」组显示按当前地速到航点的时间\n- 坡度指针下方的侧滑球:居中 —— 无侧滑,偏离 —— 侧滑或外滑(方向舵朝球的方向)\n- 速度带上的黄色箭头 —— 趋势:以当前加速度 10 秒后地速将到达的位置\n- 航向带上的粉色 ▲ 为航迹:载具相对地面的实际运动方向(由 `Vx`、`Vy` 计算);绿色 ● —— 起飞点方向;仪表下方有带角度的图例\n- 超过设定时间无数据 —— 仪表被划叉并标记「无数据」\n- 在窄单元格中,速度带和高度带变为地平线上的数字\n- 行已分组:上方「系统 / GPS / 链路 / 电池」—— 状态,下方「航线 / 起飞点 / 航迹 / 位置 / 空气」—— 去向与位置\n- 青色标记 —— 当前航点的目标:速度带和高度带上的速度与高度,航向带上的方位 ◆\n- 自驾仪消息 —— 在地平线底部同一信息行显示 10 秒,颜色按严重程度;悬停该行可查看历史\n- 键 `Lat`、`Lon`、`Alt`、`Heading`、`Sat` 与「地图」通用 —— 一个分组同时供两个组件使用\n\n⚙️ **设置**\n- 分组前缀(ID)、主色\n- 限值:最小/最大速度和最大高度 —— 速度带和高度带上的红色区域;最大坡度 —— 红色弧线;最小/最大过载 —— G 值变红;低电量;「无数据」前的时间",
|
|
122
122
|
},
|
|
123
123
|
"component.info.WidgetMap": {
|
|
124
|
-
ru: "🎯 **Назначение**\nКарта объектов в реальном времени: положение, курс и траектория каждого объекта.\n\n📦 **Пакет данных**\n- Поля групп устройства, по группе на объект: по умолчанию `MAV`, несколько — через пробел (`MAV GNSS`); привязывать переменные не нужно\n- Поток телеметрии: координаты — раз в 0.5–1 с, имя и серийный номер — реже\n- Только отображение, ничего не отправляет\n\n🔑 **Ключи**\n- `Lat` — число, °: широта, обязательный; `Lat` и `Lon` = 0 — нет фикса, объект не рисуется\n- `Lon` — число, °: долгота, обязательный\n- `Alt` — число, м: высота\n- `Heading` — число, 0–360°: курс, поворачивает маркер\n- `Sat` — число спутников\n- `DevName` — строка: имя объекта, подпись маркера; без него — имя группы\n- `DevSN` — строка: серийный номер\n\n✨ **Особенности**\n- Ключи общие с «Пилотажным дисплеем» и «Об устройстве» — один пакет питает несколько виджетов\n- Клик по объекту — карточка со всеми значениями, кнопки «Копировать» и «Скрыть»\n- Траекторию, число её точек и таймаут маркеров оператор переключает на карте; объект без новых данных краснеет, ещё через таймаут скрывается\n- «Показать все» в шапке вписывает объекты в экран; сама карта вид не меняет\n- В шапке — сколько объектов на связи из заданных\n- Только 2D: правой кнопкой мыши карта поворачивается, компас под зумом возвращает её на север\n\n⚙️ **Настройки**\n- Группы полей, мастер-цвет\n- Высота карты `S`/`M`/`L`/`XL`, иконка маркера\n- Начальные значения: траектория и число её точек\n- Режим: «Объекты», «Маршрут» или «Агро»\n\n📍 **Режим «Маршрут»**\n- Обычный планировщик: точки по порядку облёта, у каждой своя высота и скорость\n- «Изменить точки» включает правку: клик по карте добавляет точку, точки перетаскиваются, середина отрезка вставляет точку, правый клик удаляет; без правки видны только номера\n- Высота, скорость и удаление — в списке точек блока «Итог», порядок меняется перетаскиванием строки; возврат с посадкой (RTL)\n- Миссия QGC WPL 110: взлёт, точки, RTL; аппараты из групп видны поверх маршрута\n\n🌾 **Режим «Агро»**\n- Генератор миссии для агродрона: одно или несколько полей и запретные зоны на карте превращаются в траекторию опрыскивания; поля облетаются по очереди от ближайшего\n- «Поля» и «Запретные зоны» — контуры кликами по карте; синяя точка взлёта перетаскивается всегда\n- Проходы с шагом, равным ширине захвата; у каждого поля свой угол, «Подобрать» считает лучший для каждого; зоны обходятся с отступом\n- Распылитель включается на проходах и выключается на разворотах и переходах: `DO_SPRAYER` или `DO_SET_SERVO`\n- Миссия QGC WPL 110: «Скачать», «Копировать», «Отправить» на борт; аппараты из групп видны поверх
|
|
125
|
-
en: '🎯 **Purpose**\nA real-time map of objects: position, heading and track of each object.\n\n📦 **Data packet**\n- Device field groups, one group per object: `MAV` by default, several separated by spaces (`MAV GNSS`); no variable binding needed\n- Telemetry stream: coordinates every 0.5–1 s, name and serial number less often\n- Display only, sends nothing\n\n🔑 **Keys**\n- `Lat` — number, °: latitude, required; `Lat` and `Lon` = 0 — no fix, the object is not drawn\n- `Lon` — number, °: longitude, required\n- `Alt` — number, m: altitude\n- `Heading` — number, 0–360°: heading, rotates the marker\n- `Sat` — satellite count\n- `DevName` — string: object name, the marker label; without it — the group name\n- `DevSN` — string: serial number\n\n✨ **Features**\n- Keys are shared with the "Flight Display" and "About Device" — one packet feeds several widgets\n- Click an object — a card with all values, "Copy" and "Hide" buttons\n- The operator switches the track, its point count and marker timeout on the map; an object without new data turns red, after one more timeout it is hidden\n- "Show all" in the header fits all objects into view; the map never moves by itself\n- The header shows how many of the configured objects are online\n- 2D only: the right mouse button rotates the map, the compass under the zoom returns it to north\n\n⚙️ **Settings**\n- Field groups, master color\n- Map height `S`/`M`/`L`/`XL`, marker icon\n- Defaults: track and its point count\n- Mode: "Objects", "Route" or "Agro"\n\n📍 **Route mode**\n- A regular planner: points in flight order, each with its own altitude and speed\n- "Edit points" turns on editing: a click adds a point, points can be dragged, a segment midpoint inserts one, right-click removes; otherwise only numbers are shown\n- Altitude, speed and removal in the point list of the Summary block, order is changed by dragging a row; return and land (RTL)\n- QGC WPL 110 mission: takeoff, points, RTL; vehicles from the groups are shown over the route\n\n🌾 **Agro mode**\n- Mission generator for an agricultural drone: one or more fields and no-fly zones on the map become a spraying path; fields are flown one after another, nearest first\n- "Fields" and "No-fly zones" — outlines by clicking the map; the blue takeoff point can always be dragged\n- Passes spaced by the swath width; each field has its own angle, "Optimize" finds the best one for each; zones are avoided with a margin\n- The sprayer is on during passes and off on turns and transits: `DO_SPRAYER` or `DO_SET_SERVO`\n- QGC WPL 110 mission: "Download", "Copy", "Send" to the vehicle',
|
|
126
|
-
zh: "🎯 **用途**\n实时对象地图:每个对象的位置、航向和轨迹。\n\n📦 **数据包**\n- 设备字段分组,每个分组一个对象:默认 `MAV`,多个用空格分隔(`MAV GNSS`);无需绑定变量\n- 遥测流:坐标每 0.5–1 秒一次,名称和序列号频率更低\n- 仅显示,不发送任何数据\n\n🔑 **键**\n- `Lat` —— 数字,°:纬度,必需;`Lat` 和 `Lon` 为 0 —— 无定位,不绘制对象\n- `Lon` —— 数字,°:经度,必需\n- `Alt` —— 数字,m:高度\n- `Heading` —— 数字,0–360°:航向,旋转标记\n- `Sat` —— 卫星数\n- `DevName` —— 字符串:对象名称,标记标签;缺省时为分组名\n- `DevSN` —— 字符串:序列号\n\n✨ **特点**\n- 键与「飞行显示器」和「关于设备」通用 —— 一个数据包可供多个组件使用\n- 点击对象 —— 显示所有值的卡片,带「复制」和「隐藏」按钮\n- 操作员在地图上切换轨迹、轨迹点数和标记超时;无新数据的对象变红,再过一个超时后隐藏\n- 标题栏中的「显示全部」将对象纳入视野;地图本身不会自动移动\n- 标题栏显示已配置对象中在线的数量\n- 仅 2D:按住鼠标右键旋转地图,缩放按钮下方的指南针将其恢复为正北\n\n⚙️ **设置**\n- 字段分组、主色\n- 地图高度 `S`/`M`/`L`/`XL`、标记图标\n- 默认值:轨迹及其点数\n- 模式:「对象」、「航线」或「农业」\n\n📍 **航线模式**\n- 常规规划:按飞行顺序放置航点,每个航点有自己的高度和速度\n- 「编辑航点」开启编辑:点击地图添加航点,航点可拖动,拖动线段中点插入,右键删除;未开启时仅显示编号\n- 在“汇总”的航点列表中设置高度、速度和删除,拖动行可调整顺序;返航降落 (RTL)\n- QGC WPL 110 任务:起飞、航点、RTL;分组中的飞行器显示在航线上方\n\n🌾 **农业模式**\n- 农业无人机任务生成器:地图上的一个或多个田块和禁飞区生成喷洒航线;按由近到远依次作业各田块\n- 「田块」和「禁飞区」—— 点击地图绘制轮廓;蓝色起飞点可随时拖动\n- 作业行间距等于喷幅;每个田块有自己的角度,「优化」为每个田块寻找最佳角度;以边距绕开禁区\n- 喷洒器在作业行开启,在转弯和转场时关闭:`DO_SPRAYER` 或 `DO_SET_SERVO`\n- QGC WPL 110
|
|
124
|
+
ru: "🎯 **Назначение**\nКарта объектов в реальном времени: положение, курс и траектория каждого объекта.\n\n📦 **Пакет данных**\n- Поля групп устройства, по группе на объект: по умолчанию `MAV`, несколько — через пробел (`MAV GNSS`); привязывать переменные не нужно\n- Поток телеметрии: координаты — раз в 0.5–1 с, имя и серийный номер — реже\n- Только отображение, ничего не отправляет\n\n🔑 **Ключи**\n- `Lat` — число, °: широта, обязательный; `Lat` и `Lon` = 0 — нет фикса, объект не рисуется\n- `Lon` — число, °: долгота, обязательный\n- `Alt` — число, м: высота\n- `Heading` — число, 0–360°: курс, поворачивает маркер\n- `Sat` — число спутников\n- `DevName` — строка: имя объекта, подпись маркера; без него — имя группы\n- `DevSN` — строка: серийный номер\n\n✨ **Особенности**\n- Ключи общие с «Пилотажным дисплеем» и «Об устройстве» — один пакет питает несколько виджетов\n- Клик по объекту — карточка со всеми значениями, кнопки «Копировать» и «Скрыть»\n- Траекторию, число её точек и таймаут маркеров оператор переключает на карте; объект без новых данных краснеет, ещё через таймаут скрывается\n- «Показать все» в шапке вписывает объекты в экран; сама карта вид не меняет\n- В шапке — сколько объектов на связи из заданных\n- Только 2D: правой кнопкой мыши карта поворачивается, компас под зумом возвращает её на север\n\n⚙️ **Настройки**\n- Группы полей, мастер-цвет\n- Высота карты `S`/`M`/`L`/`XL`, иконка маркера\n- Начальные значения: траектория и число её точек\n- Режим: «Объекты», «Маршрут» или «Агро»\n\n📍 **Режим «Маршрут»**\n- Обычный планировщик: точки по порядку облёта, у каждой своя высота и скорость\n- «Изменить точки» включает правку: клик по карте добавляет точку, точки перетаскиваются, середина отрезка вставляет точку, правый клик удаляет; без правки видны только номера\n- Высота, скорость и удаление — в списке точек блока «Итог», порядок меняется перетаскиванием строки; возврат с посадкой (RTL)\n- Миссия QGC WPL 110: взлёт, точки, RTL; аппараты из групп видны поверх маршрута\n- Режим имитации: аппарат летит по маршруту со скоростями точек, пауза и ускорение на карте\n- Прогресс в шапке: в имитации — по ней, иначе по `WPNum` и положению аппарата первой группы\n\n🌾 **Режим «Агро»**\n- Генератор миссии для агродрона: одно или несколько полей и запретные зоны на карте превращаются в траекторию опрыскивания; поля облетаются по очереди от ближайшего\n- «Поля» и «Запретные зоны» — контуры кликами по карте; синяя точка взлёта перетаскивается всегда\n- Проходы с шагом, равным ширине захвата; у каждого поля свой угол, «Подобрать» считает лучший для каждого; зоны обходятся с отступом\n- Распылитель включается на проходах и выключается на разворотах и переходах: `DO_SPRAYER` или `DO_SET_SERVO`\n- Миссия QGC WPL 110: «Скачать», «Копировать», «Отправить» на борт; аппараты из групп видны поверх поля\n- Режим имитации: квадрокоптер облетает проходы, зелёный во время распыления; пауза и ускорение на карте",
|
|
125
|
+
en: '🎯 **Purpose**\nA real-time map of objects: position, heading and track of each object.\n\n📦 **Data packet**\n- Device field groups, one group per object: `MAV` by default, several separated by spaces (`MAV GNSS`); no variable binding needed\n- Telemetry stream: coordinates every 0.5–1 s, name and serial number less often\n- Display only, sends nothing\n\n🔑 **Keys**\n- `Lat` — number, °: latitude, required; `Lat` and `Lon` = 0 — no fix, the object is not drawn\n- `Lon` — number, °: longitude, required\n- `Alt` — number, m: altitude\n- `Heading` — number, 0–360°: heading, rotates the marker\n- `Sat` — satellite count\n- `DevName` — string: object name, the marker label; without it — the group name\n- `DevSN` — string: serial number\n\n✨ **Features**\n- Keys are shared with the "Flight Display" and "About Device" — one packet feeds several widgets\n- Click an object — a card with all values, "Copy" and "Hide" buttons\n- The operator switches the track, its point count and marker timeout on the map; an object without new data turns red, after one more timeout it is hidden\n- "Show all" in the header fits all objects into view; the map never moves by itself\n- The header shows how many of the configured objects are online\n- 2D only: the right mouse button rotates the map, the compass under the zoom returns it to north\n\n⚙️ **Settings**\n- Field groups, master color\n- Map height `S`/`M`/`L`/`XL`, marker icon\n- Defaults: track and its point count\n- Mode: "Objects", "Route" or "Agro"\n\n📍 **Route mode**\n- A regular planner: points in flight order, each with its own altitude and speed\n- "Edit points" turns on editing: a click adds a point, points can be dragged, a segment midpoint inserts one, right-click removes; otherwise only numbers are shown\n- Altitude, speed and removal in the point list of the Summary block, order is changed by dragging a row; return and land (RTL)\n- QGC WPL 110 mission: takeoff, points, RTL; vehicles from the groups are shown over the route\n- Simulation mode: a vehicle flies the route at the point speeds, pause and speed-up on the map\n- Progress in the header: from the simulation, otherwise from `WPNum` and the position of the vehicle from the first group\n\n🌾 **Agro mode**\n- Mission generator for an agricultural drone: one or more fields and no-fly zones on the map become a spraying path; fields are flown one after another, nearest first\n- "Fields" and "No-fly zones" — outlines by clicking the map; the blue takeoff point can always be dragged\n- Passes spaced by the swath width; each field has its own angle, "Optimize" finds the best one for each; zones are avoided with a margin\n- The sprayer is on during passes and off on turns and transits: `DO_SPRAYER` or `DO_SET_SERVO`\n- QGC WPL 110 mission: "Download", "Copy", "Send" to the vehicle\n- Simulation mode: a quadcopter flies the passes, green while spraying; pause and speed-up on the map',
|
|
126
|
+
zh: "🎯 **用途**\n实时对象地图:每个对象的位置、航向和轨迹。\n\n📦 **数据包**\n- 设备字段分组,每个分组一个对象:默认 `MAV`,多个用空格分隔(`MAV GNSS`);无需绑定变量\n- 遥测流:坐标每 0.5–1 秒一次,名称和序列号频率更低\n- 仅显示,不发送任何数据\n\n🔑 **键**\n- `Lat` —— 数字,°:纬度,必需;`Lat` 和 `Lon` 为 0 —— 无定位,不绘制对象\n- `Lon` —— 数字,°:经度,必需\n- `Alt` —— 数字,m:高度\n- `Heading` —— 数字,0–360°:航向,旋转标记\n- `Sat` —— 卫星数\n- `DevName` —— 字符串:对象名称,标记标签;缺省时为分组名\n- `DevSN` —— 字符串:序列号\n\n✨ **特点**\n- 键与「飞行显示器」和「关于设备」通用 —— 一个数据包可供多个组件使用\n- 点击对象 —— 显示所有值的卡片,带「复制」和「隐藏」按钮\n- 操作员在地图上切换轨迹、轨迹点数和标记超时;无新数据的对象变红,再过一个超时后隐藏\n- 标题栏中的「显示全部」将对象纳入视野;地图本身不会自动移动\n- 标题栏显示已配置对象中在线的数量\n- 仅 2D:按住鼠标右键旋转地图,缩放按钮下方的指南针将其恢复为正北\n\n⚙️ **设置**\n- 字段分组、主色\n- 地图高度 `S`/`M`/`L`/`XL`、标记图标\n- 默认值:轨迹及其点数\n- 模式:「对象」、「航线」或「农业」\n\n📍 **航线模式**\n- 常规规划:按飞行顺序放置航点,每个航点有自己的高度和速度\n- 「编辑航点」开启编辑:点击地图添加航点,航点可拖动,拖动线段中点插入,右键删除;未开启时仅显示编号\n- 在“汇总”的航点列表中设置高度、速度和删除,拖动行可调整顺序;返航降落 (RTL)\n- QGC WPL 110 任务:起飞、航点、RTL;分组中的飞行器显示在航线上方\n- 模拟模式:飞行器按航点速度沿航线飞行,可在地图上暂停和加速\n- 标题中的进度:模拟时取自模拟,否则根据第一个分组飞行器的 `WPNum` 和位置\n\n🌾 **农业模式**\n- 农业无人机任务生成器:地图上的一个或多个田块和禁飞区生成喷洒航线;按由近到远依次作业各田块\n- 「田块」和「禁飞区」—— 点击地图绘制轮廓;蓝色起飞点可随时拖动\n- 作业行间距等于喷幅;每个田块有自己的角度,「优化」为每个田块寻找最佳角度;以边距绕开禁区\n- 喷洒器在作业行开启,在转弯和转场时关闭:`DO_SPRAYER` 或 `DO_SET_SERVO`\n- QGC WPL 110 任务:「下载」、「复制」、「发送」到飞行器\n- 模拟模式:四旋翼沿作业行飞行,喷洒时为绿色;可在地图上暂停和加速",
|
|
127
127
|
},
|
|
128
128
|
"component.info.WidgetStackInfo": {
|
|
129
129
|
ru: '🎯 **Назначение**\nОтладка прошивки: свободная память и запас стека задач.\n\n📦 **Пакет данных**\n- Один объект `StackInfo` из ответа на `GET StackInfo`, привязывать переменные не нужно\n- Запрос — кнопкой «Обновить» или автоопросом\n\n🔑 **Ключи**\n- `Heap` — массив `[{ "Name", "Total", "Free", "MinFree" }]`, байт: кучи памяти — `Internal` встроенная и `PSRAM` внешняя ОЗУ у ESP, `Heap` у STM, в виджете понятными подписями; `MinFree` — минимум свободного с момента старта\n- `Tasks` — массив `[{ "Name", "State", "Prio", "Core", "CPU", "FreeMin" }]`: задачи; `State` — `0` выполняется, `1` готова, `2` ждёт, `3` приостановлена, `4` удалена; `Core` — ядро (только ESP32, `-1` без привязки); `CPU` — загрузка задачи, % одного ядра с прошлого ответа; `FreeMin` — минимальный запас стека, байт\n\n✨ **Особенности**\n- Светлая часть полосы кучи — пик занятости\n- Задачи отсортированы по запасу стека — самые рискованные сверху\n- Загрузка от 50 % — оранжевым, от 80 % — красным (кроме `IDLE` — это свободное время ядра); колонка есть, если прошивка её присылает\n- Свёрнутый виджет не опрашивает устройство\n\n⚙️ **Настройки**\n- Порог запаса стека: меньше порога — красным, меньше удвоенного — оранжевым\n- Автоопрос: выкл / 1 / 5 / 10 с; мастер-цвет',
|
|
@@ -780,9 +780,9 @@ const translations = {
|
|
|
780
780
|
"widget.mission.angle": { ru: "Угол проходов", en: "Pass angle", zh: "作业角度" },
|
|
781
781
|
"widget.mission.auto": { ru: "Подобрать", en: "Optimize", zh: "优化" },
|
|
782
782
|
"widget.mission.autoHint": {
|
|
783
|
-
ru: "Для каждого поля
|
|
784
|
-
en: "
|
|
785
|
-
zh: "
|
|
783
|
+
ru: "Для каждого поля перебираются углы полным расчётом траектории: шаг 5°, затем 1° и 0,1°. Лучший — с наименьшим временем облёта, где учитываются путь и развороты",
|
|
784
|
+
en: "Angles are tried for each field with the full path calculation: 5° steps, then 1° and 0.1°. The best one has the shortest flight time, counting the path and turns",
|
|
785
|
+
zh: "对每个田块用完整航线计算逐步尝试角度:先 5°,再 1° 和 0.1°。最佳角度的飞行时间最短(计入航程和转弯)",
|
|
786
786
|
},
|
|
787
787
|
"widget.mission.spacing": { ru: "Ширина захвата, м", en: "Swath width, m", zh: "喷幅,米" },
|
|
788
788
|
"widget.mission.margin": { ru: "Отступ от границ, м", en: "Edge margin, m", zh: "边界距离,米" },
|
|
@@ -965,6 +965,22 @@ const translations = {
|
|
|
965
965
|
en: "Pass angle of this field, ° from north. “Optimize” recalculates it, a new field gets the best angle at once",
|
|
966
966
|
zh: "该田块的作业角度(相对正北)。“优化”会重新计算,新田块会立即获得最佳角度",
|
|
967
967
|
},
|
|
968
|
+
"widget.sim.title": { ru: "Имитация", en: "Simulation", zh: "模拟" },
|
|
969
|
+
"widget.sim.play": { ru: "Продолжить", en: "Play", zh: "继续" },
|
|
970
|
+
"widget.sim.pause": { ru: "Пауза", en: "Pause", zh: "暂停" },
|
|
971
|
+
"widget.sim.scrub": { ru: "Положение на пути", en: "Position on the path", zh: "航线位置" },
|
|
972
|
+
"widget.sim.sprayOn": { ru: "распыление", en: "spraying", zh: "喷洒中" },
|
|
973
|
+
"widget.sim.sprayOff": { ru: "без распыления", en: "not spraying", zh: "未喷洒" },
|
|
974
|
+
"widget.mission.treated": { ru: "обработано", en: "treated", zh: "已作业" },
|
|
975
|
+
"widget.mission.progressHint": {
|
|
976
|
+
ru: "Выполнение миссии по телеметрии аппарата (WPNum)",
|
|
977
|
+
en: "Mission progress from the vehicle telemetry (WPNum)",
|
|
978
|
+
zh: "根据飞行器遥测 (WPNum) 的任务进度",
|
|
979
|
+
},
|
|
980
|
+
"widget.route.finished": { ru: "выполнено", en: "done", zh: "已完成" },
|
|
981
|
+
"widget.route.returning": { ru: "возврат", en: "returning", zh: "返航" },
|
|
982
|
+
"widget.route.toPoint": { ru: "к точке", en: "to point", zh: "飞往航点" },
|
|
983
|
+
"widget.mission.searching": { ru: "Подбор угла…", en: "Finding angle…", zh: "正在寻找角度…" },
|
|
968
984
|
"widget.mission.ha": { ru: "га", en: "ha", zh: "公顷" },
|
|
969
985
|
"widget.mission.passesCount": { ru: "Проходы / участки", en: "Passes / sections", zh: "作业行 / 分区" },
|
|
970
986
|
"widget.mission.turns": { ru: "Развороты", en: "Turns", zh: "转弯" },
|
package/dist/types.d.ts
CHANGED