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.
- package/dist/Graph/Graph.svelte +260 -80
- package/dist/Graph/Graph.svelte.d.ts +2 -0
- package/dist/Graph/GraphProps.svelte +13 -1
- package/dist/Graph/README.md +73 -22
- package/dist/locales/translations.js +2 -0
- package/dist/types.d.ts +10 -2
- package/package.json +1 -1
package/dist/Graph/Graph.svelte
CHANGED
|
@@ -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
|
|
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: []
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 - ((
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 (
|
|
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">{
|
|
266
|
-
<td class="w-16 text-right">{currentValues[i]
|
|
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,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}
|
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
|
|
|
@@ -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
|
-
|
|
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;
|