poe-svelte-ui-lib 1.10.5 → 1.10.7

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.
@@ -1,18 +1,41 @@
1
1
  <!-- $lib/ElementsUI/Graph.svelte -->
2
+ <script module lang="ts">
3
+ import type { IOption } from "../types"
4
+
5
+ /* Константы и настройки — экспортируются для переиспользования в GraphProps.svelte (редактор конструктора) */
6
+ export const REFRESH_OPTIONS: IOption[] = [
7
+ { id: "RefreshOption-AUTO", name: "AUTO", value: 0, class: "" },
8
+ { id: "RefreshOption-10", name: "10", value: 10, class: "" },
9
+ { id: "RefreshOption-25", name: "25", value: 25, class: "" },
10
+ { id: "RefreshOption-50", name: "50", value: 50, class: "" },
11
+ { id: "RefreshOption-100", name: "100", value: 100, class: "" },
12
+ { id: "RefreshOption-250", name: "250", value: 250, class: "" },
13
+ { id: "RefreshOption-500", name: "500", value: 500, class: "" },
14
+ { id: "RefreshOption-1000", name: "1000", value: 1000, class: "" },
15
+ { id: "RefreshOption-5000", name: "5000", value: 5000, class: "" },
16
+ ]
17
+ </script>
18
+
2
19
  <script lang="ts">
3
20
  import { onMount } from "svelte"
4
21
  import Select from "../Select/Select.svelte"
5
- import type { IGraphDataObject, IGraphProps, IOption } from "../types"
22
+ import type { IGraphDataObject, IGraphProps } from "../types"
6
23
 
7
24
  /* Инициализация пропсов с дефолтными значениями */
8
25
  let {
9
26
  id = crypto.randomUUID(),
10
27
  wrapperClass = "",
11
28
  label = { name: "", class: "" },
12
- streamingData = { data: [], timestamp: Date.now() },
29
+ streamingData = { data: [] },
13
30
  isTest = false,
31
+ refreshRate = 0,
32
+ historyData = [],
14
33
  }: IGraphProps = $props()
15
34
 
35
+ /* Статический режим: historyData передан целиком с реальными timestamp (Unix-эпоха в мс) —
36
+ компонент рисует весь массив разом, без интервалов/AUTO; streamingData/refreshRate игнорируются */
37
+ const isHistoryMode = $derived(!!historyData && historyData.length > 0)
38
+
16
39
  /* Состояние компонента */
17
40
  let graphData = $state<{ id: string; points: { x: number; y: number }[]; color: string; name: string }[]>([])
18
41
  let currentValues = $state<number[]>([])
@@ -22,40 +45,60 @@
22
45
  let width = $state(600)
23
46
  let height = $state(125)
24
47
 
25
- /* Константы и настройки */
26
- const REFRESH_OPTIONS: IOption[] = [
27
- { id: "RefreshOption-AUTO", name: "AUTO", value: 0, class: "" },
28
- { id: "RefreshOption-10", name: "10", value: 10, class: "" },
29
- { id: "RefreshOption-25", name: "25", value: 25, class: "" },
30
- { id: "RefreshOption-50", name: "50", value: 50, class: "" },
31
- { id: "RefreshOption-100", name: "100", value: 100, class: "" },
32
- { id: "RefreshOption-250", name: "250", value: 250, class: "" },
33
- { id: "RefreshOption-500", name: "500", value: 500, class: "" },
34
- { id: "RefreshOption-1000", name: "1000", value: 1000, class: "" },
35
- { id: "RefreshOption-5000", name: "5000", value: 5000, class: "" },
36
- ]
37
- const SCALE_OPTIONS: IOption[] = [
38
- { id: "ScaleOption-50", name: "50", value: 50, class: "" },
39
- { id: "ScaleOption-100", name: "100", value: 100, class: "" },
40
- { id: "ScaleOption-500", name: "500", value: 500, class: "" },
41
- { id: "ScaleOption-1000", name: "1000", value: 1000, class: "" },
42
- { id: "ScaleOption-2000", name: "2000", value: 2000, class: "" },
43
- ]
44
- let selectedRefreshRate = $derived(isTest ? 50 : 0)
45
- let selectedScale = $state(100)
48
+ /* selectedRefreshRate изначально берётся из пропа (сохраняется вместе с компонентом в конструкторе),
49
+ но остаётся локально переключаемым через Select ниже — присвоение этому $derived временно
50
+ переопределяет значение, пока не изменится refreshRate/isTest (проп) */
51
+ let selectedRefreshRate = $derived(isTest ? 50 : (refreshRate ?? 0))
46
52
  const maxDataPoints = $derived(selectedRefreshRate == 0 ? 20 : 100)
47
53
  const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899"]
48
54
 
49
- let previousDataTimestamp: number = $state(0)
55
+ let previousValues: number[] = $state([])
50
56
 
