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