poe-svelte-ui-lib 1.87.0 → 1.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -100,7 +100,7 @@ Telemetry_SendObject(g_telemetry_stream, g_mcfg.DevSN, "Data", dev, sizeof(dev)
100
100
  1. **«Нарисовать поле»**: кликами по карте обведите участок. Контур замыкается кликом по первой точке, двойным кликом, Enter или кнопкой «Замкнуть». Backspace убирает последнюю точку, Esc сбрасывает начатый контур. Когда поле есть, кнопка называется **«Поля»**: в этом режиме клики по карте обводят ещё одно поле.
101
101
  2. **«Запретные зоны»**: обведите места, где нельзя лететь и распылять: деревья, опоры ЛЭП, постройки, водоёмы. Зон может быть сколько угодно.
102
102
  3. **Точка взлёта** (синяя) видна всегда, её можно перетащить в любой момент.
103
- 4. В блоке **«Маршрут и внесение»** задайте ширину захвата, отступ от границ, высоту, скорость, норму внесения и объём бака. У каждого поля свой угол проходов: новое поле сразу получает лучший угол, «Подобрать» пересчитывает углы всех полей, а ползунок «Угол для всех полей» ставит всем один угол, например чтобы проходы шли вдоль рядов посадки. Угол отдельного поля правится в его строке в блоке «Итог».
103
+ 4. В блоке **«Маршрут и внесение»** задайте ширину захвата, отступ от границ, высоту, скорость, норму внесения и объём бака. У каждого поля свой угол проходов: новое поле сразу получает лучший угол, «Подобрать» пересчитывает углы всех полей приближениями по полному расчёту траектории: шаг 5° по всем направлениям и вдоль сторон поля, затем 1° около трёх лучших, затем 0,1° около победителя; лучший угол даёт наименьшее время облёта с учётом разворотов. Пока идёт подбор, на кнопке и карте видно колесо ожидания с процентом (на большом поле это несколько секунд), а ползунок «Угол для всех полей» ставит всем один угол, например чтобы проходы шли вдоль рядов посадки. Угол отдельного поля правится в его строке в блоке «Итог».
104
104
  5. Блок **«Итог»** показывает контуры, площадь к обработке, покрытие, длину пути, время, расход раствора и число дозаправок.
105
105
  6. **«Скачать»**, **«Копировать»** или **«Отправить»** миссию.
106
106
 
@@ -15,6 +15,8 @@
15
15
  import FitIcon from "./FitIcon.svelte"
16
16
  import MapAgro from "./mission/MapAgro.svelte"
17
17
  import MapRoute from "./mission/MapRoute.svelte"
18
+ import { DEFAULT_MISSION_PLAN } from "./mission/plan"
19
+ import { DEFAULT_ROUTE_PLAN } from "./mission/route"
18
20
  import * as UI from ".."
19
21
  import { MapLibre, NavigationControl, ScaleControl, GeolocateControl, FullScreenControl, Marker, Popup, Line, CustomControl } from "./mapWrapper"
20
22
 
@@ -33,9 +35,10 @@
33
35
  collapsed = $bindable(false),
34
36
  persistKey,
35
37
  mode = "objects",
36
- plan = $bindable(),
38
+ /* Значения по умолчанию обязательны: режимы получают их через bind:, а bind к undefined при запасном значении Svelte запрещает */
39
+ plan = $bindable(structuredClone(DEFAULT_MISSION_PLAN)),
37
40
  onPlanChange,
38
- route = $bindable(),
41
+ route = $bindable(structuredClone(DEFAULT_ROUTE_PLAN)),
39
42
  onRouteChange,
40
43
  onSend,
41
44
  }: IWidgetMapProps = $props()
@@ -12,10 +12,11 @@
12
12
  import { MapLibre, NavigationControl, ScaleControl, FullScreenControl } from "../mapWrapper"
13
13
  import MissionLayers from "./MissionLayers.svelte"
14
14
  import MissionSim from "./MissionSim.svelte"
