poe-svelte-ui-lib 1.84.0 → 1.85.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.
@@ -27,8 +27,7 @@
27
27
 
28
28
  {#snippet GraphIsTest()}
29
29
  <UI.Switch
30
- wrapperClass="bg-blue"
31
- label={{ name: $T("constructor.props.istest") }}
30
+ label={{ name: $T("constructor.props.simulation") }}
32
31
  value={component.properties.isTest ? 1 : 0}
33
32
  options={[{ id: crypto.randomUUID(), value: 0, class: "" }]}
34
33
  onChange={(value) => updateProperty("isTest", value === 1, component, onPropertyChange)}
@@ -44,35 +43,37 @@
44
43
  />
45
44
  {/snippet}
46
45
 
47
- {#if forConstructor}
48
- <div class="flex flex-col gap-2">
49
- <PropsGroup label={$T("constructor.props.group.general")}>
50
- <CommonSnippets snippet="Access" {component} {onPropertyChange} />
51
- <CommonSnippets snippet="Variable" {VARIABLE_OPTIONS} {component} {onPropertyChange} />
52
- </PropsGroup>
53
- <PropsGroup label={$T("constructor.props.group.appearance")}>
54
- <CommonSnippets snippet="Label" {component} {onPropertyChange} />
55
- <CommonSnippets snippet="LabelAlign" initialValue={initialAlign} {component} {onPropertyChange} />
56
- </PropsGroup>
57
- <PropsGroup label={$T("constructor.props.group.behavior")}>
58
- {@render GraphIsTest()}
59
- {@render GraphSettings()}
60
- </PropsGroup>
46
+ <div class="flex flex-col gap-2">
47
+ <PropsGroup>
48
+ {@render GraphIsTest()}
49
+ </PropsGroup>
50
+ <!-- В режиме имитации остальные свойства не учитываются, поэтому вся панель бледная и недоступна -->
51
+ <div class={twMerge("flex flex-col gap-2 transition-opacity", component.properties.isTest && "opacity-40 grayscale")} inert={component.properties.isTest}>
52
+ {#if forConstructor}
53
+ <PropsGroup label={$T("constructor.props.group.general")}>
54
+ <CommonSnippets snippet="Access" {component} {onPropertyChange} />
55
+ <CommonSnippets snippet="Variable" {VARIABLE_OPTIONS} {component} {onPropertyChange} />
56
+ </PropsGroup>
57
+ <PropsGroup label={$T("constructor.props.group.appearance")}>
58
+ <CommonSnippets snippet="Label" {component} {onPropertyChange} />
59
+ <CommonSnippets snippet="LabelAlign" initialValue={initialAlign} {component} {onPropertyChange} />
60
+ </PropsGroup>
61
+ <PropsGroup label={$T("constructor.props.group.behavior")}>
62
+ {@render GraphSettings()}
63
+ </PropsGroup>
64
+ {:else}
65
+ <PropsGroup label={$T("constructor.props.group.general")}>
66
+ <CommonSnippets snippet="Identificator" {component} {onPropertyChange} />
67
+ <CommonSnippets snippet="Access" {component} {onPropertyChange} />
68
+ </PropsGroup>
69
+ <PropsGroup label={$T("constructor.props.group.appearance")}>
70
+ <CommonSnippets snippet="Label" {component} {onPropertyChange} />
71
+ <CommonSnippets snippet="LabelClass" initialValue={initialAlign} {component} {onPropertyChange} />
72
+ </PropsGroup>
73
+ <PropsGroup label={$T("constructor.props.group.behavior")}>
74
+ <CommonSnippets snippet="WrapperClass" {component} {onPropertyChange} />
75
+ {@render GraphSettings()}
76
+ </PropsGroup>
77
+ {/if}
61
78
  </div>
62
- {:else}
63
- <div class="flex flex-col gap-2">
64
- <PropsGroup label={$T("constructor.props.group.general")}>
65
- <CommonSnippets snippet="Identificator" {component} {onPropertyChange} />
66
- <CommonSnippets snippet="Access" {component} {onPropertyChange} />
67
- </PropsGroup>
68
- <PropsGroup label={$T("constructor.props.group.appearance")}>
69
- <CommonSnippets snippet="Label" {component} {onPropertyChange} />
70
- <CommonSnippets snippet="LabelClass" initialValue={initialAlign} {component} {onPropertyChange} />
71
- </PropsGroup>
72
- <PropsGroup label={$T("constructor.props.group.behavior")}>
73
- <CommonSnippets snippet="WrapperClass" {component} {onPropertyChange} />
74
- {@render GraphIsTest()}
75
- {@render GraphSettings()}
76
- </PropsGroup>
77
- </div>
78
- {/if}
79
+ </div>
@@ -35,7 +35,7 @@
35
35
 
36
36
  **Статический.** Если передан непустой `historyData`, график рисует готовый ряд с отметками времени, например энергию за сутки по часам. Точки сортируются по времени сами. Новый массив `historyData` перерисовывает график целиком.
37
37
 
38
- `isTest` включает случайные данные. Удобно, чтобы посмотреть график без устройства.
38
+ `isTest` включает режим имитации: случайные данные, точка каждые 50 мс. Удобно, чтобы посмотреть график без устройства. Пока режим включён, данные устройства и `refreshRate` не учитываются. В конструкторе все остальные свойства становятся бледными и недоступными, пока имитацию не выключат.
39
39
 
40
40
  ## Пропсы
41
41
 
@@ -47,7 +47,7 @@
47
47
  | `streamingData.data` | `{ name, value }[] \| null` | `[]` | Текущие значения линий для живого режима |
48
48
  | `refreshRate` | `number` | `0` | Период добавления точек, мс. `0` — по изменению значений |
49
49
  | `historyData` | `{ name, points: { value, timestamp }[] }[]` | `[]` | Готовый ряд для статического режима. `timestamp` в миллисекундах, как `Date.now()` |
50
- | `isTest` | `boolean` | `false` | Случайные данные, точка каждые 50 мс |
50
+ | `isTest` | `boolean` | `false` | Режим имитации: случайные данные, точка каждые 50 мс |
51
51
 
52
52
  Колбэков нет.
53
53
 
@@ -52,6 +52,7 @@ Telemetry_SendObject(g_telemetry_stream, g_mcfg.DevSN, "Data", dev, sizeof(dev)
52
52
  - **Траектория.** Точка добавляется только при смене координат. Курс и высота могут меняться чаще.
53
53
  - **«Показать все»** в шапке вписывает все видимые объекты в экран, один объект приближает. Сама карта вид не меняет, ведь объектов может быть много.
54
54
  - **Клик по маркеру** открывает карточку: имя, группа, координаты, высота, курс, спутники, серийный номер. Кнопки «Копировать» (всё в буфер обмена) и «Скрыть» (до следующего обновления объекта).
55
+ - **Слежение.** Кнопка «Следить» в карточке залипает. Один отслеживаемый объект всегда в центре карты. Несколько вписываются в карту при включении слежения, дальше карта держит в центре их середину. Масштаб остаётся за оператором: карта сама отдаляется, только когда объект уходит за край. Под маркером отслеживаемого объекта точка, она видна и при закрытой карточке. Если объект пропал с карты, слежение за ним продолжится, когда он появится снова. «Скрыть» снимает слежение.
55
56
  - **Шапка** показывает, сколько объектов на связи. Зелёный значит все, серый часть, красный ни одного.
56
57
  - **Только 2D.** Наклонить карту нельзя. Правая кнопка мыши только поворачивает карту, компас под кнопками масштаба возвращает её на север.
57
58
  - Карта светлая или тёмная по теме страницы и переключается вместе с ней.
@@ -61,7 +62,7 @@ Telemetry_SendObject(g_telemetry_stream, g_mcfg.DevSN, "Data", dev, sizeof(dev)
61
62
 
62
63
  ## Режим имитации
63
64
 
64
- `isTest` включает два объекта `SIM-1` и `SIM-2` без устройства. Они летают по одному маршруту над Минском, в пределах примерно 1,5 км, и обновляются раз в полсекунды. При включении карта переходит к этому району. Пока режим включён, `value` и `groups` не учитываются, в конструкторе поле групп неактивно.
65
+ `isTest` включает имитацию шести аппаратов `SIM-1`-`SIM-6` без устройства. Они летают над одним районом Минска, в пределах пары километров, и обновляются раз в полсекунды. На связи всегда три аппарата. Через 40-100 секунд один из них пропадает, и его сразу сменяет тот, кто молчит дольше всех. Поэтому видно все состояния маркера: свежий, красный устаревший, скрытый и снова появившийся. При включении карта переходит к этому району. Пока режим включён, `value` и `groups` не учитываются. В конструкторе все остальные свойства становятся бледными и недоступными, пока имитацию не выключат.
65
66
 
66
67
  ## Пропсы
67
68
 
@@ -65,17 +65,69 @@
65
65
  /* (0, 0) и нечисловые координаты — признак отсутствия фикса */
66
66
  const hasFix = (lat: unknown, lon: unknown) => Number.isFinite(lat) && Number.isFinite(lon) && (lat !== 0 || lon !== 0)
67
67
 
68
- /* Режим имитации: два объекта на одном маршруте, данные и группы устройства не учитываются */
69
- const SIM_GROUPS = ["SIM-1", "SIM-2"]
70
- const SIM_SN = ["10.10.001-01_0000804500006B3600004000:F76F", "10.10.001-01_0000804500006B3600004001:3A9C"]
68
+ /* Режим имитации: шесть аппаратов над одним районом сами выходят на связь и пропадают, данные и группы устройства не учитываются */
69
+ const SIM_COUNT = 6
70
+ const SIM_ONLINE = 3
71
+ const SIM_GROUPS = Array.from({ length: SIM_COUNT }, (_, i) => `SIM-${i + 1}`)
72
+ const SIM_SN = [
73
+ "10.10.001-01_0000804500006B3600004000:F76F",
74
+ "10.10.001-01_0000441B0000F68C0000561C:36B1",
75
+ "20.00.000-01_000068B60000B33B0000A520:5180",
76
+ "00.00.000-00_0000AC670000B22B00007428:DBC5",
77
+ "25.00.000-02_0000A0850000E31300004C5C:8D50",
78
+ "50.00.000-00_0000DCDA00000C1800003194:2BC6",
79
+ ]
80
+ /* Сдвиг маршрута каждого аппарата, м: север, восток — чтобы не летали друг за другом */
81
+ const SIM_SHIFT: [number, number][] = [
82
+ [0, 0],
83
+ [-400, 700],
84
+ [700, -900],
85
+ [-800, 600],
86
+ [1100, 900],
87
+ [-600, -1200],
88
+ ]
89
+ const randomSec = (min: number, max: number) => (min + Math.random() * (max - min)) * 1000
71
90
  let simValue: Record<string, Partial<IWidgetMapData>> | undefined = $state()
72
91
  $effect(() => {
73
92
  if (!isTest) return
74
- const sims = [createFlightSim(), createFlightSim(3)]
75
- const frame = (dt: number) =>
76
- Object.fromEntries(SIM_GROUPS.map((group, i) => [group, { ...sims[i](dt), DevName: group, DevSN: SIM_SN[i] }]))
77
- simValue = frame(0)
78
- const timer = setInterval(() => (simValue = frame(0.5)), 500)
93
+ /* На связи всегда три аппарата: когда один пропадает, его сменяет тот, кто молчит дольше всех; молчащий маркер успевает покраснеть и скрыться */
94
+ const fleet = SIM_GROUPS.map((group, i) => ({
95
+ group,
96
+ sim: createFlightSim(i % 7),
97
+ online: i < SIM_ONLINE,
98
+ switchAt: i < SIM_ONLINE ? Date.now() + randomSec(20 + i * 25, 40 + i * 25) : 0,
99
+ offlineSince: i,
100
+ }))
101
+ const kmLat = 111320
102
+ const kmLon = 111320 * Math.cos((HOME.lat * Math.PI) / 180)
103
+ const frames: Record<string, Partial<IWidgetMapData>> = {}
104
+ /* Модель летит всегда; пока аппарат без связи, его последний кадр не меняется — маркер краснеет, затем скрывается */
105
+ const tick = (dt: number) => {
106
+ const t = Date.now()
107
+ fleet.forEach((f, i) => {
108
+ const data = f.sim(dt)
109
+ if (f.online && t >= f.switchAt) {
110
+ const next = fleet.filter((x) => !x.online).sort((a, b) => a.offlineSince - b.offlineSince)[0]
111
+ f.online = false
112
+ f.offlineSince = t
113
+ next.online = true
114
+ next.switchAt = t + randomSec(40, 100)
115
+ }
116
+ if (!f.online) return
117
+ frames[f.group] = {
118
+ Lat: data.Lat + SIM_SHIFT[i][0] / kmLat,
119
+ Lon: data.Lon + SIM_SHIFT[i][1] / kmLon,
120
+ Alt: data.Alt + i * 15,
121
+ Heading: data.Heading,
122
+ Sat: data.Sat - (i % 3),
123
+ DevName: f.group,
124
+ DevSN: SIM_SN[i],
125
+ }
126
+ })
127
+ simValue = { ...frames }
128
+ }
129
+ tick(0)
130
+ const timer = setInterval(() => tick(0.5), 500)
79
131
  return () => {
80
132
  clearInterval(timer)
81
133
  simValue = undefined
@@ -171,8 +223,44 @@
171
223
  const hideBoard = (group: string) => {
172
224
  const b = boards.find((x) => x.group === group)
173
225
  if (b) b.hiddenAt = b.lastChange
226
+ followed = followed.filter((g) => g !== group)
174
227
  }
175
228
 
229
+ /* Слежение: один объект держится в центре карты, несколько — вписываются в карту при включении и дальше только центрируются; масштаб остаётся за оператором, карта отдаляется сама, лишь когда объект уходит за край */
230
+ let followed: string[] = $state([])
231
+ const toggleFollow = (group: string) => (followed = followed.includes(group) ? followed.filter((g) => g !== group) : [...followed, group])
232
+ const FOLLOW_PADDING = 60
233
+ let lastFollowKey = ""
234
+ $effect(() => {
235
+ const map = mapInstance
236
+ const targets = visibleBoards.filter((b) => followed.includes(b.group))
237
+ if (!map || targets.length === 0) return
238
+ untrack(() => {
239
+ if (targets.length === 1) return map.easeTo({ center: [targets[0].lon, targets[0].lat], duration: 300 })
240
+ const lons = targets.map((b) => b.lon)
241
+ const lats = targets.map((b) => b.lat)
242
+ const bounds: [[number, number], [number, number]] = [
243
+ [Math.min(...lons), Math.min(...lats)],
244
+ [Math.max(...lons), Math.max(...lats)],
245
+ ]
246
+ const key = targets.map((b) => b.group).join(" ")
247
+ if (key !== lastFollowKey) {
248
+ lastFollowKey = key
249
+ return map.fitBounds(bounds, { padding: FOLLOW_PADDING, maxZoom: 16, duration: 300 })
250
+ }
251
+ const center: [number, number] = [(bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2]
252
+ /* Помещаются ли все объекты, если поставить их середину в центр */
253
+ const { width, height } = map.getContainer().getBoundingClientRect()
254
+ const mid = map.project(center)
255
+ const fits = targets.every((b) => {
256
+ const p = map.project([b.lon, b.lat])
257
+ return Math.abs(p.x - mid.x) <= width / 2 - FOLLOW_PADDING && Math.abs(p.y - mid.y) <= height / 2 - FOLLOW_PADDING
258
+ })
259
+ if (fits) map.easeTo({ center, duration: 300 })
260
+ else map.fitBounds(bounds, { padding: FOLLOW_PADDING, maxZoom: map.getZoom(), duration: 300 })
261
+ })
262
+ })
263
+
176
264
  const copyBoard = (b: Board) => {
177
265
  navigator.clipboard.writeText(
178
266
  `DevName: ${b.name}\nDevSN: ${b.sn}\nGroup: ${b.group}\nLat: ${b.lat.toFixed(6)} | Lon: ${b.lon.toFixed(6)} | Alt: ${b.alt.toFixed(1)}\nHeading: ${b.heading.toFixed(1)} | Satellites: ${b.sat}`,
@@ -287,6 +375,7 @@
287
375
  {/if}
288
376
 
289
377
  {#each visibleBoards as board (board.group)}
378
+ {@const isFollowed = followed.includes(board.group)}
290
379
  <Marker lnglat={{ lng: board.lon, lat: board.lat }}>
291
380
  {#snippet content()}
292
381
  <div class="flex flex-col items-center justify-center leading-none">
@@ -318,6 +407,10 @@
318
407
  {/if}
319
408
  </div>
320
409
  <p class="font-bold">{board.name}</p>
410
+ <!-- Метка слежения видна и при закрытом окне деталей -->
411
+ {#if isFollowed}
412
+ <span class="mt-0.5 size-2 rounded-full bg-(--accent-color) ring-2 ring-white" title={$T("widget.map.follow")}></span>
413
+ {/if}
321
414
  </div>
322
415
  {/snippet}
323
416
 
@@ -350,8 +443,8 @@
350
443
 
351
444
  {#if board.sn}<p class="text-center font-mono text-[0.6rem] tracking-wide uppercase">{board.sn}</p>{/if}
352
445
 
353
- <div class="flex items-center justify-between border-t border-(--hairline-color) pt-1">
354
- <div class="relative flex items-center">
446
+ <div class="grid grid-cols-[1fr_auto_1fr] items-center border-t border-(--hairline-color) pt-1">
447
+ <div class="relative flex items-center justify-self-start">
355
448
  <button
356
449
  class="flex cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-1 py-0.5 opacity-70 transition hover:opacity-100"
357
450
  onclick={(e) => {
@@ -374,7 +467,24 @@
374
467
  </div>
375
468
 
376
469
  <button
377
- class="cursor-pointer rounded-lg border-none bg-transparent p-0.5 text-(--red-color) transition hover:opacity-80"
470
+ class="flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-0.5 text-xs font-semibold transition {isFollowed
471
+ ? 'border-(--accent-color) bg-(--accent-color) text-white'
472
+ : 'border-(--hairline-color) bg-transparent opacity-80 hover:opacity-100'}"
473
+ aria-pressed={isFollowed}
474
+ onclick={() => toggleFollow(board.group)}
475
+ >
476
+ <svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" viewBox="0 0 24 24">
477
+ <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5">
478
+ <circle cx="12" cy="12" r="7" />
479
+ <circle cx="12" cy="12" r="2.5" fill={isFollowed ? "currentColor" : "none"} />
480
+ <path d="M12 2v3m0 14v3M2 12h3m14 0h3" />
481
+ </g>
482
+ </svg>
483
+ {$T("widget.map.follow")}
484
+ </button>
485
+
486
+ <button
487
+ class="cursor-pointer justify-self-end rounded-lg border-none bg-transparent p-0.5 text-(--red-color) transition hover:opacity-80"
378
488
  aria-label="Hide"
379
489
  title="Hide"
380
490
  onclick={() => hideBoard(board.group)}
@@ -8,6 +8,7 @@
8
8
  import WidgetAccentPicker from "../WidgetAccentPicker.svelte"
9
9
  import CommonSnippets from "../CommonSnippets.svelte"
10
10
  import PropsGroup from "../PropsGroup.svelte"
11
+ import { twMerge } from "tailwind-merge"
11
12
 
12
13
  const {
13
14
  component,
@@ -37,53 +38,58 @@
37
38
  </script>
38
39
 
39
40
  <div class="flex flex-col gap-2">
40
- <PropsGroup label={$T("constructor.props.group.general")}>
41
- <TextsEditor {component} {onPropertyChange} />
41
+ <PropsGroup>
42
42
  <UI.Switch
43
43
  label={{ name: $T("constructor.props.simulation") }}
44
44
  value={component.properties.isTest ? 1 : 0}
45
45
  options={[{ id: crypto.randomUUID(), value: 0, class: "" }]}
46
46
  onChange={(value) => updateProperty("isTest", value === 1, component, onPropertyChange)}
47
47
  />
48
- <UI.Input label={{ name: $T("widget.map.groups") }} disabled={component.properties.isTest} value={(component.properties.groups ?? ["MAV"]).join(" ")} onUpdate={setGroups} />
49
- <WidgetAccentPicker
50
- value={component.properties.componentClass ?? ""}
51
- onUpdate={(value) => updateProperty("componentClass", value, component, onPropertyChange)}
52
- />
53
- </PropsGroup>
54
- <PropsGroup label={$T("constructor.props.group.appearance")}>
55
- <UI.Select
56
- label={{ name: $T("widget.map.height") }}
57
- type="buttons"
58
- options={HEIGHT_OPTIONS}
59
- value={HEIGHT_OPTIONS.find((opt) => opt.value === (component.properties.height ?? 400)) ?? HEIGHT_OPTIONS[1]}
60
- onUpdate={(option) => updateProperty("height", (option as { value: number }).value, component, onPropertyChange)}
61
- />
62
- <CommonSnippets
63
- snippet="IconsLib"
64
- initialValue={{
65
- name: $T("constructor.props.markerIcon"),
66
- icon: component.properties.markerIcon,
67
- updateProperty: (icon: string) => updateProperty("markerIcon", icon as string, component, onPropertyChange),
68
- icons: { array: ICONS },
69
- }}
70
- {component}
71
- {onPropertyChange}
72
- />
73
- </PropsGroup>
74
- <PropsGroup label={$T("widget.map.defaults")}>
75
- <UI.Switch
76
- label={{ name: $T("constructor.props.map.track") }}
77
- value={component.properties.trackEnabled ? 1 : 0}
78
- options={[{ id: crypto.randomUUID(), value: 0, class: "" }]}
79
- onChange={(value) => updateProperty("trackEnabled", Boolean(value), component, onPropertyChange)}
80
- />
81
- <UI.Select
82
- label={{ name: $T("constructor.props.map.trackLength") }}
83
- type="input"
84
- options={TRACK_LENGTH_OPTIONS}
85
- value={TRACK_LENGTH_OPTIONS.find((opt) => opt.value === (component.properties.trackLength ?? 1000))}
86
- onUpdate={(option) => updateProperty("trackLength", (option as { value: number }).value, component, onPropertyChange)}
87
- />
88
48
  </PropsGroup>
49
+ <!-- В режиме имитации остальные свойства не учитываются, поэтому вся панель бледная и недоступна -->
50
+ <div class={twMerge("flex flex-col gap-2 transition-opacity", component.properties.isTest && "opacity-40 grayscale")} inert={component.properties.isTest}>
51
+ <PropsGroup label={$T("constructor.props.group.general")}>
52
+ <TextsEditor {component} {onPropertyChange} />
53
+ <UI.Input label={{ name: $T("widget.map.groups") }} value={(component.properties.groups ?? ["MAV"]).join(" ")} onUpdate={setGroups} />
54
+ <WidgetAccentPicker
55
+ value={component.properties.componentClass ?? ""}
56
+ onUpdate={(value) => updateProperty("componentClass", value, component, onPropertyChange)}
57
+ />
58
+ </PropsGroup>
59
+ <PropsGroup label={$T("constructor.props.group.appearance")}>
60
+ <UI.Select
61
+ label={{ name: $T("widget.map.height") }}
62
+ type="buttons"
63
+ options={HEIGHT_OPTIONS}
64
+ value={HEIGHT_OPTIONS.find((opt) => opt.value === (component.properties.height ?? 400)) ?? HEIGHT_OPTIONS[1]}
65
+ onUpdate={(option) => updateProperty("height", (option as { value: number }).value, component, onPropertyChange)}
66
+ />
67
+ <CommonSnippets
68
+ snippet="IconsLib"
69
+ initialValue={{
70
+ name: $T("constructor.props.markerIcon"),
71
+ icon: component.properties.markerIcon,
72
+ updateProperty: (icon: string) => updateProperty("markerIcon", icon as string, component, onPropertyChange),
73
+ icons: { array: ICONS },
74
+ }}
75
+ {component}
76
+ {onPropertyChange}
77
+ />
78
+ </PropsGroup>
79
+ <PropsGroup label={$T("widget.map.defaults")}>
80
+ <UI.Switch
81
+ label={{ name: $T("constructor.props.map.track") }}
82
+ value={component.properties.trackEnabled ? 1 : 0}
83
+ options={[{ id: crypto.randomUUID(), value: 0, class: "" }]}
84
+ onChange={(value) => updateProperty("trackEnabled", Boolean(value), component, onPropertyChange)}
85
+ />
86
+ <UI.Select
87
+ label={{ name: $T("constructor.props.map.trackLength") }}
88
+ type="input"
89
+ options={TRACK_LENGTH_OPTIONS}
90
+ value={TRACK_LENGTH_OPTIONS.find((opt) => opt.value === (component.properties.trackLength ?? 1000))}
91
+ onUpdate={(option) => updateProperty("trackLength", (option as { value: number }).value, component, onPropertyChange)}
92
+ />
93
+ </PropsGroup>
94
+ </div>
89
95
  </div>
@@ -114,7 +114,7 @@
114
114
 
115
115
  ## Режим имитации
116
116
 
117
- `isTest` включает полёт без устройства: прибор получает кадр каждые 50 мс от той же модели полёта, что в прошивке 20.00.000-01. Это облёт маршрута над Минском. Пока режим включён, `value`, `id` и `limits.staleMs` не учитываются, в конструкторе эти поля неактивны. Остальные границы `limits` работают, их удобно подбирать на имитации. Модель полёта лежит в `src/lib/flightSim.ts`.
117
+ `isTest` включает полёт без устройства: прибор получает кадр каждые 50 мс от той же модели полёта, что в прошивке 20.00.000-01. Это облёт маршрута над Минском. Пока режим включён, `value`, `id` и `limits.staleMs` не учитываются. В конструкторе все остальные свойства становятся бледными и недоступными, пока имитацию не выключат. Сохранённые заголовок, цвет и границы зон на имитации видны как есть. Модель полёта лежит в `src/lib/flightSim.ts`.
118
118
 
119
119
  ## Пропсы
120
120
 
@@ -6,6 +6,7 @@
6
6
  import { updateProperty, type UIComponent, type IWidgetPFDProps, type IWidgetPFDLimits } from "../types"
7
7
  import WidgetAccentPicker from "../WidgetAccentPicker.svelte"
8
8
  import PropsGroup from "../PropsGroup.svelte"
9
+ import { twMerge } from "tailwind-merge"
9
10
 
10
11
  const {
11
12
  component,
@@ -27,35 +28,39 @@
27
28
  </script>
28
29
 
29
30
  <div class="flex flex-col gap-2">
30
- <PropsGroup label={$T("constructor.props.group.general")}>
31
- <TextsEditor {component} {onPropertyChange} />
31
+ <PropsGroup>
32
32
  <UI.Switch
33
33
  label={{ name: $T("constructor.props.simulation") }}
34
34
  value={component.properties.isTest ? 1 : 0}
35
35
  options={[{ id: crypto.randomUUID(), value: 0, class: "" }]}
36
36
  onChange={(value) => updateProperty("isTest", value === 1, component, onPropertyChange)}
37
37
  />
38
- <UI.Input
39
- label={{ name: $T("pfd.props.group") }}
40
- disabled={component.properties.isTest}
41
- value={component.properties.id ?? "MAV"}
42
- onUpdate={(value) => updateProperty("id", value as string, component, onPropertyChange)}
43
- />
44
- <WidgetAccentPicker
45
- value={component.properties.componentClass ?? ""}
46
- onUpdate={(value) => updateProperty("componentClass", value, component, onPropertyChange)}
47
- />
48
- </PropsGroup>
49
- <PropsGroup label={$T("pfd.limits")}>
50
- <div class="grid grid-cols-2 gap-2">
51
- <UI.Input label={{ name: $T("pfd.props.speed_min") }} type="number" value={limits.speedMin} onUpdate={(v) => setLimit("speedMin", v)} />
52
- <UI.Input label={{ name: $T("pfd.props.speed_max") }} type="number" value={limits.speedMax} onUpdate={(v) => setLimit("speedMax", v)} />
53
- <UI.Input label={{ name: $T("pfd.props.bank_max") }} type="number" value={limits.bankMax} onUpdate={(v) => setLimit("bankMax", v)} />
54
- <UI.Input label={{ name: $T("pfd.props.alt_max") }} type="number" value={limits.altMax} onUpdate={(v) => setLimit("altMax", v)} />
55
- <UI.Input label={{ name: $T("pfd.props.g_min") }} type="number" value={limits.gMin} onUpdate={(v) => setLimit("gMin", v)} />
56
- <UI.Input label={{ name: $T("pfd.props.g_max") }} type="number" value={limits.gMax} onUpdate={(v) => setLimit("gMax", v)} />
57
- <UI.Input label={{ name: $T("pfd.props.batt_low") }} type="number" value={limits.battLow} onUpdate={(v) => setLimit("battLow", v)} />
58
- <UI.Input disabled={component.properties.isTest} label={{ name: $T("pfd.props.stale") }} type="number" value={limits.staleMs ?? 2000} onUpdate={(v) => setLimit("staleMs", v)} />
59
- </div>
60
38
  </PropsGroup>
39
+ <!-- В режиме имитации остальные свойства не учитываются, поэтому вся панель бледная и недоступна -->
40
+ <div class={twMerge("flex flex-col gap-2 transition-opacity", component.properties.isTest && "opacity-40 grayscale")} inert={component.properties.isTest}>
41
+ <PropsGroup label={$T("constructor.props.group.general")}>
42
+ <TextsEditor {component} {onPropertyChange} />
43
+ <UI.Input
44
+ label={{ name: $T("pfd.props.group") }}
45
+ value={component.properties.id ?? "MAV"}
46
+ onUpdate={(value) => updateProperty("id", value as string, component, onPropertyChange)}
47
+ />
48
+ <WidgetAccentPicker
49
+ value={component.properties.componentClass ?? ""}
50
+ onUpdate={(value) => updateProperty("componentClass", value, component, onPropertyChange)}
51
+ />
52
+ </PropsGroup>
53
+ <PropsGroup label={$T("pfd.limits")}>
54
+ <div class="grid grid-cols-2 gap-2">
55
+ <UI.Input label={{ name: $T("pfd.props.speed_min") }} type="number" value={limits.speedMin} onUpdate={(v) => setLimit("speedMin", v)} />
56
+ <UI.Input label={{ name: $T("pfd.props.speed_max") }} type="number" value={limits.speedMax} onUpdate={(v) => setLimit("speedMax", v)} />
57
+ <UI.Input label={{ name: $T("pfd.props.bank_max") }} type="number" value={limits.bankMax} onUpdate={(v) => setLimit("bankMax", v)} />
58
+ <UI.Input label={{ name: $T("pfd.props.alt_max") }} type="number" value={limits.altMax} onUpdate={(v) => setLimit("altMax", v)} />
59
+ <UI.Input label={{ name: $T("pfd.props.g_min") }} type="number" value={limits.gMin} onUpdate={(v) => setLimit("gMin", v)} />
60
+ <UI.Input label={{ name: $T("pfd.props.g_max") }} type="number" value={limits.gMax} onUpdate={(v) => setLimit("gMax", v)} />
61
+ <UI.Input label={{ name: $T("pfd.props.batt_low") }} type="number" value={limits.battLow} onUpdate={(v) => setLimit("battLow", v)} />
62
+ <UI.Input label={{ name: $T("pfd.props.stale") }} type="number" value={limits.staleMs ?? 2000} onUpdate={(v) => setLimit("staleMs", v)} />
63
+ </div>
64
+ </PropsGroup>
65
+ </div>
61
66
  </div>
@@ -295,7 +295,6 @@ const translations = {
295
295
  "constructor.props.divider.width": { ru: "Толщина полосы", en: "Line width", zh: "线条粗细" },
296
296
  "constructor.props.disabled": { ru: "Отключить", en: "Disable", zh: "禁用" },
297
297
  "constructor.props.readonly": { ru: "Только чтение", en: "Read only", zh: "只读" },
298
- "constructor.props.istest": { ru: "Тестовые данные", en: "Test data", zh: "测试数据" },
299
298
  "constructor.props.simulation": { ru: "Режим имитации", en: "Simulation mode", zh: "模拟模式" },
300
299
  "constructor.props.refreshrate": { ru: "Скорость обновления", en: "Refresh rate", zh: "刷新速率" },
301
300
  "constructor.props.variable": { ru: "Имя переменной", en: "Variable name", zh: "变量名" },
@@ -750,6 +749,7 @@ const translations = {
750
749
  "widget.rgb.count_invalid": { ru: "Количество — целое от 1 до {max}", en: "Count must be an integer from 1 to {max}", zh: "数量必须是 1 到 {max} 的整数" },
751
750
  "widget.rgb.group": { ru: "Группа ленты (ID)", en: "Strip group (ID)", zh: "灯带组 (ID)" },
752
751
  "widget.map.title": { ru: "Карта", en: "Map", zh: "地图" },
752
+ "widget.map.follow": { ru: "Следить", en: "Follow", zh: "跟踪" },
753
753
  "widget.map.groups": { ru: "Группы полей (через пробел)", en: "Field groups (space-separated)", zh: "字段组(以空格分隔)" },
754
754
  "widget.map.height": { ru: "Высота карты", en: "Map height", zh: "地图高度" },
755
755
  "widget.map.defaults": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-svelte-ui-lib",
3
- "version": "1.84.0",
3
+ "version": "1.85.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Oleg-Perevyshin/ElementsUI.git"