/
Alcatraz
/
Wagomon
Обзор
Документация
Войти
/
Alcatraz
/
Wagomon
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/lib/SensorChart.svelte
603 строки
16 KB
saitgalineu
vkr
26 май 2026, 10:08
26 май 2026, 10:08
f9b2919
Код
Авторство
О чём код?
<script> import * as echarts from 'echarts'; import { onMount, onDestroy, createEventDispatcher } from 'svelte'; import { fetchHistoryBatched } from '$lib/historyBatchClient.js'; export let sensor; export let sensorId; export let type; export let ctrl; export let mergedSensors = []; export let currentRange = '15m'; export let description = null; export let hideRangeButtons = false; export let timeZone = 'UTC'; const dispatch = createEventDispatcher(); let chartDiv; let chart; let loading = false; let data = []; let resizeHandler; let isFetching = false; let sensorDescription = description; let descriptionItems = []; let descriptionsOpen = false; let previousRange = currentRange; let previousSourcesSignature = ''; function getSeriesSources() { const base = sensorId ? [{ id: sensorId, type, ctrl, name: sensor, description: sensorDescription }] : []; const extras = Array.isArray(mergedSensors) ? mergedSensors.filter((item) => item && item.id && item.type).map((item) => ({ id: item.id, type: item.type, ctrl: item.ctrl, name: item.name || `${item.type}:${item.id}`, description: item.description || null })) : []; return [...base, ...extras]; } function getChartTitle() { const names = getSeriesSources().map((item) => item.name).filter(Boolean); return names.length ? names.join(' + ') : sensor; } function getSourcesSignature() { return getSeriesSources() .map((item) => `${item.type}:${item.id}:${item.ctrl ?? ''}:${item.name ?? ''}`) .join('|'); } function getDescriptionItems() { return getSeriesSources().map((item) => ({ key: `${item.type}:${item.id}:${item.ctrl ?? ''}`, name: item.name || `${item.type}:${item.id}`, description: item.description || 'Описание отсутствует' })); } function getTitleWrapWidth() { const w = chartDiv?.clientWidth ?? 0; return Math.max(160, w - 48); } function updateTitleWrap() { if (!chart) return; chart.setOption( { title: { text: getChartTitle(), textStyle: { width: getTitleWrapWidth(), overflow: 'break', lineHeight: 16 } } }, false ); } const ranges = [ { label: '5 мин', value: '5m' }, { label: '15 мин', value: '15m' }, { label: '30 мин', value: '30m' }, { label: '1 час', value: '1h' }, { label: '6 часов', value: '6h' }, { label: '12 часов', value: '12h' }, { label: 'Сутки', value: '1d' }, { label: 'Неделя', value: '1w' }, { label: 'Месяц', value: '1mo' }, { label: '1 год', value: '1y' }, { label: '2 года', value: '2y' }, { label: '3 года', value: '3y' }, { label: '5 лет', value: '5y' } ]; function formatDate(value) { if (value === null || value === undefined) return ''; const ms = typeof value === 'number' ? value : new Date(value).getTime(); if (!Number.isFinite(ms)) return ''; try { return new Intl.DateTimeFormat('ru-RU', { timeZone, year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(ms)); } catch { return new Date(ms).toLocaleString('ru-RU'); } } const baseOptions = { useUTC: false, title: { text: sensor, left: 'center', top: 0, textStyle: { width: getTitleWrapWidth(), overflow: 'break', lineHeight: 16 } }, grid: { left: 40, right: 20, top: 72, bottom: 60 }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, formatter: (params) => { const list = Array.isArray(params) ? params : [params]; if (!list.length) return ''; const first = list[0]; const xRaw = (first && first.value && first.value[0]) ?? first?.axisValue; const timeLabel = formatDate(xRaw); const lines = [timeLabel]; for (const p of list) { const v = p?.value?.[1]; const val = typeof v === 'number' && Number.isFinite(v) ? v.toFixed(2) : v; lines.push(`${p.seriesName}: ${val}`); } return lines.join('<br/>'); } }, dataZoom: [ { type: 'inside', xAxisIndex: 0, filterMode: 'none' }, { type: 'slider', xAxisIndex: 0, filterMode: 'none' } ], xAxis: { type: 'time', boundaryGap: false, axisLabel: { formatter: (value) => formatDate(value) } }, yAxis: { type: 'value', axisLabel: { formatter: value => Number(value).toFixed(2) } }, series: [ { name: sensor, type: 'line', smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: [] } ], }; function setChartData(seriesEntries, min, max) { max = 1.1 * max; min = 0.9 * min; if (!chart) return; chart.setOption( { title: { text: getChartTitle(), textStyle: { width: getTitleWrapWidth(), overflow: 'break', lineHeight: 16 } }, series: seriesEntries.map((entry) => ({ name: entry.name, type: 'line', smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: entry.data.map((d) => [d.x, d.y]) })), xAxis: { min: undefined, max: undefined }, yAxis: {min, max} }, false ); } async function fetchData(range) { if (isFetching) { console.warn('fetchData: уже выполняется, пропуск', range); return; } const sources = getSeriesSources(); if (!sources.length) { data = []; loading = false; return; } isFetching = true; loading = true; console.log('fetchData: начало', range); try { const rawList = await Promise.all( sources.map((source) => fetchHistoryBatched(source.type, source.id, range)) ); const seriesEntries = rawList.map((raw, index) => { let points = Array.isArray(raw) ? raw .filter(d => d && typeof d.x !== 'undefined' && typeof d.y === 'number' && !isNaN(d.y)) .map(d => ({ x: typeof d.x === 'number' ? d.x : new Date(d.x).getTime(), y: d.y })) : []; if (range === 'all' && points.length > 0) { const sampled = []; for (let i = 0; i < points.length; i += 12) sampled.push(points[i]); const last = points[points.length - 1]; if (sampled.length === 0 || sampled[sampled.length - 1].x !== last.x) sampled.push(last); points = sampled; } return { name: sources[index].name, data: points }; }); data = seriesEntries.flatMap((item) => item.data); console.log('fetchData: получено серий', seriesEntries.length, 'для диапазона', range); if (data.length > 0) { const values = data.map((item) => item.y); const max = values.reduce((a, b) => Math.max(a, b), -Infinity); const min = values.reduce((a, b) => Math.min(a, b), Infinity); console.log('fetchData: установка данных графика', { min, max, count: data.length }); setChartData(seriesEntries, min, max); } else { console.log('fetchData: нет данных, очистка графика'); if (chart) { chart.setOption({ title: { text: getChartTitle(), textStyle: { width: getTitleWrapWidth(), overflow: 'break', lineHeight: 16 } }, series: sources.map((source) => ({ name: source.name, type: 'line', smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: [] })), xAxis: { min: undefined, max: undefined }, yAxis: { min: undefined, max: undefined } }, false); } } console.log('fetchData: завершено', range); } catch (error) { console.error('fetchData: ошибка', error, 'для диапазона', range); throw error; } finally { loading = false; isFetching = false; console.log('fetchData: флаги сброшены', range); } } function zoomToRange(range) { if (!chart || isFetching || !range || !Array.isArray(range) || range.length !== 2) return; const min = Number(range[0]); const max = Number(range[1]); if (isNaN(min) || isNaN(max)) return; try { chart.setOption({ xAxis: { min, max } }, false); } catch (e) { console.error('Ошибка в zoomToRange:', e); } } function applyWindow(range) { if (isFetching || !chart) return; const now = Date.now(); const map = { '5m': 5 * 60e3, '15m': 15 * 60e3, '30m': 30 * 60e3, '1h': 60 * 60e3, '6h': 6 * 60 * 60e3, '12h': 12 * 60 * 60e3, '1d': 24 * 60 * 60e3, '1w': 7 * 24 * 60 * 60e3, '1mo': 4 * 7 * 24 * 60 * 60e3, '1y': 365 * 24 * 60 * 60e3, '2y': 2 * 365 * 24 * 60 * 60e3, '3y': 3 * 365 * 24 * 60 * 60e3, '5y': 5 * 365 * 24 * 60 * 60e3 }; if (map[range]) { zoomToRange([now - map[range], now]); } } function setRangeByButton(range) { if (isFetching || loading || currentRange === range) { console.log('setRangeByButton: пропуск', { isFetching, loading, currentRange, range }); return; } console.log('setRangeByButton: установка диапазона', range); const previousRange = currentRange; currentRange = range; fetchData(range).then(() => { if (chart && !isFetching) { applyWindow(range); } }).catch(err => { console.error('Ошибка в fetchData:', err); currentRange = previousRange; isFetching = false; loading = false; }); } function isActiveButton(range) { return currentRange === range; } async function loadDescription() { if (sensorDescription || !sensorId || !type) return; try { const res = await fetch(`/api/sensor/${type}/${sensorId}/description`); if (res.ok) { const data = await res.json(); if (data.description) { sensorDescription = data.description; } } } catch (error) { console.error('Ошибка загрузки описания датчика:', error); } } onMount(() => { chart = echarts.init(chartDiv); chart.setOption(baseOptions); updateTitleWrap(); previousSourcesSignature = getSourcesSignature(); chart.off('datazoom'); loadDescription(); if (!isFetching && !loading) { previousRange = currentRange; fetchData(currentRange).then(() => { if (chart) { applyWindow(currentRange); } }); } resizeHandler = () => { if (!chart) return; chart.resize(); updateTitleWrap(); }; window.addEventListener('resize', resizeHandler); }); onDestroy(() => { if (resizeHandler) window.removeEventListener('resize', resizeHandler); if (chart) chart.dispose(); }); $: if (hideRangeButtons && currentRange && chart && currentRange !== previousRange && !isFetching && !loading) { const rangeToUpdate = currentRange; previousRange = rangeToUpdate; fetchData(rangeToUpdate).then(() => { if (chart && !isFetching) { applyWindow(rangeToUpdate); } }).catch(err => { console.error('Ошибка при обновлении диапазона:', err); previousRange = rangeToUpdate; }); } $: if (chart) { const nextSourcesSignature = getSourcesSignature(); if (nextSourcesSignature !== previousSourcesSignature && !isFetching) { previousSourcesSignature = nextSourcesSignature; fetchData(currentRange).then(() => { if (chart && !isFetching) { applyWindow(currentRange); } }).catch((err) => { console.error('Ошибка при обновлении объединенного графика:', err); }); } } $: if (description && description !== sensorDescription) { sensorDescription = description; } $: descriptionItems = getDescriptionItems(); </script> <div class="sensor-chart"> {#if !hideRangeButtons} <div class="chart-header"> <div class="range-btns"> {#each ranges as r} <button class:active={isActiveButton(r.value)} on:click={() => setRangeByButton(r.value)}>{r.label}</button> {/each} </div> </div> {/if} <div bind:this={chartDiv} class="chart-area"></div> {#if loading} <div class="loading">Загрузка...</div> {/if} {#if descriptionItems.length === 1} <div class="sensor-description">{descriptionItems[0].description}</div> {:else if descriptionItems.length > 1} <details class="sensor-descriptions" bind:open={descriptionsOpen}> <summary class="descriptions-toggle"> <span>Описания линий ({descriptionItems.length})</span> <span class="toggle-icon">▾</span> </summary> <div class="sensor-description-list"> {#each descriptionItems as item (item.key)} <div class="sensor-description-item"> <div class="sensor-description-title">{item.name}</div> <div class="sensor-description-text">{item.description}</div> </div> {/each} </div> </details> {/if} </div> <style> .sensor-chart { display: flex; flex-direction: column; height: 100%; min-height: 0; } .chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .range-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; } .range-btns button { background: #e3f2fd; border: none; color: #1976d2; font-weight: 500; font-size: 1rem; border-radius: 0.3rem; padding: 0.2rem 0.8rem; cursor: pointer; transition: background 0.15s; } .range-btns button.active, .range-btns button:hover { background: #bbdefb; } .range-btns button.active { background: #64b5f6; color: #fff; } .chart-area { min-width: 320px; min-height: 320px; flex: 1 1 auto; } .loading { text-align: center; color: #1976d2; margin-top: 1rem; } .sensor-description { margin-top: 0.75rem; padding: 0.75rem 1rem; background: #f5f5f5; border-radius: 0.3rem; color: #555; font-size: 0.95rem; line-height: 1.5; border-left: 3px solid #1976d2; width: 100%; max-height: 6rem; overflow: auto; overflow-wrap: anywhere; } .sensor-descriptions { margin-top: 0.75rem; width: 100%; border: 1px solid #cfe3f8; border-radius: 0.45rem; background: #f8fbff; position: relative; z-index: 5; } .descriptions-toggle { list-style: none; display: flex; align-items: center; justify-content: space-between; width: 100%; box-sizing: border-box; margin: 0; padding: 0.55rem 0.75rem; color: #0f4f95; font-size: 0.92rem; font-weight: 600; cursor: pointer; user-select: none; } .descriptions-toggle::-webkit-details-marker { display: none; } .sensor-descriptions[open] .descriptions-toggle { border-bottom: 1px solid #d5e5f6; background: #eef6ff; } .toggle-icon { transition: transform 0.18s ease; } .sensor-descriptions[open] .toggle-icon { transform: rotate(180deg); } .sensor-description-list { position: absolute; top: calc(100% + 0.35rem); left: 0; right: 0; width: 100%; box-sizing: border-box; padding: 0.6rem 0.7rem 0.7rem; display: flex; flex-direction: column; gap: 0.55rem; border: 1px solid #cfe3f8; border-radius: 0.45rem; background: #f8fbff; box-shadow: 0 10px 24px rgba(15, 79, 149, 0.2); z-index: 30; } .sensor-description-item { background: #f5f5f5; border-left: 3px solid #1976d2; border-radius: 0.3rem; padding: 0.6rem 0.75rem; } .sensor-description-title { font-weight: 700; color: #0d47a1; margin-bottom: 0.25rem; overflow-wrap: anywhere; } .sensor-description-text { color: #555; font-size: 0.95rem; line-height: 1.5; overflow-wrap: anywhere; } </style>