15
+ import SpinnerIcon from "./SpinnerIcon.svelte"
15
16
  import { missionProgress } from "./progress"
16
17
  import MissionIcon from "./MissionIcon.svelte"
17
18
  import ArrowIcon from "../../libIcons/ArrowIcon.svelte"
18
- import { planFields, bestFieldAngle, toMission, fieldArea, fieldsArea, type LngLat, type SprayControl } from "./coverage"
19
+ import { planFields, bestFieldAngle, searchFieldAngle, toMission, fieldArea, fieldsArea, type LngLat, type SprayControl } from "./coverage"
19
20
  import { DEFAULT_MISSION_PLAN, type IMissionPlan } from "./plan"
20
21
 
21
22
  /* Режим «Агро» карты: часть пропсов WidgetMap */
@@ -39,7 +40,7 @@
39
40
  componentClass = "",
40
41
  label = {},
41
42
  height = 520,
42
- plan = $bindable({ ...DEFAULT_MISSION_PLAN }),
43
+ plan = $bindable(structuredClone(DEFAULT_MISSION_PLAN)),
43
44
  collapsed = $bindable(false),
44
45
  persistKey,
45
46
  overlay,
@@ -95,11 +96,11 @@
95
96
  }
96
97
  const finishDraw = () => {
97
98
  if (draft.length >= 3) {
98
- if (mode === "field")
99
- update({
100
- fields: [...fieldList, { contour: draft, angle: bestFieldAngle(draft, plan.holes, plan.spacing, plan.margin) }],
101
- start: plan.start ?? draft[0],
102
- })
99
+ if (mode === "field") {
100
+ const contour = draft
101
+ update({ fields: [...fieldList, { contour, angle: bestFieldAngle(contour, plan.holes, plan.spacing, plan.margin) }], start: plan.start ?? contour[0] })
102
+ searchAngles([contour])
103
+ }
103
104
  if (mode === "hole") update({ holes: [...plan.holes, draft] })
104
105
  }
105
106
  draft = []
@@ -189,9 +190,22 @@
189
190
  }),
190
191
  )
191
192
 