51
- /* Инициализация данных графиков */
57
+ /* Автомасштаб Y для живого режима: диапазон плавно "подъезжает" к фактическому min/max буфера
58
+ (±10% отступ), пересчитывается один раз за тик (не за кадр/mousemove) и сглаживается —
59
+ иначе ось дёргалась бы на каждый новый семпл */
60
+ let liveYRange = $state<{ min: number; max: number } | null>(null)
61
+
62
+ const computeAutoYRange = (values: number[]): { min: number; max: number } => {
63
+ if (values.length === 0) return { min: -1, max: 1 }
64
+ const rawMin = Math.min(...values)
65
+ const rawMax = Math.max(...values)
66
+ const pad = (rawMax - rawMin) * 0.1 || Math.abs(rawMax) * 0.1 || 1
67
+ return { min: rawMin - pad, max: rawMax + pad }
68
+ }
69
+
70
+ const updateLiveYRange = () => {
71
+ const target = computeAutoYRange(graphData.flatMap((g) => g.points.map((p) => p.y)))
72
+ if (!liveYRange) {
73
+ liveYRange = target
74
+ return
75
+ }
76
+ const smoothing = 0.15
77
+ liveYRange = {
78
+ min: liveYRange.min + (target.min - liveYRange.min) * smoothing,
79
+ max: liveYRange.max + (target.max - liveYRange.max) * smoothing,
80
+ }
81
+ }
82
+
83
+ /* Тултип при наведении — время и значения всех серий в ближайшей к курсору точке */
84
+ let tooltip = $state<{ visible: boolean; x: number; y: number; time: number; items: { name: string; value: number; color: string }[] }>({
85
+ visible: false,
86
+ x: 0,
87
+ y: 0,
88
+ time: 0,
89
+ items: [],
90
+ })
91
+
92
+ /* Инициализация данных графиков (потоковый режим) */
52
93
  const initializeGraphData = () => {
94
+ if (isHistoryMode) return
53
95
  if (typeof streamingData === "string") {
54
96
  streamingData = JSON.parse(streamingData)
55
97
  }
56
98
  if (!streamingData.data || streamingData.data.length === 0) {
57
99
  graphData = []
58
100
  currentValues = []
101
+ previousValues = []
59
102
  return
60
103
  }
61
104
  const newGraphData = (streamingData.data as IGraphDataObject[]).slice(0, 6).map((d, i) => {
@@ -79,7 +122,25 @@
79
122
  initializeGraphData()
80
123
  })
81
124
 
82
- /* Обработка входящих данных.
125
+ /* Статический режим: строим graphData один раз (и заново при каждой смене historyData) —
126
+ x = реальный timestamp точки, "текущее" значение в панели Values — последняя (по времени) точка серии */
127
+ $effect(() => {
128
+ if (!isHistoryMode) return
129
+ const series = historyData.slice(0, 6).map((s, i) => {
130
+ const sortedPoints = [...s.points].sort((a, b) => a.timestamp - b.timestamp).map((p) => ({ x: p.timestamp, y: p.value }))
131
+ return {
132
+ id: crypto.randomUUID(),
133
+ points: sortedPoints,
134
+ color: defaultColors[i % defaultColors.length],
135
+ name: s.name || `Value ${i}`,
136
+ }
137
+ })
138
+ graphData = series
139
+ currentValues = series.map((s) => (s.points.length ? s.points[s.points.length - 1].y : 0))
140
+ drawAllGraphs()
141
+ })
142
+
143
+ /* Обработка входящих потоковых данных.
83
144
  ВАЖНО: streamingData читается только внутри колбэков setInterval (асинхронный контекст),
84
145
  а не в теле $effect — иначе каждое новое сообщение (у нас до 10 Гц с устройства) делает
85
146
  streamingData зависимостью эффекта, и он пересоздаёт таймер на каждый пакет: если Refresh
@@ -88,6 +149,7 @@
88
149
  let intervalId: ReturnType<typeof setInterval>
89
150
  $effect(() => {
90
151
  clearInterval(intervalId)
152
+ if (isHistoryMode) return
91
153
  if (selectedRefreshRate > 0) {
92
154
  intervalId = setInterval(() => {
93
155
  if (!isTest && (!streamingData.data || streamingData.data.length === 0)) return
@@ -103,25 +165,30 @@
103
165
  }
104
166
  currentValues[i] = value
105
167
  })
168
+ updateLiveYRange()
106
169
  drawAllGraphs()
107
170
  }, selectedRefreshRate)
108
171
  } else if (selectedRefreshRate == 0 && !isTest) {
172
+ /* AUTO: streamingData не несёт признак "это новый пакет" (метки времени тут нет —
173
+ DeviceStore хранит только последнее значение), поэтому опрашиваем каждые 10мс и рисуем
174
+ точку только когда сами значения реально изменились, а не на каждый тик поллинга. */
109
175
  intervalId = setInterval(() => {
110
176
  if (!streamingData.data || streamingData.data.length === 0) return
111
- if (previousDataTimestamp < (streamingData.timestamp ?? Date.now())) {
112
- let newValues = (streamingData.data as IGraphDataObject[]).map((dataset) => dataset.value)
113
-
114
- newValues.forEach((value, i) => {
115
- if (!graphData[i]) return
116
- graphData[i].points.push({ x: streamingData.timestamp ?? Date.now(), y: value })
117
- if (graphData[i].points.length > maxDataPoints) {
118
- graphData[i].points.shift()
119
- }
120
- currentValues[i] = value
121
- })
122
- drawAllGraphs()
123
- previousDataTimestamp = streamingData.timestamp ?? Date.now()
124
- }
177
+ const newValues = (streamingData.data as IGraphDataObject[]).map((dataset) => dataset.value)
178
+ if (JSON.stringify(newValues) === JSON.stringify(previousValues)) return
179
+ previousValues = newValues
180
+
181
+ const now = Date.now()
182
+ newValues.forEach((value, i) => {
183
+ if (!graphData[i]) return
184
+ graphData[i].points.push({ x: now, y: value })
185
+ if (graphData[i].points.length > maxDataPoints) {
186
+ graphData[i].points.shift()
187
+ }
188
+ currentValues[i] = value
189
+ })
190
+ updateLiveYRange()
191
+ drawAllGraphs()
125
192
  }, 10)
126
193
  }
