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.
@@ -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
- if (!isTest) return
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 {wrapperClass} {componentClass} {label} height={height ?? 520} bind:plan bind:collapsed {persistKey} {onPlanChange} {onSend} overlay={vehicles} />
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 {wrapperClass} {componentClass} {label} height={height ?? 520} bind:route bind:collapsed {persistKey} {onRouteChange} {onSend} overlay={vehicles} />
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, toWaypointsFile, fieldArea, fieldsArea, type LngLat, type SprayControl } from "./coverage"
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
- const missionText = () => (result && hasField ? toWaypointsFile(result, plan.start ?? fields[0][0], missionOptions()) : "")
287
- const missionOptions = () => ({
288
- alt: plan.alt,
289
- speed: plan.speed,
290
- spray: plan.sprayControl,
291
- servo: { channel: plan.servoChannel, on: plan.servoOn, off: plan.servoOff },
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
- {#if stats}
396
- <span class="text-[12px] font-semibold text-(--muted-color) tabular-nums"
397
- >{fmt(stats.ha, 2)} {$T("widget.mission.ha")} · {fmt(stats.total, 2)} {$T("widget.mission.km")}</span
398
- >
399
- {/if}
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
- const missionText = () => routeToWaypointsFile(route)
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">{stats.points} {$T("widget.route.pts")} · {km(stats.length)}</span>
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
- return lines.join("\n") + "\n";
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
@@ -722,6 +722,7 @@ export interface IWidgetMapData {
722
722
  Sat: number;
723
723
  DevName: string;
724
724
  DevSN: string;
725
+ WPNum?: number;
725
726
  }
726
727
  export interface IWidgetMapProps {
727
728
  id?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-svelte-ui-lib",
3
- "version": "1.86.0",
3
+ "version": "1.87.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Oleg-Perevyshin/ElementsUI.git"