poe-svelte-ui-lib 1.86.0 → 1.87.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 +7 -0
- package/dist/WidgetMap/WidgetMap.svelte +36 -3
- package/dist/WidgetMap/mission/MapAgro.svelte +56 -13
- package/dist/WidgetMap/mission/MapAgro.svelte.d.ts +6 -0
- package/dist/WidgetMap/mission/MapRoute.svelte +28 -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/coverage.d.ts +8 -0
- package/dist/WidgetMap/mission/coverage.js +8 -2
- package/dist/WidgetMap/mission/progress.d.ts +14 -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 +17 -3
- 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.
|
|
@@ -97,7 +97,8 @@
|
|
|
97
97
|
const randomSec = (min: number, max: number) => (min + Math.random() * (max - min)) * 1000
|
|
98
98
|
let simValue: Record<string, Partial<IWidgetMapData>> | undefined = $state()
|
|
99
99
|
$effect(() => {
|
|
100
|
-
|
|
100
|
+
/* В «Маршруте» и «Агро» имитация — полёт по своей траектории, аппараты SIM не нужны */
|
|
101
|
+
if (!isTest || mode !== "objects") return
|
|
101
102
|
/* На связи всегда три аппарата: когда один пропадает, его сменяет тот, кто молчит дольше всех; молчащий маркер успевает покраснеть и скрыться */
|
|
102
103
|
const fleet = SIM_GROUPS.map((group, i) => ({
|
|
103
104
|
group,
|
|
@@ -143,6 +144,12 @@
|
|
|
143
144
|
})
|
|
144
145
|
let value = $derived(isTest ? simValue : deviceValue)
|
|
145
146
|
let groupList = $derived(isTest ? SIM_GROUPS : groups.map((g) => g.trim()).filter(Boolean))
|
|
147
|
+
/* Аппарат для прогресса миссии в «Маршруте» и «Агро»: первая группа, положение и номер текущей команды */
|
|
148
|
+
let missionVehicle = $derived.by(() => {
|
|
149
|
+
const d = deviceValue?.[groupList[0]]
|
|
150
|
+
if (isTest || !d || !d.Lat || !d.Lon) return undefined
|
|
151
|
+
return { point: [d.Lon, d.Lat] as [number, number], wpNum: d.WPNum }
|
|
152
|
+
})
|
|
146
153
|
let boards: Board[] = $state([])
|
|
147
154
|
let now = $state(Date.now())
|
|
148
155
|
let mapInstance: any = $state()
|
|
@@ -446,9 +453,35 @@
|
|
|
446
453
|
|
|
447
454
|
<!-- Режим «Агро» — генератор миссии для агродрона, «Маршрут» — обычный планировщик миссии, остальное — карта объектов -->
|
|
448
455
|
{#if mode === "agro"}
|
|
449
|
-
<MapAgro
|
|
456
|
+
<MapAgro
|
|
457
|
+
{wrapperClass}
|
|
458
|
+
{componentClass}
|
|
459
|
+
{label}
|
|
460
|
+
height={height ?? 520}
|
|
461
|
+
bind:plan
|
|
462
|
+
bind:collapsed
|
|
463
|
+
{persistKey}
|
|
464
|
+
{onPlanChange}
|
|
465
|
+
{onSend}
|
|
466
|
+
overlay={isTest ? undefined : vehicles}
|
|
467
|
+
simulate={isTest}
|
|
468
|
+
vehicle={missionVehicle}
|
|
469
|
+
/>
|
|
450
470
|
{:else if mode === "route"}
|
|
451
|
-
<MapRoute
|
|
471
|
+
<MapRoute
|
|
472
|
+
{wrapperClass}
|
|
473
|
+
{componentClass}
|
|
474
|
+
{label}
|
|
475
|
+
height={height ?? 520}
|
|
476
|
+
bind:route
|
|
477
|
+
bind:collapsed
|
|
478
|
+
{persistKey}
|
|
479
|
+
{onRouteChange}
|
|
480
|
+
{onSend}
|
|
481
|
+
overlay={isTest ? undefined : vehicles}
|
|
482
|
+
simulate={isTest}
|
|
483
|
+
vehicle={missionVehicle}
|
|
484
|
+
/>
|
|
452
485
|
{:else}
|
|
453
486
|
<div
|
|
454
487
|
bind:this={rootEl}
|
|
@@ -11,9 +11,11 @@
|
|
|
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 MissionIcon from "./MissionIcon.svelte"
|
|
15
17
|
import ArrowIcon from "../../libIcons/ArrowIcon.svelte"
|
|
16
|
-
import { planFields, bestFieldAngle,
|
|
18
|
+
import { planFields, bestFieldAngle, toMission, fieldArea, fieldsArea, type LngLat, type SprayControl } from "./coverage"
|
|
17
19
|
import { DEFAULT_MISSION_PLAN, type IMissionPlan } from "./plan"
|
|
18
20
|
|
|
19
21
|
/* Режим «Агро» карты: часть пропсов WidgetMap */
|
|
@@ -26,6 +28,8 @@
|
|
|
26
28
|
collapsed?: boolean
|
|
27
29
|
persistKey?: string
|
|
28
30
|
overlay?: Snippet /* Аппараты карты поверх миссии */
|
|
31
|
+
simulate?: boolean /* Имитация полёта по рассчитанной траектории */
|
|
32
|
+
vehicle?: { point: LngLat; wpNum?: number } /* Аппарат из первой группы карты: положение и номер текущей команды миссии */
|
|
29
33
|
onPlanChange?: (plan: IMissionPlan) => void
|
|
30
34
|
onSend?: (mission: string, plan: IMissionPlan) => void | Promise<void>
|
|
31
35
|
}
|
|
@@ -39,6 +43,8 @@
|
|
|
39
43
|
collapsed = $bindable(false),
|
|
40
44
|
persistKey,
|
|
41
45
|
overlay,
|
|
46
|
+
simulate = false,
|
|
47
|
+
vehicle,
|
|
42
48
|
onPlanChange,
|
|
43
49
|
onSend,
|
|
44
50
|
}: Props = $props()
|
|
@@ -210,6 +216,20 @@
|
|
|
210
216
|
}
|
|
211
217
|
})
|
|
212
218
|
|
|
219
|
+
/* Путь для имитации: точка взлёта, затем все отрезки траектории; распыление — на проходах */
|
|
220
|
+
let simProgress = $state(0)
|
|
221
|
+
let simPath = $derived.by(() => {
|
|
222
|
+
if (!result?.waypoints.length) return []
|
|
223
|
+
const out = [{ point: result.waypoints[0].point, alt: plan.alt, speed: plan.speed, spray: false }]
|
|
224
|
+
for (const seg of result.segments)
|
|
225
|
+
seg.points.forEach((pt, i) => {
|
|
226
|
+
const prev = out[out.length - 1].point
|
|
227
|
+
if (i === 0 && Math.abs(prev[0] - pt[0]) < 1e-9 && Math.abs(prev[1] - pt[1]) < 1e-9) return
|
|
228
|
+
out.push({ point: pt, alt: plan.alt, speed: plan.speed, spray: seg.kind === "spray" && i > 0 })
|
|
229
|
+
})
|
|
230
|
+
return out
|
|
231
|
+
})
|
|
232
|
+
|
|
213
233
|
/* ********************************************************** */
|
|
214
234
|
/* GeoJSON для слоёв */
|
|
215
235
|
|
|
@@ -283,13 +303,22 @@
|
|
|
283
303
|
}
|
|
284
304
|
})
|
|
285
305
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
/* Миссия и номера её точек: выгрузка и прогресс реального полёта по WPNum */
|
|
307
|
+
let mission = $derived(
|
|
308
|
+
result && hasField
|
|
309
|
+
? toMission(result, plan.start ?? fields[0][0], {
|
|
310
|
+
alt: plan.alt,
|
|
311
|
+
speed: plan.speed,
|
|
312
|
+
spray: plan.sprayControl,
|
|
313
|
+
servo: { channel: plan.servoChannel, on: plan.servoOn, off: plan.servoOff },
|
|
314
|
+
})
|
|
315
|
+
: null,
|
|
316
|
+
)
|
|
317
|
+
const missionText = () => mission?.text ?? ""
|
|
318
|
+
let live = $derived(mission && !simulate ? missionProgress(mission.nav, vehicle?.wpNum, vehicle?.point) : null)
|
|
319
|
+
/* Обработано: доля пройденных проходов от площади к обработке */
|
|
320
|
+
let treatedHa = $derived(live && stats && live.sprayTotal > 0 ? (stats.ha * live.sprayDone) / live.sprayTotal : 0)
|
|
321
|
+
let headerProgress = $derived(simulate ? simProgress : (live?.fraction ?? null))
|
|
293
322
|
const download = () => {
|
|
294
323
|
const text = missionText()
|
|
295
324
|
if (!text) return
|
|
@@ -392,11 +421,22 @@
|
|
|
392
421
|
>
|
|
393
422
|
<WidgetHeader icon={MissionIcon} label={label?.name || $T("widget.mission.title")} bind:collapsed {hints}>
|
|
394
423
|
{#snippet right()}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
424
|
+
<span class="flex items-center gap-3 text-[12px] font-semibold text-(--muted-color) tabular-nums">
|
|
425
|
+
{#if headerProgress !== null}
|
|
426
|
+
<span class="flex items-center gap-1.5" title={simulate ? $T("widget.sim.title") : $T("widget.mission.progressHint")}>
|
|
427
|
+
<span class="h-1.5 w-14 overflow-hidden rounded-full bg-(--hairline-color)"
|
|
428
|
+
><span class="block h-full rounded-full bg-(--accent-color)" style="width: {Math.round(headerProgress * 100)}%"></span></span
|
|
429
|
+
>
|
|
430
|
+
{Math.round(headerProgress * 100)}%{#if live}
|
|
431
|
+
· {$T("widget.mission.treated")} {fmt(treatedHa, 2)} {$T("widget.mission.ha")}{/if}
|
|
432
|
+
</span>
|
|
433
|
+
{/if}
|
|
434
|
+
{#if stats}
|
|
435
|
+
<span class="text-[12px] font-semibold text-(--muted-color) tabular-nums"
|
|
436
|
+
>{fmt(stats.ha, 2)} {$T("widget.mission.ha")} · {fmt(stats.total, 2)} {$T("widget.mission.km")}</span
|
|
437
|
+
>
|
|
438
|
+
{/if}
|
|
439
|
+
</span>
|
|
400
440
|
{/snippet}
|
|
401
441
|
</WidgetHeader>
|
|
402
442
|
|
|
@@ -477,6 +517,9 @@
|
|
|
477
517
|
onCursor={(ll) => (cursor = ll)}
|
|
478
518
|
/>
|
|
479
519
|
{@render overlay?.()}
|
|
520
|
+
{#if simulate}
|
|
521
|
+
<MissionSim path={simPath} showSpray icon="quad" bind:progress={simProgress} />
|
|
522
|
+
{/if}
|
|
480
523
|
</MapLibre>
|
|
481
524
|
</div>
|
|
482
525
|
|
|
@@ -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,18 @@
|
|
|
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.target > route.points.length ? $T("widget.route.returning") : `${$T("widget.route.toPoint")} ${live.target}/${route.points.length}`}{/if}
|
|
274
|
+
</span>
|
|
275
|
+
{/if}
|
|
276
|
+
<span>{stats.points} {$T("widget.route.pts")} · {km(stats.length)}</span>
|
|
277
|
+
</span>
|
|
265
278
|
{/snippet}
|
|
266
279
|
</WidgetHeader>
|
|
267
280
|
|
|
@@ -319,6 +332,9 @@
|
|
|
319
332
|
onCursor={() => {}}
|
|
320
333
|
/>
|
|
321
334
|
{@render overlay?.()}
|
|
335
|
+
{#if simulate}
|
|
336
|
+
<MissionSim path={legs.nodes} bind:progress={simProgress} />
|
|
337
|
+
{/if}
|
|
322
338
|
</MapLibre>
|
|
323
339
|
</div>
|
|
324
340
|
|
|
@@ -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;
|
|
@@ -56,3 +56,11 @@ export interface IMissionExport {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
export declare const toWaypointsFile: (res: ICoverageResult, home: LngLat, opt: IMissionExport) => string;
|
|
59
|
+
export declare const toMission: (res: ICoverageResult, home: LngLat, opt: IMissionExport) => {
|
|
60
|
+
text: string;
|
|
61
|
+
nav: {
|
|
62
|
+
point: LngLat;
|
|
63
|
+
seq: number;
|
|
64
|
+
spray?: boolean;
|
|
65
|
+
}[];
|
|
66
|
+
};
|
|
@@ -726,8 +726,11 @@ 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
|
-
export const toWaypointsFile = (res, home, opt) =>
|
|
729
|
+
export const toWaypointsFile = (res, home, opt) => toMission(res, home, opt).text;
|
|
730
|
+
/* Миссия и номера команд её точек маршрута: по ним прогресс считается из WPNum телеметрии */
|
|
731
|
+
export const toMission = (res, home, opt) => {
|
|
730
732
|
const lines = ["QGC WPL 110"];
|
|
733
|
+
const nav = [];
|
|
731
734
|
let seq = 0;
|
|
732
735
|
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
736
|
const sprayRow = (on) => {
|
|
@@ -741,7 +744,10 @@ export const toWaypointsFile = (res, home, opt) => {
|
|
|
741
744
|
/* Перед полётом распыление явно выключено, чтобы не лить по дороге к полю */
|
|
742
745
|
sprayRow(false);
|
|
743
746
|
let spraying = false;
|
|
747
|
+
let sprayOnLeg = false;
|
|
744
748
|
for (const wp of res.waypoints) {
|
|
749
|
+
nav.push({ point: wp.point, seq, spray: sprayOnLeg });
|
|
750
|
+
sprayOnLeg = wp.spray;
|
|
745
751
|
row(0, 3, 16, [0, 0, 0, 0], wp.point, opt.alt);
|
|
746
752
|
if (wp.spray !== spraying) {
|
|
747
753
|
spraying = wp.spray;
|
|
@@ -750,5 +756,5 @@ export const toWaypointsFile = (res, home, opt) => {
|
|
|
750
756
|
}
|
|
751
757
|
if (spraying)
|
|
752
758
|
sprayRow(false);
|
|
753
|
-
return lines.join("\n") + "\n";
|
|
759
|
+
return { text: lines.join("\n") + "\n", nav };
|
|
754
760
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
}
|
|
14
|
+
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 };
|
|
20
|
+
if (k === 0)
|
|
21
|
+
return { fraction: 0, sprayDone: 0, sprayTotal, target: 1, total: nav.length };
|
|
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 };
|
|
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 с; мастер-цвет',
|
|
@@ -965,6 +965,20 @@ 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.returning": { ru: "возврат", en: "returning", zh: "返航" },
|
|
981
|
+
"widget.route.toPoint": { ru: "к точке", en: "to point", zh: "飞往航点" },
|
|
968
982
|
"widget.mission.ha": { ru: "га", en: "ha", zh: "公顷" },
|
|
969
983
|
"widget.mission.passesCount": { ru: "Проходы / участки", en: "Passes / sections", zh: "作业行 / 分区" },
|
|
970
984
|
"widget.mission.turns": { ru: "Развороты", en: "Turns", zh: "转弯" },
|
package/dist/types.d.ts
CHANGED