192
- const autoAngle = () => {
193
- update({ fields: fieldList.map((f) => ({ ...f, angle: bestFieldAngle(f.contour, plan.holes, plan.spacing, plan.margin) })) })
193
+ /* Подбор угла полным расчётом приближениями (searchFieldAngle): поля по очереди, угол поля обновляется сразу после его подбора; на кнопке и карте колесо ожидания с процентом */
194
+ let searching: number | null = $state(null)
195
+ const searchAngles = async (contours: LngLat[][]) => {
196
+ if (searching !== null || !contours.length) return
197
+ searching = 0
198
+ try {
199
+ for (const [k, contour] of contours.entries()) {
200
+ const angle = await searchFieldAngle(contour, plan.holes, plan.spacing, plan.margin, plan.speed, (f) => (searching = (k + f) / contours.length))
201
+ /* Контур могли изменить или удалить, пока шёл подбор — тогда угол не ставится */
202
+ if (fieldList.some((f) => f.contour === contour)) update({ fields: fieldList.map((f) => (f.contour === contour ? { ...f, angle } : f)) })
203
+ }
204
+ } finally {
205
+ searching = null
206
+ }
194
207
  }
208
+ const autoAngle = () => searchAngles(fieldList.filter((f) => f.contour.length >= 3).map((f) => f.contour))
195
209
 
196
210
  let stats = $derived.by(() => {
197
211
  const area = fieldsArea(fields, plan.holes)
@@ -517,6 +531,16 @@
517
531
  onCursor={(ll) => (cursor = ll)}
518
532
  />
519
533
  {@render overlay?.()}
534
+ {#if searching !== null}
535
+ <!-- Колесо ожидания поверх карты, пока подбирается угол -->
536
+ <div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
537
+ <div class="flex items-center gap-2 rounded-xl bg-white/90 px-4 py-2 text-[13px] font-semibold text-black shadow-lg tabular-nums">
538
+ <span class="size-6 text-(--accent-color)"><SpinnerIcon /></span>
539
+ {$T("widget.mission.searching")}
540
+ {Math.round(searching * 100)}%
541
+ </div>
542
+ </div>
543
+ {/if}
520
544
  {#if simulate}
521
545
  <MissionSim path={simPath} showSpray icon="quad" bind:progress={simProgress} />
522
546
  {/if}
@@ -555,7 +579,7 @@
555
579
  wrapperClass="w-auto"
556
580
  componentClass="bg-blue"
557
581
  content={{ name: $T("widget.mission.auto"), info: { text: $T("widget.mission.autoHint"), side: "top" } }}
558
- disabled={!hasField}
582
+ disabled={!hasField || searching !== null}
559
583
  onClick={autoAngle}
560
584
  />
561
585
  </div>
@@ -270,7 +270,11 @@
270
270
  ><span class="block h-full rounded-full bg-(--accent-color)" style="width: {Math.round(headerProgress * 100)}%"></span></span
271
271
  >
272
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}
273
+ · {live.finished
274
+ ? $T("widget.route.finished")
275
+ : live.target > route.points.length
276
+ ? $T("widget.route.returning")
277
+ : `${$T("widget.route.toPoint")} ${live.target}/${route.points.length}`}{/if}
274
278
  </span>
275
279
  {/if}
276
280
  <span>{stats.points} {$T("widget.route.pts")} · {km(stats.length)}</span>
@@ -0,0 +1,10 @@
1
+ <!-- $lib/WidgetMap/mission/SpinnerIcon.svelte — колесо ожидания размером с родителя: подбор угла проходов -->
2
+ <script lang="ts"></script>
3
+
4
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24"
5
+ ><path fill="currentColor" d="M12 2A10 10 0 1 0 22 12A10 10 0 0 0 12 2Zm0 18a8 8 0 1 1 8-8A8 8 0 0 1 12 20Z" opacity=".35" /><path
6
+ fill="currentColor"
7
+ d="M20 12h2A10 10 0 0 0 12 2V4A8 8 0 0 1 20 12Z"
8
+ ><animateTransform attributeName="transform" dur="0.8s" from="0 12 12" repeatCount="indefinite" to="360 12 12" type="rotate" /></path
9
+ ></svg
10
+ >
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const SpinnerIcon: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type SpinnerIcon = InstanceType<typeof SpinnerIcon>;
18
+ export default SpinnerIcon;
@@ -44,6 +44,7 @@ export declare const holesOf: (field: LngLat[], holes: LngLat[][]) => LngLat[][]
44
44
  export declare const fieldsArea: (fields: LngLat[][], holes: LngLat[][]) => number;
45
45
  export declare const planFields: (input: IFieldsInput) => ICoverageResult | null;
46
46
  export declare const bestFieldAngle: (contour: LngLat[], holes: LngLat[][], spacing: number, margin: number) => number;
47
+ export declare const searchFieldAngle: (contour: LngLat[], holes: LngLat[][], spacing: number, margin: number, speed: number, onProgress?: (fraction: number) => void) => Promise<number>;
47
48
  export type SprayControl = "none" | "sprayer" | "servo";
48
49
  export interface IMissionExport {
49
50
  alt: number;
@@ -726,6 +726,59 @@ 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
+ /* Подбор угла поля приближениями по полному расчёту траектории: шаг 5° по всем 180° и направления сторон поля, затем шаг 1° около трёх лучших, затем 0.1° около победителя. Мерило — время облёта: путь со скоростью плюс около 4 с на разворот, недопокрытая площадь штрафуется. Считается частями, чтобы интерфейс не замирал */
730
+ export const searchFieldAngle = async (contour, holes, spacing, margin, speed, onProgress) => {
731
+ const own = holesOf(contour, holes);
732
+ const area = fieldArea(contour, own);
733
+ const norm = (a) => Math.round((((a % 180) + 180) % 180) * 10) / 10;
734
+ const cache = new Map();
735
+ const cost = (a) => {
736
+ const key = norm(a);
737
+ const hit = cache.get(key);
738
+ if (hit !== undefined)
739
+ return hit;
740
+ const r = planCoverage({ field: contour, holes: own, angle: key, spacing, margin });
741
+ /* Недопокрытая площадь штрафуется: недолитый метр прохода стоит как трёхкратный полёт по нему — за ним пришлось бы возвращаться */
742
+ const missed = r ? Math.max(0, area / spacing - r.sprayLength) : 0;
743
+ const c = r ? (r.sprayLength + r.turnLength + r.transitLength + missed * 3) / Math.max(0.1, speed) + r.turns * 4 + r.unreachable * 1e6 : Infinity;
744
+ cache.set(key, c);
745
+ return c;
746
+ };
747
+ const yieldUi = () => new Promise((r) => setTimeout(r, 0));
748
+ /* Направления сторон поля: вдоль длинной стороны проходы часто ложатся лучше всего */
749
+ const kLon = Math.cos(((contour[0]?.[1] ?? 0) * Math.PI) / 180);
750
+ const edges = contour.map((p, i) => {
751
+ const q = contour[(i + 1) % contour.length];
752
+ return norm((Math.atan2((q[0] - p[0]) * kLon, q[1] - p[1]) * 180) / Math.PI);
753
+ });
754
+ const coarse = [...new Set([...Array.from({ length: 36 }, (_, i) => i * 5), ...edges])];
755
+ const stages = coarse.length + 3 * 9 + 11;
756
+ let done = 0;
757
+ const run = async (angles) => {
758
+ for (const a of angles) {
759
+ cost(a);
760
+ done++;
761
+ onProgress?.(Math.min(1, done / stages));
762
+ await yieldUi();
763
+ }
764
+ };
765
+ await run(coarse);
766
+ /* Три лучших диапазона, не ближе 5° друг к другу */
767
+ const ranked = [...coarse].sort((a, b) => cost(a) - cost(b));
768
+ const seeds = [];
769
+ for (const a of ranked) {
770
+ if (seeds.every((b) => Math.min(Math.abs(a - b), 180 - Math.abs(a - b)) >= 5))
771
+ seeds.push(a);
772
+ if (seeds.length === 3)
773
+ break;
774
+ }
775
+ await run(seeds.flatMap((sd) => Array.from({ length: 9 }, (_, i) => norm(sd - 4 + i))));
776
+ let best = [...cache.entries()].reduce((m, e) => (e[1] < m[1] ? e : m))[0];
777
+ await run(Array.from({ length: 11 }, (_, i) => norm(best - 0.5 + i * 0.1)));
778
+ best = [...cache.entries()].reduce((m, e) => (e[1] < m[1] ? e : m))[0];
779
+ onProgress?.(1);
780
+ return best;
781
+ };
729
782
  export const toWaypointsFile = (res, home, opt) => toMission(res, home, opt).text;
730
783
  /* Миссия и номера команд её точек маршрута: по ним прогресс считается из WPNum телеметрии */
731
784
  export const toMission = (res, home, opt) => {
@@ -2,7 +2,7 @@
2
2
  export const DEFAULT_MISSION_PLAN = {
3
3
  fields: [
4
4
  {
5
- angle: 24.5,
5
+ angle: 22.7,
6
6
  contour: [
7
7
  [27.458515, 53.888433],
8
8
  [27.458569, 53.888949],
@@ -23,7 +23,7 @@ export const DEFAULT_MISSION_PLAN = {
23
23
  ],
24
24
  },
25
25
  {
26
- angle: 5,
26
+ angle: 12.1,
27
27
  contour: [
28
28
  [27.463107, 53.888261],
29
29
  [27.464298, 53.888191],
@@ -10,5 +10,6 @@ export interface IMissionProgress {
10
10
  sprayTotal: number;
11
11
  target: number;
12
12
  total: number;
13
+ finished: boolean;
13
14
  }
14
15
  export declare const missionProgress: (nav: IMissionNav[], wpNum: number | undefined, pos: LngLat | undefined) => IMissionProgress | null;
@@ -16,9 +16,9 @@ export const missionProgress = (nav, wpNum, pos) => {
16
16
  let k = nav.findIndex((n) => n.seq >= wpNum);
17
17
  const sprayTotal = cumSpray[cumSpray.length - 1];
18
18
  if (k === -1)
19
- return { fraction: 1, sprayDone: sprayTotal, sprayTotal, target: nav.length, total: nav.length };
19
+ return { fraction: 1, sprayDone: sprayTotal, sprayTotal, target: nav.length, total: nav.length, finished: true };
20
20
  if (k === 0)
21
- return { fraction: 0, sprayDone: 0, sprayTotal, target: 1, total: nav.length };
21
+ return { fraction: 0, sprayDone: 0, sprayTotal, target: 1, total: nav.length, finished: false };
22
22
  /* Часть текущего отрезка: проекция положения аппарата на отрезок от прошлой точки к цели */
23
23
  const a = nav[k - 1].point;
24
24
  const b = nav[k].point;
@@ -35,5 +35,5 @@ export const missionProgress = (nav, wpNum, pos) => {
35
35
  }
36
36
  const done = cum[k - 1] + part * leg;
37
37
  const sprayDone = cumSpray[k - 1] + (nav[k].spray ? part * leg : 0);
38
- return { fraction: done / total, sprayDone, sprayTotal, target: k, total: nav.length };
38
+ return { fraction: done / total, sprayDone, sprayTotal, target: k, total: nav.length, finished: false };
39
39
  };
@@ -780,9 +780,9 @@ const translations = {
780
780
  "widget.mission.angle": { ru: "Угол проходов", en: "Pass angle", zh: "作业角度" },
781
781
  "widget.mission.auto": { ru: "Подобрать", en: "Optimize", zh: "优化" },
782
782
  "widget.mission.autoHint": {
783
- ru: "Для каждого поля свой угол: самый короткий путь с наименьшим числом разворотов и переходов",
784
- en: "A separate angle for each field: the shortest path with the fewest turns and transits",
785
- zh: "为每个田块单独计算:路径最短、转弯和转场最少的角度",
783
+ ru: "Для каждого поля перебираются углы полным расчётом траектории: шаг 5°, затем 1° и 0,1°. Лучший — с наименьшим временем облёта, где учитываются путь и развороты",
784
+ en: "Angles are tried for each field with the full path calculation: 5° steps, then 1° and 0.1°. The best one has the shortest flight time, counting the path and turns",
785
+ zh: "对每个田块用完整航线计算逐步尝试角度:先 5°,再 1° 和 0.1°。最佳角度的飞行时间最短(计入航程和转弯)",
786
786
  },
787
787
  "widget.mission.spacing": { ru: "Ширина захвата, м", en: "Swath width, m", zh: "喷幅,米" },
788
788
  "widget.mission.margin": { ru: "Отступ от границ, м", en: "Edge margin, m", zh: "边界距离,米" },
@@ -977,8 +977,10 @@ const translations = {
977
977
  en: "Mission progress from the vehicle telemetry (WPNum)",
978
978
  zh: "根据飞行器遥测 (WPNum) 的任务进度",
979
979
  },
980
+ "widget.route.finished": { ru: "выполнено", en: "done", zh: "已完成" },
980
981
  "widget.route.returning": { ru: "возврат", en: "returning", zh: "返航" },
981
982
  "widget.route.toPoint": { ru: "к точке", en: "to point", zh: "飞往航点" },
983
+ "widget.mission.searching": { ru: "Подбор угла…", en: "Finding angle…", zh: "正在寻找角度…" },
982
984
  "widget.mission.ha": { ru: "га", en: "ha", zh: "公顷" },
983
985
  "widget.mission.passesCount": { ru: "Проходы / участки", en: "Passes / sections", zh: "作业行 / 分区" },
984
986
  "widget.mission.turns": { ru: "Развороты", en: "Turns", zh: "转弯" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-svelte-ui-lib",
3
- "version": "1.87.0",
3
+ "version": "1.88.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Oleg-Perevyshin/ElementsUI.git"