127
194
  return () => clearInterval(intervalId)
@@ -147,28 +214,117 @@
147
214
  return () => resizeObserver.disconnect()
148
215
  })
149
216
 
150
- const drawAllGraphs = () => {
151
- if (!ctx) return
152
- ctx.clearRect(0, 0, width, height)
153
-
154
- const padding = {
155
- top: 10,
156
- right: 10,
157
- bottom: 20,
158
- left: 35,
159
- }
160
-
217
+ /* Общая геометрия осей — переиспользуется отрисовкой и обработчиком наведения мыши, чтобы не дублировать формулы */
218
+ const computeGeometry = () => {
219
+ const padding = { top: 10, right: 10, bottom: 20, left: 35 }
161
220
  const graphWidth = width - padding.left - padding.right
162
221
  const graphHeight = height - padding.top - padding.bottom
163
222
 
164
223
  const allPoints = graphData.flatMap((g) => g.points)
165
- const minX = Math.min(...allPoints.map((p) => p.x))
166
- const maxX = Math.max(...allPoints.map((p) => p.x))
224
+ const minX = allPoints.length ? Math.min(...allPoints.map((p) => p.x)) : 0
225
+ const maxX = allPoints.length ? Math.max(...allPoints.map((p) => p.x)) : 1
167
226
  const timeSpan = maxX - minX || 1
168
227
 
169
- // Функции преобразования координат с учетом отступов
228
+ /* Оба режима — авто-масштаб по фактическим данным (±10% отступ, не обязательно симметрично относительно нуля):
229
+ в статическом считается разово по всему массиву, в живом — используется сглаженный liveYRange
230
+ (обновляется отдельно, в updateLiveYRange, чтобы не дёргаться на каждый кадр/mousemove) */
231
+ let yMin: number
232
+ let yMax: number
233
+ if (isHistoryMode) {
234
+ const values = allPoints.map((p) => p.y)
235
+ const range = computeAutoYRange(values)
236
+ yMin = range.min
237
+ yMax = range.max
238
+ } else {
239
+ const range = liveYRange ?? computeAutoYRange(allPoints.map((p) => p.y))
240
+ yMin = range.min
241
+ yMax = range.max
242
+ }
243
+ const ySpan = yMax - yMin || 1
244
+
170
245
  const getX = (x: number) => padding.left + ((x - minX) / timeSpan) * graphWidth
171
- const getY = (y: number) => padding.top + graphHeight - (((y / selectedScale) * graphHeight) / 2 + graphHeight / 2)
246
+ const getY = (y: number) => padding.top + graphHeight - ((y - yMin) / ySpan) * graphHeight
247
+
248
+ return { padding, graphWidth, graphHeight, minX, maxX, timeSpan, yMin, yMax, ySpan, getX, getY }
249
+ }
250
+
251
+ /* Бинарный поиск ближайшей по времени точки — точки в graphData всегда отсортированы по x (по построению) */
252
+ const findNearestPoint = (points: { x: number; y: number }[], t: number): { x: number; y: number } | null => {
253
+ if (points.length === 0) return null
254
+ let lo = 0
255
+ let hi = points.length - 1
256
+ while (lo < hi) {
257
+ const mid = (lo + hi) >> 1
258
+ if (points[mid].x < t) lo = mid + 1
259
+ else hi = mid
260
+ }
261
+ if (lo > 0 && Math.abs(points[lo - 1].x - t) < Math.abs(points[lo].x - t)) return points[lo - 1]
262
+ return points[lo]
263
+ }
264
+
265
+ const pad2 = (n: number) => String(n).padStart(2, "0")
266
+
267
+ /* Компактная адаптивная подпись сетки по оси X: относительное время для живого режима, дата/время — для статического */
268
+ const formatAxisLabel = (t: number, spanMs: number): string => {
269
+ if (!isHistoryMode) return `${((t - Date.now()) / 1000).toFixed(0)}s`
270
+ const d = new Date(t)
271
+ const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
272
+ if (spanMs < 24 * 60 * 60 * 1000) return hhmm
273
+ const ddmm = `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}`
274
+ if (spanMs < 365 * 24 * 60 * 60 * 1000) return `${ddmm} ${hhmm}`
275
+ return `${ddmm}.${d.getFullYear()}`
276
+ }
277
+
278
+ /* Полная подпись времени в тултипе */
279
+ const formatTooltipTime = (t: number): string => {
280
+ if (!isHistoryMode) return `${((t - Date.now()) / 1000).toFixed(1)}s`
281
+ const d = new Date(t)
282
+ return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}.${d.getFullYear()} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
283
+ }
284
+
285
+ const handleMouseMove = (e: MouseEvent) => {
286
+ if (!canvas || graphData.length === 0) return
287
+ const rect = canvas.getBoundingClientRect()
288
+ const mouseX = e.clientX - rect.left
289
+ const mouseY = e.clientY - rect.top
290
+ const { padding, graphWidth, minX, timeSpan } = computeGeometry()
291
+
292
+ if (mouseX < padding.left || mouseX > width - padding.right) {
293
+ if (tooltip.visible) {
294
+ tooltip = { ...tooltip, visible: false }
295
+ drawAllGraphs()
296
+ }
297
+ return
298
+ }
299
+
300
+ const t = minX + ((mouseX - padding.left) / graphWidth) * timeSpan
301
+ const items = graphData
302
+ .map((g) => {
303
+ const nearest = findNearestPoint(g.points, t)
304
+ return nearest ? { name: g.name, value: nearest.y, color: g.color, time: nearest.x } : null
305
+ })
306
+ .filter((v): v is { name: string; value: number; color: string; time: number } => v !== null)
307
+
308
+ if (items.length === 0) {
309
+ tooltip = { ...tooltip, visible: false }
310
+ return
311
+ }
312
+
313
+ tooltip = { visible: true, x: mouseX, y: mouseY, time: items[0].time, items }
314
+ drawAllGraphs()
315
+ }
316
+
317
+ const handleMouseLeave = () => {
318
+ if (!tooltip.visible) return
319
+ tooltip = { ...tooltip, visible: false }
320
+ drawAllGraphs()
321
+ }
322
+
323
+ const drawAllGraphs = () => {
324
+ if (!ctx) return
325
+ ctx.clearRect(0, 0, width, height)
326
+
327
+ const { padding, graphWidth, graphHeight, minX, timeSpan, yMin, yMax, getX, getY } = computeGeometry()
172
328
 
173
329
  /* Сетка X */
174
330
  ctx.strokeStyle = "#777"
@@ -177,17 +333,15 @@
177
333
  ctx.font = "10px monospace"
178
334
  ctx.textAlign = "center"
179
335
 
180
- const now = Date.now()
181
336
  for (let i = 0; i <= 10; i++) {
182
337
  const t = minX + (i / 10) * timeSpan
183
338
  const x = getX(t)
184
- const secondsAgo = ((t - now) / 1000).toFixed(0)
185
339
  ctx.beginPath()
186
340
  ctx.moveTo(x, padding.top)
187
341
  ctx.lineTo(x, height - padding.bottom)
188
342
  ctx.stroke()
189
343
  ctx.textBaseline = "top"
190
- ctx.fillText(`${secondsAgo}s`, x, height - padding.bottom + 2)
344
+ ctx.fillText(formatAxisLabel(t, timeSpan), x, height - padding.bottom + 2)
191
345
  }
192
346
 
193
347
  /* Сетка Y */
@@ -195,13 +349,13 @@
195
349
  ctx.textBaseline = "middle"
196
350
  const ySteps = 8
197
351
  for (let i = 0; i <= ySteps; i++) {
198
- const yVal = selectedScale - (i * 2 * selectedScale) / ySteps
352
+ const yVal = yMax - (i * (yMax - yMin)) / ySteps
199
353
  const y = getY(yVal)
200
354
  ctx.beginPath()
201
355
  ctx.moveTo(padding.left, y)
202
356
  ctx.lineTo(width - padding.right, y)
203
357
  ctx.stroke()
204
- ctx.fillText(`${yVal.toFixed(0)}`, padding.left - 5, y)
358
+ ctx.fillText(yVal.toFixed(1), padding.left - 5, y)
205
359
  }
206
360
 
207
361
  /* Отрисовка графиков */
@@ -222,6 +376,17 @@
222
376
  ctx.lineTo(getX(last.x), getY(last.y))
223
377
  ctx.stroke()
224
378
  })
379
+
380
+ /* Вертикальная линия-указатель под тултипом */
381
+ if (tooltip.visible) {
382
+ const x = getX(tooltip.time)
383
+ ctx.strokeStyle = "rgba(120, 120, 120, 0.6)"
384
+ ctx.lineWidth = 1
385
+ ctx.beginPath()
386
+ ctx.moveTo(x, padding.top)
387
+ ctx.lineTo(x, height - padding.bottom)
388
+ ctx.stroke()
389
+ }
225
390
  }
226
391
  </script>
227
392
 
@@ -232,38 +397,53 @@
232
397
 
233
398
  <div class="flex w-full flex-row gap-4">
234
399
  <!-- График -->
235
- <div bind:this={container} class="h-64 grow overflow-hidden rounded-xl border border-gray-200 shadow-sm">
236
- <canvas class="h-full w-full bg-(--back-color)" bind:this={canvas}></canvas>
400
+ <div bind:this={container} class="relative h-64 grow overflow-hidden rounded-xl border border-gray-200 shadow-sm">
401
+ <canvas
402
+ class="h-full w-full bg-(--back-color)"
403
+ bind:this={canvas}
404
+ onmousemove={handleMouseMove}
405
+ onmouseleave={handleMouseLeave}
406
+ ></canvas>
407
+
408
+ {#if tooltip.visible}
409
+ <div
410
+ class="pointer-events-none absolute z-10 rounded-lg border border-(--border-color) bg-(--back-color) px-2 py-1 text-xs whitespace-nowrap shadow-lg"
411
+ style="left: {Math.min(tooltip.x + 12, width - 150)}px; top: {Math.min(tooltip.y + 12, height - 16 - tooltip.items.length * 16)}px;"
412
+ >
413
+ <div class="mb-1 font-semibold">{formatTooltipTime(tooltip.time)}</div>
414
+ {#each tooltip.items as item (item.name)}
415
+ <div class="flex items-center gap-1.5">
416
+ <span class="h-2 w-2 shrink-0 rounded-full" style="background-color: {item.color}"></span>
417
+ <span>{item.name}:</span>
418
+ <span class="font-semibold">{item.value.toFixed(2)}</span>
419
+ </div>
420
+ {/each}
421
+ </div>
422
+ {/if}
237
423
  </div>
238
424
 
239
425
  <!-- Панель настроек -->
240
426
  <div class="flex w-48 flex-col gap-2">
241
- <!-- Развертка по горизонтали -->
242
- <Select
243
- label={{ name: "Refresh rate", class: "" }}
244
- options={REFRESH_OPTIONS}
245
- value={REFRESH_OPTIONS.find((o) => o.value == selectedRefreshRate)}
246
- onUpdate={(value) => (selectedRefreshRate = (value as IOption).value as number)}
247
- />
248
-
249
- <!-- Масштаб по вертикали -->
250
- <Select
251
- label={{ name: "Scale" }}
252
- options={SCALE_OPTIONS}
253
- value={SCALE_OPTIONS.find((o) => o.value == selectedScale)}
254
- onUpdate={(value) => (selectedScale = (value as IOption).value as number)}
255
- />
427
+ {#if !isHistoryMode}
428
+ <!-- Развертка по горизонтали -->
429
+ <Select
430
+ label={{ name: "Refresh Rate", class: "" }}
431
+ options={REFRESH_OPTIONS}
432
+ value={REFRESH_OPTIONS.find((o) => o.value == selectedRefreshRate)}
433
+ onUpdate={(value) => (selectedRefreshRate = (value as IOption).value as number)}
434
+ />
435
+ {/if}
256
436
 
257
437
  <!-- Переменные и их значение -->
258
438
  <div>
259
439
  <h5 class="px-4">Values</h5>
260
440
  <table class="w-full font-mono text-sm">
261
441
  <tbody>
262
- {#each graphData as data, i (i)}
442
+ {#each graphData as data, i (data.id)}
263
443
  <tr>
264
444
  <td><div class="mr-2 h-4 w-4 rounded-full" style="background-color: {data.color}"></div></td>
265
- <td class="w-24 truncate text-left font-semibold">{(streamingData.data as IGraphDataObject[])?.[i]?.name}</td>
266
- <td class="w-16 text-right">{currentValues[i].toFixed(2)}</td>
445
+ <td class="w-24 truncate text-left font-semibold">{data.name}</td>
446
+ <td class="w-16 text-right">{currentValues[i]?.toFixed(2)}</td>
267
447
  </tr>
268
448
  {/each}
269
449
  </tbody>
@@ -1,3 +1,5 @@
1
+ import type { IOption } from "../types";
2
+ export declare const REFRESH_OPTIONS: IOption[];
1
3
  import type { IGraphProps } from "../types";
2
4
  declare const Graph: import("svelte").Component<IGraphProps, {}, "">;
3
5
  type Graph = ReturnType<typeof Graph>;
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { getContext } from "svelte"
3
3
  import { T } from "../locales/i18n"
4
- import { type UIComponent, type IGraphProps, updateProperty, type IUIComponentHandler } from "../types"
4
+ import { type UIComponent, type IGraphProps, type IOption, updateProperty, type IUIComponentHandler } from "../types"
5
5
  import * as UI from ".."
6
6
  import { optionsStore } from "../options"
7
7
  import { twMerge } from "tailwind-merge"
8
8
  import CommonSnippets from "../CommonSnippets.svelte"
9
+ import { REFRESH_OPTIONS } from "./Graph.svelte"
9
10
 
10
11
  const {
11
12
  component,
@@ -37,6 +38,15 @@
37
38
  />
38
39
  {/snippet}
39
40
 
41
+ {#snippet GraphSettings()}
42
+ <UI.Select
43
+ label={{ name: $T("constructor.props.refreshrate") }}
44
+ options={REFRESH_OPTIONS}
45
+ value={REFRESH_OPTIONS.find((o) => o.value == (component.properties.refreshRate ?? 0))}
46
+ onUpdate={(value) => updateProperty("refreshRate", (value as IOption).value as number, component, onPropertyChange)}
47
+ />
48
+ {/snippet}
49
+
40
50
  {#if forConstructor}
41
51
  <div class="relative flex flex-row items-start justify-center">
42
52
  <div class="flex w-1/3 flex-col px-2">
@@ -49,6 +59,7 @@
49
59
  </div>
50
60
  <div class="flex w-1/3 flex-col px-2">
51
61
  {@render GraphIsTest()}
62
+ {@render GraphSettings()}
52
63
  </div>
53
64
  </div>
54
65
  {:else}
@@ -64,6 +75,7 @@
64
75
  <div class="flex w-1/3 flex-col px-2">
65
76
  <CommonSnippets snippet="WrapperClass" {component} {onPropertyChange} />
66
77
  {@render GraphIsTest()}
78
+ {@render GraphSettings()}
67
79
  </div>
68
80
  </div>
69
81
  {/if}
@@ -2,17 +2,28 @@
2
2
 
3
3
  ## Описание
4
4
 
5
- График - это UI-компонент, который позволяет визуализировать данные в виде динамических графиков. Компонент поддерживает потоковую передачу данных, настройку частоты обновления, масштаба отображения и другие параметры через систему пропсов.
5
+ График - это UI-компонент, который позволяет визуализировать данные в виде динамических графиков. Компонент поддерживает два режима работы:
6
+
7
+ - **Живой (потоковый) режим** — точки добавляются по одной по мере поступления `streamingData`, X-ось показывает "N секунд назад" относительно текущего момента.
8
+ - **Статический (исторический) режим** — компонент получает уже готовый массив точек через `historyData` (каждая со своим реальным `timestamp`) и рисует весь ряд разом, используя настоящие дату/время на оси X.
9
+
10
+ Режим определяется автоматически: если проп `historyData` задан и непустой, компонент переходит в статический режим (`streamingData`/`refreshRate` в этом случае игнорируются).
11
+
12
+ Отдельного пропа для масштаба вертикальной оси нет — в обоих режимах Y-ось масштабируется автоматически по фактическим данным (±10% отступ). В живом режиме диапазон плавно "подъезжает" к новому min/max буфера (сглаживание), а не пересчитывается резко на каждый семпл, чтобы сетка не дёргалась.
6
13
 
7
14
  ## Пропсы
8
15
 
9
- | Название | Тип | По умолчанию | Описание |
10
- | ------------- | --------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------ |
11
- | id | string | crypto.randomUUID() | Уникальный идентификатор компонента |
12
- | wrapperClass | string | "" | Дополнительные CSS-классы для обертки компонента |
13
- | label | { name?: string; class?: string } | { name: "", class: "" } | Настройки заголовка компонента |
14
- | streamingData | { data: { name: string; value: number }[]; timestamp?: number } | { data: [], timestamp: Date.now() } | Потоковые данные для отображения на графике |
15
- | isTest | boolean | false | Режим тестирования (генерация случайных данных) |
16
+ | Название | Тип | По умолчанию | Описание |
17
+ | ------------- | ------------------------------------------------------------------ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
18
+ | id | string | crypto.randomUUID() | Уникальный идентификатор компонента |
19
+ | wrapperClass | string | "" | Дополнительные CSS-классы для обертки компонента |
20
+ | label | { name?: string; class?: string } | { name: "", class: "" } | Настройки заголовка компонента |
21
+ | streamingData | { data: { name: string; value: number }[] \| null } | { data: [] } | Потоковые данные для живого режима |
22
+ | isTest | boolean | false | Режим тестирования (генерация случайных данных); также форсирует `refreshRate` = 50мс |
23
+ | refreshRate | number | 0 | Интервал отрисовки точки в живом режиме, мс. `0` = **AUTO** — точка рисуется, когда значения реально изменились (без таймера) |
24
+ | historyData | { name: string; points: { value: number; timestamp: number }[] }[] | [] | Готовый исторический ряд — если задан (непустой), включает статический режим (см. ниже) |
25
+
26
+ `refreshRate` относится только к живому режиму и сохраняется вместе с компонентом при сборке интерфейса в конструкторе (редактируется через `GraphProps.svelte`, см. ниже) — это стартовое значение при монтировании; остаётся переключаемым "на лету" прямо на самом графике через встроенный `Select`. В статическом режиме (`historyData`) этот селект скрыт, так как не имеет эффекта.
16
27
 
17
28
  ### Вложенные типы
18
29
 
@@ -25,10 +36,20 @@
25
36
 
26
37
  #### StreamingData
27
38
 
28
- | Название | Тип | По умолчанию | Описание |
29
- | --------- | --------------------------------- | ------------ | -------------------------------------------- |
30
- | data | { name: string; value: number }[] | [] | Массив данных для отображения |
31
- | timestamp | number | Date.now() | Временная метка последнего обновления данных |
39
+ | Название | Тип | По умолчанию | Описание |
40
+ | -------- | ----------------------------------------- | ------------ | ----------------------------- |
41
+ | data | { name: string; value: number }[] \| null | [] | Массив данных для отображения |
42
+
43
+ Режим AUTO (`refreshRate: 0`) определяет появление новых данных сравнением самих значений с предыдущим пакетом, а не по временной метке.
44
+
45
+ #### HistoryData
46
+
47
+ | Название | Тип | По умолчанию | Описание |
48
+ | -------- | -------------------- | ------------ | ------------------------------------------------------------ |
49
+ | name | string | - | Имя серии (отображается в подписи и в таблице значений) |
50
+ | points | IGraphHistoryPoint[] | - | Точки серии, каждая — `{ value: number; timestamp: number }` |
51
+
52
+ `timestamp` — Unix-эпоха в миллисекундах (как `Date.now()`). Точки сортируются компонентом по `timestamp` при построении графика — исходный порядок в массиве не важен.
32
53
 
33
54
  ## События
34
55
 
@@ -64,10 +85,10 @@
64
85
  { name: "Humidity", value: 60 },
65
86
  { name: "Pressure", value: 1013 },
66
87
  ],
67
- timestamp: Date.now(),
68
88
  })
69
89
 
70
- // Обновление данных в реальном времени
90
+ // Обновление данных в реальном времени — при refreshRate: 0 (AUTO) новая точка
91
+ // рисуется на каждое изменение значений, без привязки к частоте setInterval здесь
71
92
  setInterval(() => {
72
93
  data = {
73
94
  data: [
@@ -75,22 +96,51 @@
75
96
  { name: "Humidity", value: Math.random() * 100 },
76
97
  { name: "Pressure", value: 1000 + Math.random() * 50 },
77
98
  ],
78
- timestamp: Date.now(),
79
99
  }
80
100
  }, 1000)
81
101
  </script>
82
102
 
83
- <UI.Graph label={{ name: "График с потоковыми данными" }} {streamingData} />
103
+ <UI.Graph label={{ name: "График с потоковыми данными" }} streamingData={data} refreshRate={0} />
84
104
  ```
85
105
 
106
+ ### Статический режим (historyData)
107
+
108
+ ```svelte
109
+ <script>
110
+ import * as UI from "poe-svelte-ui-lib"
111
+
112
+ const now = Date.now()
113
+ const hourMs = 60 * 60 * 1000
114
+
115
+ // Готовый исторический ряд, например потребление электроэнергии за сутки с шагом в час
116
+ const historyExample = [
117
+ {
118
+ name: "P, кВт·ч",
119
+ points: Array.from({ length: 24 }, (_, i) => ({
120
+ timestamp: now - (23 - i) * hourMs,
121
+ value: 30 + Math.round(Math.sin(i / 3) * 20),
122
+ })),
123
+ },
124
+ ]
125
+ </script>
126
+
127
+ <UI.Graph label={{ name: "Потребление за сутки" }} historyData={historyExample} />
128
+ ```
129
+
130
+ При наведении курсора на график появляется всплывающая подсказка с точным временем и значениями всех серий в ближайшей к курсору временной точке.
131
+
86
132
  ## Внутренняя архитектура
87
133
 
88
134
  ### Реактивность
89
135
 
90
- - Переменная `graphData` хранит точки графиков для отображения
91
- - Переменная `currentValues` хранит текущие значения переменных
92
- - Переменные `selectedRefreshRate` и `selectedScale` управляют частотой обновления и масштабом
93
- - Объекты `REFRESH_OPTIONS` и `SCALE_OPTIONS` определяют доступные настройки
136
+ - Переменная `graphData` хранит точки графиков для отображения (в обоих режимах)
137
+ - Переменная `currentValues` хранит текущие значения переменных — в живом режиме это последний полученный пакет, в статическом — последняя точка каждой серии из `historyData`
138
+ - Переменная `previousValues` хранит значения предыдущего пакета — используется в режиме AUTO, чтобы отличить "пришли новые данные" от повторного опроса
139
+ - Переменная `isHistoryMode` — `$derived` от наличия непустого `historyData`; определяет режим работы компонента целиком (без отдельного булевого пропа)
140
+ - Переменная `selectedRefreshRate` — `$derived` от пропа `refreshRate` (и `isTest`); присвоение через встроенный `Select` временно переопределяет значение, пока не изменится сам проп; в статическом режиме `Select` скрыт
141
+ - Переменная `liveYRange` хранит текущий (сглаженный) диапазон Y-оси живого режима; обновляется функцией `updateLiveYRange()`, вызываемой один раз за тик (после добавления новой точки в интервале), а не за кадр — целевой диапазон берётся из фактических min/max буфера (±10% отступ, `computeAutoYRange()`) и приближается к нему экспоненциальным сглаживанием (коэффициент 0.15), чтобы сетка не дёргалась на каждый семпл. В статическом режиме диапазон считается разово по всему массиву той же `computeAutoYRange()`
142
+ - Объект `REFRESH_OPTIONS` экспортируется из `<script module>` компонента — переиспользуется в `GraphProps.svelte`, чтобы не дублировать список вариантов
143
+ - Переменная `tooltip` хранит состояние всплывающей подсказки (позиция, время, значения серий); обновляется по `onmousemove`/`onmouseleave` канваса, поиск ближайшей точки — бинарным поиском по отсортированному массиву точек серии
94
144
 
95
145
  ### Сторы и зависимости
96
146
 
@@ -124,7 +174,7 @@
124
174
 
125
175
  ### Особенности конструктора
126
176
 
127
- - Предоставляет визуальные элементы управления для всех доступных свойств
177
+ - Предоставляет визуальные элементы управления для всех доступных свойств, включая `refreshRate` (`constructor.props.refreshrate`) через `Select` с теми же вариантами, что и на самом графике (`REFRESH_OPTIONS` из `Graph.svelte`)
128
178
  - Поддерживает настройку переменной для отображения
129
179
  - Включает переключатель для режима тестирования
130
180
  - Имеет различные наборы полей в зависимости от режима (`forConstructor`)
@@ -142,7 +192,8 @@
142
192
 
143
193
  - Компонент ограничен максимальным количеством отображаемых графиков (6)
144
194
  - При отсутствии данных график остается пустым
145
- - Частота обновления влияет на производительность при большом объеме данных
195
+ - Частота обновления влияет на производительность при большом объеме данных (актуально только для живого режима)
196
+ - В статическом режиме (`historyData`) данные рисуются один раз при получении массива — последующие изменения `historyData` (например, замена на новый массив) перестраивают график заново, инкрементального добавления точек нет
146
197
 
147
198
  ### Производительность
148
199
 
@@ -140,6 +140,7 @@ const translations = {
140
140
  "constructor.props.disabled": "Отключить",
141
141
  "constructor.props.readonly": "Только чтение",
142
142
  "constructor.props.istest": "Тестовые данные",
143
+ "constructor.props.refreshrate": "Скорость обновления",
143
144
  "constructor.props.variable": "Имя переменной",
144
145
  "constructor.props.widthMode": "Ширина кнопок",
145
146
  "constructor.props.graphdata.title": "Начальные данные ",
@@ -303,6 +304,7 @@ const translations = {
303
304
  "common.select_tag": "Make a choice",
304
305
  "constructor.props.file.select": "Select a file",
305
306
  "constructor.props.file.notselected": "File is not selected",
307
+ "constructor.props.refreshrate": "Refresh rate",
306
308
  },
307
309
  zh: {},
308
310
  };
package/dist/types.d.ts CHANGED
@@ -133,7 +133,14 @@ export interface IFileAttachProps {
133
133
  export interface IGraphDataObject {
134
134
  name: string;
135
135
  value: number;
136
- timestamp?: Date;
136
+ }
137
+ export interface IGraphHistoryPoint {
138
+ value: number;
139
+ timestamp: number;
140
+ }
141
+ export interface IGraphHistorySeries {
142
+ name: string;
143
+ points: IGraphHistoryPoint[];
137
144
  }
138
145
  export interface IGraphProps {
139
146
  id?: string;
@@ -144,9 +151,10 @@ export interface IGraphProps {
144
151
  };
145
152
  streamingData?: {
146
153
  data: IGraphDataObject[] | null;
147
- timestamp?: number;
148
154
  };
149
155
  isTest?: boolean;
156
+ refreshRate?: number;
157
+ historyData?: IGraphHistorySeries[];
150
158
  }
151
159
  export interface IInputProps {
152
160
  id?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-svelte-ui-lib",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {