Пример ГРАФИКА на HTML виджете.
-
Была задача реализовать ГРАФИК максимально приближенный по функционалу к Chart_MultiLine_GL. Т.е. возможность масштабирования по осям, всплывающая подсказка, перья.
Главное условие, что по осям можно произвольные свои переменные вставлять и гибко настраивать визуал.Пример как все выглядит:

Процесс:
- создаем контейнер
- вставляем HTML виджет
- вставляем код.
Созданы несколько локальных переменных:
-
переменная с результирующим объектом из точек;
-
2 переменные для включения/отключения масштабирования по осям;

-
переменная с массивом типа [1,1,1,1] для включения отображения или скрытия каких то графиков, т.к. при использовании встроенного функционала при обновлении графика все кривые вновь появляются и надо их снова отключать.

Изменение настроечных переменных график отслеживает по подписке.
Входящие данные для графиков формируются в скрипте визуализации(можно делать каким угодно образом, не обязательно так). В моем случае я формирую такой же объект для графика как в стандартном обработчике и с ним работаю.
Итоговый код (комментарии есть, если не понятно закидываем в ИИ):
-
Добавил в версию при отсутствии интернета или доступа к внешним ресурсам загружать библиотеки с папки \anyfiles
<style type="text/css"> /* ===== Стили для контейнера и canvas ===== */ #${uuid} { height: 100%; width: 100%; position: relative; } #${uuid} canvas { width: 100% !important; height: 100% !important; display: block; } </style> <div id="${uuid}"> <canvas id="myChart"></canvas> <button id="exportCsvBtn" style="position: absolute; top: 10px; right: 10px; z-index: 20; padding: 6px 12px; background: #5889A5; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;"> ⬇ CSV </button> </div> <script type="text/javascript"> // ===== Глобальные переменные ===== let updateTimeout = null; let isRendering = false; let chartInstance = null; let crosshair = null; let isSubscribed = false; // ===== Экспорт данных в CSV (разделитель ";" с BOM) ===== function exportChartDataToCSV() { if (!chartInstance) { console.warn('График ещё не создан'); return; } const datasets = chartInstance.data.datasets; if (!datasets || datasets.length === 0) { console.warn('Нет данных для экспорта'); return; } const xValues = new Set(); datasets.forEach(ds => { ds.data.forEach(point => { if (point.x !== undefined && point.x !== null) { xValues.add(point.x); } }); }); const sortedX = Array.from(xValues).sort((a, b) => a - b); const headers = ['Масса, кг']; datasets.forEach(ds => { headers.push(ds.label || 'Без названия'); }); const rows = []; sortedX.forEach(x => { const row = [x]; datasets.forEach(ds => { const point = ds.data.find(p => p.x === x); row.push(point && point.y !== undefined && point.y !== null ? point.y : ''); }); rows.push(row); }); const csvContent = [ headers.join(';'), ...rows.map(row => row.join(';')) ].join('\n'); const bom = '\uFEFF'; const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `chart_data_${new Date().toISOString().slice(0,10)}.csv`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); console.log('CSV файл успешно скачан (разделитель ";", с BOM)'); } // ===== КАСТОМНОЕ ПЕРЕКРЕСТИЕ (с поддержкой изменения размера окна) ===== function enableCrosshair(chart) { if (!chart) return; // Удаляем старое перекрестие, если есть if (crosshair) { if (crosshair.vLine) { crosshair.vLine.remove(); crosshair.hLine.remove(); crosshair.label.remove(); } if (crosshair.resizeHandler) { window.removeEventListener('resize', crosshair.resizeHandler); } if (crosshair.resizeObserver) { crosshair.resizeObserver.disconnect(); } crosshair = null; } const canvas = chart.canvas; if (!canvas) { console.warn('Canvas отсутствует'); return; } const container = canvas.parentNode; if (!container) { console.warn('Контейнер canvas не найден'); return; } container.style.position = 'relative'; // Создаём элементы перекрестия const vLine = document.createElement('div'); vLine.style.cssText = ` position: absolute; top: 0; left: 0; height: 100%; border-left: 1px dashed rgba(88, 137, 165, 0.7); pointer-events: none; display: none; z-index: 10; `; container.appendChild(vLine); const hLine = document.createElement('div'); hLine.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; border-top: 1px dashed rgba(88, 137, 165, 0.7); pointer-events: none; display: none; z-index: 10; `; container.appendChild(hLine); const label = document.createElement('div'); label.style.cssText = ` position: absolute; background: rgba(235,235,235,0.85); padding: 2px 8px; border-radius: 4px; font-size: 12px; pointer-events: none; display: none; white-space: nowrap; border: 1px solid #ccc; z-index: 11; `; container.appendChild(label); crosshair = { vLine, hLine, label }; // Храним относительные координаты (в долях от размера canvas) let lastXPercent = -1; let lastYPercent = -1; let isMouseInside = false; function updateCrosshairPosition(x, y) { if (!crosshair || !crosshair.vLine) return; const rect = canvas.getBoundingClientRect(); if (x < 0 || y < 0 || x > rect.width || y > rect.height || !isMouseInside) { crosshair.vLine.style.display = 'none'; crosshair.hLine.style.display = 'none'; crosshair.label.style.display = 'none'; return; } crosshair.vLine.style.display = 'block'; crosshair.vLine.style.left = x + 'px'; crosshair.hLine.style.display = 'block'; crosshair.hLine.style.top = y + 'px'; const xScale = chart.scales.x; const yScale = chart.scales.y; if (xScale && yScale) { const xVal = xScale.getValueForPixel(x); const yVal = yScale.getValueForPixel(y); if (xVal !== undefined && yVal !== undefined) { crosshair.label.style.display = 'block'; crosshair.label.textContent = `X: ${xVal.toFixed(1)} кг, Y: ${yVal.toFixed(2)} МПа`; let lx = x + 10, ly = y - 10; if (lx + 200 > rect.width) lx = x - 200; if (ly < 0) ly = 0; crosshair.label.style.left = lx + 'px'; crosshair.label.style.top = ly + 'px'; } else { crosshair.label.style.display = 'none'; } } } function onMouseMove(e) { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Сохраняем относительные координаты lastXPercent = x / rect.width; lastYPercent = y / rect.height; isMouseInside = true; updateCrosshairPosition(x, y); } function onMouseLeave() { isMouseInside = false; if (crosshair) { crosshair.vLine.style.display = 'none'; crosshair.hLine.style.display = 'none'; crosshair.label.style.display = 'none'; } } // Обработчик изменения размера окна или контейнера function onResize() { if (isMouseInside && lastXPercent >= 0 && lastYPercent >= 0 && crosshair) { // Обновляем размеры canvas chart.resize(); // Даём время на пересчёт размеров setTimeout(() => { const rect = canvas.getBoundingClientRect(); const x = lastXPercent * rect.width; const y = lastYPercent * rect.height; updateCrosshairPosition(x, y); }, 30); } } canvas.addEventListener('mousemove', onMouseMove); canvas.addEventListener('mouseleave', onMouseLeave); window.addEventListener('resize', onResize); // Наблюдатель за изменением размера контейнера (для случаев, когда окно не меняется, а контейнер — да) const resizeObserver = new ResizeObserver(() => { onResize(); }); resizeObserver.observe(container); // Сохраняем ссылки для очистки chart._crosshairHandlers = { onMouseMove, onMouseLeave, onResize }; crosshair.resizeHandler = onResize; crosshair.resizeObserver = resizeObserver; } // ===== ОСНОВНАЯ ФУНКЦИЯ (debounce) ===== function drawChart(canvasId) { if (isRendering) return; if (updateTimeout) { clearTimeout(updateTimeout); updateTimeout = null; } updateTimeout = setTimeout(() => { updateTimeout = null; performRender(canvasId); }, 150); } // ===== РЕАЛЬНЫЙ РЕНДЕРИНГ ===== function performRender(canvasId) { if (isRendering) return; isRendering = true; try { const canvas = document.getElementById(canvasId); if (!canvas) { console.warn('Canvas not found'); return; } const context = canvas.getContext('2d'); if (!context) { console.warn('Canvas context missing'); return; } const dataObj = window.ihapi.deviceValue('local676', 'report_graf_line_points'); if (!dataObj || !dataObj.items || dataObj.items.length === 0) { console.warn('Нет данных'); return; } const scaleXVal = window.ihapi.deviceValue('local638', 'scale_X'); const scaleYVal = window.ihapi.deviceValue('local637', 'scale_Y'); const isScaleX = scaleXVal == true || scaleXVal == 'true'; const isScaleY = scaleYVal == true || scaleYVal == 'true'; let mode = 'xy'; if (isScaleX && !isScaleY) mode = 'x'; else if (!isScaleX && isScaleY) mode = 'y'; else if (!isScaleX && !isScaleY) mode = 'none'; let zoomConfig = { pan: { enabled: false }, zoom: { wheel: { enabled: false } } }; if (mode !== 'none') { zoomConfig = { pan: { enabled: true, mode: mode }, zoom: { wheel: { enabled: true }, mode: mode }, limits: { x: { min: dataObj.start, max: dataObj.end }, y: { min: dataObj.min, max: dataObj.max } } }; } const datasets = []; for (const item of dataObj.items) { if (!item.visible) continue; const points = item.points.map(p => ({ x: parseFloat(p.x), y: parseFloat(p.y) })); datasets.push({ label: item.legend || 'Без названия', data: points, fill: false, borderColor: item.lineColor || '#000000', borderWidth: parseInt(item.lineWidth) || 2, tension: 0.0, pointRadius: 1.0, pointStyle: 'circle', }); } if (datasets.length === 0) { console.warn('Нет видимых линий'); return; } if (chartInstance) { // === ОБНОВЛЕНИЕ === chartInstance.data.datasets = datasets; chartInstance.options.scales.x.min = dataObj.start; chartInstance.options.scales.x.max = dataObj.end; chartInstance.options.scales.y.min = dataObj.min; chartInstance.options.scales.y.max = dataObj.max; chartInstance.options.plugins.zoom = zoomConfig; chartInstance.update(); console.log('График обновлён (zoom: ' + mode + ')'); } else { // === ПЕРВОЕ СОЗДАНИЕ ГРАФИКА === const config = { type: 'line', data: { datasets }, options: { responsive: true, maintainAspectRatio: false, animation: false, scales: { x: { type: 'linear', min: dataObj.start, max: dataObj.end, ticks: { autoSkip: true, maxRotation: 0, callback: (val, index, ticks) => index === 0 || index === ticks.length - 1 ? null : `${Number(val.toFixed(3))} кг`, color: '#5889A5', font: { size: 12 }, maxTicksLimit: 20 }, grid: { display: true, color: 'rgba(0,0,0,0.1)', lineWidth: 1 }, border: { color: '#5889A540', lineWidth: 1 }, title: { display: true, text: 'Количество вяжущего (цемент + МД)', color: '#5C7C8F', font: { size: 14 } } }, y: { min: dataObj.min, max: dataObj.max, ticks: { callback: (val, index, ticks) => index === 0 || index === ticks.length - 1 ? null : Number(val.toFixed(3)), color: '#5889A5', font: { size: 12 }, maxTicksLimit: 20 }, grid: { display: true, color: 'rgba(0,0,0,0.1)', lineWidth: 1 }, border: { color: '#5889A540', lineWidth: 1 }, title: { display: true, text: 'Прочность, МПа', color: '#5C7C8F', font: { size: 14 } } } }, plugins: { zoom: zoomConfig, tooltip: { mode: 'nearest', axis: 'x', intersect: false, backgroundColor: '#F0F0F0', titleColor: '#444444', titleFont: { size: 13, weight: 'normal' }, bodyColor: '#444444', borderColor: '#CCCCCC', borderWidth: 1, cornerRadius: 2, padding: 5, usePointStyle: true, boxWidth: 5, boxHeight: 5, callbacks: { title: function(tooltipItems) { const x = tooltipItems[0].parsed.x; return `масса: ${x.toFixed(1)} кг`; }, label: function(context) { let label = context.dataset.label || ''; if (label) label += ': '; if (context.parsed.y !== null) { label += context.parsed.y.toFixed(2) + ' МПа'; } return label; }, labelPointStyle: function(context) { return { pointStyle: 'circle' }; }, labelColor: function(context) { return { borderColor: context.dataset.borderColor, backgroundColor: context.dataset.borderColor, borderWidth: 0, borderRadius: 2 }; } } }, legend: { labels: { boxWidth: 7, boxHeight: 7, usePointStyle: true, font: { size: 13 } } } } } }; chartInstance = new Chart(context, config); // Принудительная установка размеров const container = canvas.parentNode; container.style.height = '100%'; container.style.width = '100%'; container.style.overflow = 'hidden'; canvas.style.height = '100%'; canvas.style.width = '100%'; canvas.style.display = 'block'; chartInstance.resize(); // Вызываем enableCrosshair только один раз enableCrosshair(chartInstance); console.log('График успешно создан (zoom: ' + mode + ')'); if (!isSubscribed) { setTimeout(initSubscriptions, 100); } } } catch (err) { console.error('Ошибка при рендеринге:', err); } finally { isRendering = false; } } // ===== ЗАГРУЗКА ПЛАГИНА ZOOM ===== function loadZoomPlugin() { // Проверяем, не загружен ли уже плагин (чтобы избежать дублирования) if (document.getElementById('chartjs-plugin-zoom')) { drawChart('myChart'); return; } const pluginScript = document.createElement('script'); pluginScript.id = 'chartjs-plugin-zoom'; pluginScript.src = 'https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom'; document.head.appendChild(pluginScript); pluginScript.addEventListener('load', function () { console.log('Плагин zoom загружен'); drawChart('myChart'); }); pluginScript.addEventListener('error', function () { console.warn('Ошибка загрузки плагина zoom с CDN, пробуем локальную версию'); const localPlugin = document.createElement('script'); localPlugin.id = 'chartjs-plugin-zoom'; localPlugin.src = '/files/chartjs-plugin-zoom.js'; // путь к локальной копии document.head.appendChild(localPlugin); localPlugin.addEventListener('load', function () { console.log('Плагин zoom загружен локально'); drawChart('myChart'); }); localPlugin.addEventListener('error', function () { console.error('Не удалось загрузить плагин zoom'); }); }); } // ===== ЗАГРУЗКА ОСНОВНОГО СКРИПТА ===== function loadScripts() { const chartScript = document.getElementById('chart-js'); if (!chartScript) { const script = document.createElement('script'); script.id = 'chart-js'; console.log('Текущий хост:', window.location.hostname); // Сначала пробуем загрузить с CDN script.src = 'https://cdn.jsdelivr.net/npm/chart.js'; document.head.appendChild(script); script.addEventListener('load', function () { loadZoomPlugin(); }); script.addEventListener('error', function () { console.warn('Ошибка загрузки Chart.js с CDN, пробуем локальную версию'); // Если CDN недоступен, загружаем локальный файл const localScript = document.createElement('script'); localScript.id = 'chart-js'; // тот же ID, чтобы не дублировать localScript.src = '/files/chart.js'; // путь к локальной копии document.head.appendChild(localScript); localScript.addEventListener('load', function () { loadZoomPlugin(); }); localScript.addEventListener('error', function () { console.error('Не удалось загрузить Chart.js ни с CDN, ни локально'); }); }); } else { if (!document.getElementById('chartjs-plugin-zoom')) { loadZoomPlugin(); } else { drawChart('myChart'); } } } // ===== Подписка на изменения локальных переменных ===== let prevScaleX = null; let prevScaleY = null; let prevRecipeData = null; let prevFlags = null; function initSubscriptions() { if (isSubscribed) { console.warn('Подписка уже создана, пропускаем'); return; } const myUuid = '${uuid}'; const subscribeList = [ 'local638_scale_X', 'local637_scale_Y', 'local644_new_rec_table_12_3', 'local669_report_enabledFlags' ]; console.log('Вызов initSubscriptions'); prevScaleX = window.ihapi.deviceValue('local638', 'scale_X'); prevScaleY = window.ihapi.deviceValue('local637', 'scale_Y'); prevRecipeData = window.ihapi.deviceValue('local644', 'new_rec_table_12_3'); prevFlags = window.ihapi.deviceValue('local669', 'report_enabledFlags'); window.ihapi.deviceSub(myUuid, subscribeList); window.ihapi.addEventListener(myUuid, 'data', function(event) { const newData = event; let hasChanged = false; subscribeList.forEach(key => { if (newData[key] !== undefined) { const currentVal = newData[key]; if (key === 'local638_scale_X' && currentVal !== prevScaleX) { prevScaleX = currentVal; hasChanged = true; } else if (key === 'local637_scale_Y' && currentVal !== prevScaleY) { prevScaleY = currentVal; hasChanged = true; } else if (key === 'local644_new_rec_table_12_3' && JSON.stringify(currentVal) !== JSON.stringify(prevRecipeData)) { prevRecipeData = currentVal; hasChanged = true; } else if (key === 'local669_report_enabledFlags') { prevFlags = currentVal; hasChanged = true; } } }); if (hasChanged) { console.log('Обнаружено изменение локальной переменной, обновляем график'); drawChart('myChart'); } }); window.ihapi.addEventListener(myUuid, 'destroy', function() { window.ihapi.deviceUnsub(myUuid, subscribeList); console.log('Отписка от локальных переменных выполнена'); }); isSubscribed = true; } // ===== ИНИЦИАЛИЗАЦИЯ ===== console.log('START'); const scriptExists = document.getElementById('chart-js'); const exportBtn = document.getElementById('exportCsvBtn'); if (exportBtn) { exportBtn.addEventListener('click', exportChartDataToCSV); } if (!scriptExists) { loadScripts(); } else { if (!document.getElementById('chartjs-plugin-zoom')) { loadZoomPlugin(); } else { drawChart('myChart'); setTimeout(initSubscriptions, 500); } } </script> -
В папку \anyfiles поместить 2 файла с библиотекой
- chart.js - код для него можно скачать по ссылке https://cdn.jsdelivr.net/npm/chart.js и вставить в файл
- chartjs - plugin-zoom.js - код для него можно скачать по ссылке https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom и вставить в файл
-
@grinsva Контейнер с готовым HTML виджетом доступен во вложении vc291_1.ihpack. Установить можно через импорт.
-
@grinsva Для того, чтобы ваш HTML виджет мог работать через p2p, то вам необходимо понимать откуда закачивать сторонние библиотеки. Для этого в HTML виджете можете воспользоваться window.location.hostname, который вернет hostname из адресной строки. В случае подключения через p2p вернется p2p.ih-systems.com. В этом случае ссылку на библиотеку делаем из публичных cdn, если запускается локально, без доступа в интернет, то библиотеку берем из "/files/chart.js"
-
@grinsva Так же прошу обратить внимание, что данные по точкам, которые необходимо отрисовать на графике, желательно получать через RESTApi, а не через переменные клиента, так как это большой объем данных и от этого может пострадать производительность системы.
-
Здравствуйте, на основе первой версии сделал версию с несколькими маркерами и печатью в файл. Прикрепляю исходный код REST API и виджета. Внешний вид:

REST API метод GET маршрут /forGraphmodule.exports = async (req, res, holder, debug) => { try { const q = req.query; const start = Number(q.start_ms) || (Date.now() - 3600000); const end = Number(q.end_ms) || Date.now(); const prop = q.prop || 'CV'; const tagsParam = q.tags; if (!tagsParam) throw { message: 'Не указан параметр tags (список устройств через запятую)' }; const tagsDecoded = decodeURIComponent(String(tagsParam)); const tags = tagsDecoded.split(',').map(t => t.trim()).filter(Boolean); //const tags = String(tagsParam).split(',').map(t => t.trim()).filter(Boolean); const data = {}; for (const tag of tags) { const curdev = holder.getDevice(tag); if (!curdev) { debug('Устройство не найдено: ' + tag); data[tag] = []; continue; } const records = await holder.dm.get('records', { start, end, dn_prop: curdev.dn + '.' + prop }); // Приводим записи к единому формату { ts, value } — // на случай если поля в БД называются иначе (time/val и т.п.) data[tag] = (records || []) .map(r => ({ ts: Number(r.ts !== undefined ? r.ts : (r.time !== undefined ? r.time : r.dt)), value: Number(r.value !== undefined ? r.value : (r.val !== undefined ? r.val : r.v)) })) .filter(p => isFinite(p.ts) && isFinite(p.value)) .sort((a, b) => a.ts - b.ts); } res.json({ res: 1, data }); debug(JSON.stringify({ res: 1, tags, start, end, prop, counts: Object.fromEntries(tags.map(t => [t, data[t].length])) })); } catch (e) { res.status(500).json({ res: 0, message: e.message }); debug('ERROR: ' + e.message); } };HTML виджет
<style> #root-${uuid} { --bg: #10161c; --panel: #161f28; --panel-2: #1c2732; --line: #2a3744; --text: #dbe4ec; --text-dim: #8ea0b0; --accent: #4fb3d9; --accent-2: #f2a154; --danger: #e2694b; --radius: 6px; font-family: "Segoe UI", Roboto, Arial, sans-serif; color: var(--text); background: var(--bg); width: 100%; height: 100%; box-sizing: border-box; padding: 14px; display: flex; flex-direction: column; gap: 12px; overflow: auto; } #root-${uuid} * { box-sizing: border-box; } #root-${uuid} .controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; } #root-${uuid} .field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-dim); } #root-${uuid} .field input { background: var(--panel-2); border: 1px solid var(--line); color: var(--text); border-radius: 4px; padding: 6px 8px; font-size: 13px; font-family: inherit; min-width: 170px; } #root-${uuid} .field input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } #root-${uuid} button { border: 1px solid var(--line); background: var(--accent); color: #071019; font-weight: 600; font-size: 13px; padding: 8px 16px; border-radius: 4px; cursor: pointer; transition: filter 0.15s ease; } #root-${uuid} button:hover { filter: brightness(1.08); } #root-${uuid} button:active { filter: brightness(0.95); } #root-${uuid} button.secondary { background: transparent; color: var(--text); border: 1px solid var(--line); } #root-${uuid} button:disabled { opacity: 0.5; cursor: not-allowed; } #root-${uuid} .status { font-size: 12px; color: var(--text-dim); min-width: 160px; } #root-${uuid} .status.error { color: var(--danger); } #root-${uuid} .chart-wrap { position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; height: 360px; min-height: 260px; flex: 0 0 auto; } #root-${uuid} .chart-canvas-holder { position: relative; width: 100%; height: 100%; } #root-${uuid} canvas { width: 100% !important; height: 100% !important; display: block; } #root-${uuid} .empty-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; text-align: center; padding: 20px; } #root-${uuid} .marker-line { position: absolute; top: 0; width: 0; border-left: 2px dashed; cursor: ew-resize; touch-action: none; z-index: 5; } #root-${uuid} .marker-handle { position: absolute; top: -22px; left: -11px; width: 22px; height: 20px; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: #071019; cursor: ew-resize; touch-action: none; user-select: none; } #root-${uuid} table.marker-table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; font-size: 13px; } #root-${uuid} table.marker-table caption { text-align: left; padding: 10px 12px; font-size: 12px; color: var(--text-dim); caption-side: top; } #root-${uuid} table.marker-table th, #root-${uuid} table.marker-table td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; } #root-${uuid} table.marker-table thead th { background: var(--panel-2); color: var(--text-dim); font-weight: 600; font-size: 12px; } #root-${uuid} table.marker-table tbody tr:last-child td { border-bottom: none; } #root-${uuid} .marker-dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: middle; } /* Печать теперь выполняется через отдельное чистое окно (см. openPrintWindow), поэтому здесь достаточно на всякий случай прятать панель управления, если кто-то всё же нажмёт Ctrl+P прямо на экране IntraSCADA. */ #root-${uuid} .no-print { /* переопределяется ниже через @media print */ } @media print { #root-${uuid} .no-print { display: none !important; } } </style> <div id="root-${uuid}"> <div class="controls no-print"> <div class="field"> <label for="dtStart-${uuid}">Начало эксперимента</label> <input type="text" id="dtStart-${uuid}" placeholder="ДД.ММ.ГГГГ ЧЧ:ММ:СС" pattern="\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}(:\d{2})?" title="Формат: ДД.ММ.ГГГГ ЧЧ:ММ:СС" /> </div> <div class="field"> <label for="dtEnd-${uuid}">Конец эксперимента</label> <input type="text" id="dtEnd-${uuid}" placeholder="ДД.ММ.ГГГГ ЧЧ:ММ:СС" pattern="\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}(:\d{2})?" title="Формат: ДД.ММ.ГГГГ ЧЧ:ММ:СС" /> </div> <button id="btnLoad-${uuid}" type="button">Показать</button> <button id="btnPrint-${uuid}" type="button" class="secondary" disabled>Печать</button> <span id="status-${uuid}" class="status"></span> </div> <div class="chart-wrap" id="chartWrap-${uuid}"> <div class="chart-canvas-holder" id="chartHolder-${uuid}"> <canvas id="canvas-${uuid}"></canvas> </div> <div class="empty-state" id="emptyState-${uuid}"> Укажите период эксперимента и нажмите «Показать» </div> </div> <table class="marker-table" id="table-${uuid}"> <caption>Значения температур в точках маркеров</caption> <thead> <tr id="tableHead-${uuid}"></tr> </thead> <tbody id="tableBody-${uuid}"></tbody> </table> </div> <script> (function () { // ========================================================================== // КОНФИГУРАЦИЯ — правьте под свой проект // ========================================================================== var CFG = { // Путь REST API эндпоинта проекта (Источники данных → REST API), // который должен вернуть исторические данные температур за период. restEndpoint: '/restapi/forGraph', // Имена query-параметров, которые виджет отправит на эндпоинт. // Отправляются одновременно ms (unix-время в миллисекундах) и ISO-строка — // используйте то, что удобнее в обработчике. params: { startMs: 'start_ms', endMs: 'end_ms', startIso: 'start_iso', endIso: 'end_iso', tags: 'tags' }, // Температурные кривые. tag = имя устройства в IntraSCADA // (то же самое имя уходит в параметр tags запроса и должно совпадать // с именем, которое ищет holder.getDevice(tag) в обработчике api002). series: [ { tag: 's_047', label: 'sns 1 test', color: '#4fb3d9' }, { tag: 's_048', label: 'sns 2 test', color: '#f2a154' } // Добавляй сюда новые устройства по такому же образцу: // { tag: 's_049', label: 'sns 3', color: '#7fd8a0' }, ], markerCount: 5, markerColors: ['#dbe4ec', '#f2a154', '#7fd8a0', '#c98bde', '#e2694b'] }; // ========================================================================== // Разбор ответа REST API (см. обработчик api002 маршрута /forGraph). // Ожидаемый формат ответа: // { // "res": 1, // "data": { // "s_047": [ { "ts": 1699999999000, "value": 23.4 }, ... ], // "s_048": [ ... ] // } // } // При ошибке на сервере: { "res": 0, "message": "..." } // ========================================================================== function parseApiResponse(json) { console.log("respose " + JSON.stringify(json)) if (!json || json.res === 0) { throw new Error((json && json.message) || 'Сервер вернул ошибку'); } var payload = json.data || {}; var result = {}; CFG.series.forEach(function (s) { var raw = payload[s.tag] || []; var points = raw .map(function (p) { return { ts: Number(p.ts), value: Number(p.value) }; }) .filter(function (p) { return isFinite(p.ts) && isFinite(p.value); }); points.sort(function (a, b) { return a.ts - b.ts; }); result[s.tag] = points; }); return result; } // ========================================================================== // Служебные утилиты // ========================================================================== var uid = '${uuid}'; function $(id) { return document.getElementById(id + '-' + uid); } function pad(n) { return n < 10 ? '0' + n : '' + n; } function fmtRuDateTime(d) { return pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); } // Разбор строки формата ДД.ММ.ГГГГ ЧЧ:ММ[:СС] в unix-время (мс). // Возвращает NaN, если строка не соответствует формату или дата некорректна // (например 31.02.2026 не пройдёт проверку после round-trip). function parseRuDateTime(str) { if (!str) return NaN; var m = String(str).trim().match(/^(\d{2})\.(\d{2})\.(\d{4})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/); if (!m) return NaN; var day = Number(m[1]), month = Number(m[2]), year = Number(m[3]); var hour = Number(m[4]), min = Number(m[5]), sec = m[6] ? Number(m[6]) : 0; var d = new Date(year, month - 1, day, hour, min, sec, 0); if (d.getDate() !== day || d.getMonth() !== month - 1 || d.getFullYear() !== year || d.getHours() !== hour || d.getMinutes() !== min) { return NaN; } return d.getTime(); } function fmtDisplay(ts) { var d = new Date(ts); return pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); } function fmtTick(ts) { var d = new Date(ts); return pad(d.getHours()) + ':' + pad(d.getMinutes()) + '\n' + pad(d.getDate()) + '.' + pad(d.getMonth() + 1); } // Линейная интерполяция значения серии в момент ts. function valueAt(points, ts) { if (!points || points.length === 0) return null; if (ts <= points[0].ts) return points[0].value; if (ts >= points[points.length - 1].ts) return points[points.length - 1].value; for (var i = 0; i < points.length - 1; i++) { var a = points[i], b = points[i + 1]; if (ts >= a.ts && ts <= b.ts) { if (b.ts === a.ts) return a.value; var frac = (ts - a.ts) / (b.ts - a.ts); return a.value + (b.value - a.value) * frac; } } return null; } // ========================================================================== // Состояние виджета // ========================================================================== var chart = null; var seriesData = {}; // { tag: [{ts,value}, ...] } var rangeStart = null; var rangeEnd = null; var markers = []; // [{ ts, lineEl, handleEl }] var dragging = null; var resizeObserver = null; var elStart = $('dtStart'); var elEnd = $('dtEnd'); var btnLoad = $('btnLoad'); var btnPrint = $('btnPrint'); var elStatus = $('status'); var chartWrap = $('chartWrap'); var chartHolder = $('chartHolder'); var emptyState = $('emptyState'); var canvas = $('canvas'); var tableHead = $('tableHead'); var tableBody = $('tableBody'); function setStatus(text, isError) { elStatus.textContent = text || ''; elStatus.className = 'status' + (isError ? ' error' : ''); } // По умолчанию: начало — сегодня 00:00, конец — сейчас. (function initDefaults() { var now = new Date(); var startDefault = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); elStart.value = fmtRuDateTime(startDefault); elEnd.value = fmtRuDateTime(now); })(); // ========================================================================== // Загрузка Chart.js (один раз на страницу) // ========================================================================== function ensureChartJs(callback) { if (window.Chart) { callback(); return; } var existing = document.getElementById('chartjs-lib'); if (existing) { if (window.Chart) { callback(); return; } existing.addEventListener('load', callback); return; } var script = document.createElement('script'); script.id = 'chartjs-lib'; // Сначала пробуем публичный CDN (актуально при подключении через p2p, // где window.location.hostname === 'p2p.ih-systems.com'). script.src = 'https://cdn.jsdelivr.net/npm/chart.js'; script.addEventListener('load', callback); script.addEventListener('error', function () { // Нет доступа в интернет — берём локальную копию из папки anyfiles // проекта, которая на сервере отдаётся по пути /files/... setStatus('CDN недоступен, загружаю локальную копию Chart.js...'); var localScript = document.createElement('script'); localScript.id = 'chartjs-lib'; // тот же id, чтобы не пытаться грузить дважды localScript.src = '/files/chart.js'; localScript.addEventListener('load', callback); localScript.addEventListener('error', function () { setStatus('Не удалось загрузить библиотеку графиков ни с CDN, ни локально (/files/chart.js)', true); }); document.head.appendChild(localScript); }); document.head.appendChild(script); } // ========================================================================== // Запрос данных через REST API // ========================================================================== function requestData(startMs, endMs) { var tags = CFG.series.map(function (s) { return s.tag; }).join(','); var qs = [ CFG.params.startMs + '=' + encodeURIComponent(startMs), CFG.params.endMs + '=' + encodeURIComponent(endMs), CFG.params.startIso + '=' + encodeURIComponent(new Date(startMs).toISOString()), CFG.params.endIso + '=' + encodeURIComponent(new Date(endMs).toISOString()), CFG.params.tags + '=' + encodeURIComponent(tags) ].join('&'); var url = CFG.restEndpoint + '?' + qs; //window.ihapi.fetch('/restapi/myapi').then(res => res.json()).then(json => console.log(json)) return window.ihapi.fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } }) //return window.ihapi.fetch(url) .then(function (resp) { // Обработчик кладёт понятный текст ошибки в тело (res:0, message) // даже при HTTP-статусе 500, поэтому сперва пытаемся распарсить JSON. return resp.json().catch(function () { throw new Error('HTTP ' + resp.status); }); }) .then(parseApiResponse); } // ========================================================================== // Построение / обновление графика // ========================================================================== function buildChart() { var datasets = CFG.series.map(function (s) { var points = seriesData[s.tag] || []; return { label: s.label, data: points.map(function (p) { return { x: p.ts, y: p.value }; }), borderColor: s.color, backgroundColor: s.color, borderWidth: 2, pointRadius: 0, tension: 0.15, spanGaps: true }; }); var config = { type: 'line', data: { datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: 'nearest', axis: 'x', intersect: false }, scales: { x: { type: 'linear', min: rangeStart, max: rangeEnd, ticks: { color: '#8ea0b0', maxTicksLimit: 8, callback: function (val) { return fmtTick(val); } }, grid: { color: 'rgba(255,255,255,0.06)' } }, y: { ticks: { color: '#8ea0b0' }, grid: { color: 'rgba(255,255,255,0.06)' }, title: { display: true, text: '°C', color: '#8ea0b0' } } }, plugins: { legend: { labels: { color: '#dbe4ec', usePointStyle: true, boxWidth: 8, boxHeight: 8 } }, tooltip: { callbacks: { title: function (items) { return fmtDisplay(items[0].parsed.x); }, label: function (ctx) { return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + ' °C'; } } } } } }; if (chart) { chart.data = config.data; chart.options.scales.x.min = rangeStart; chart.options.scales.x.max = rangeEnd; chart.update(); } else { chart = new Chart(canvas.getContext('2d'), config); } } // ========================================================================== // Маркеры: создание, позиционирование, перетаскивание // ========================================================================== function clearMarkers() { markers.forEach(function (m) { if (m.lineEl && m.lineEl.parentNode) m.lineEl.parentNode.removeChild(m.lineEl); }); markers = []; } function createMarkers() { clearMarkers(); var span = rangeEnd - rangeStart; var fractions = [0.1, 0.3, 0.5, 0.7, 0.9].slice(0, CFG.markerCount); fractions.forEach(function (frac, idx) { var ts = rangeStart + span * frac; var color = CFG.markerColors[idx % CFG.markerColors.length]; var line = document.createElement('div'); line.className = 'marker-line'; line.style.borderColor = color; var handle = document.createElement('div'); handle.className = 'marker-handle'; handle.style.background = color; handle.textContent = 'M' + (idx + 1); line.appendChild(handle); chartHolder.appendChild(line); var markerObj = { ts: ts, lineEl: line, handleEl: handle, index: idx, color: color }; attachDrag(markerObj); markers.push(markerObj); }); positionMarkers(); buildTable(); } function positionMarkers() { if (!chart || !chart.chartArea) return; var area = chart.chartArea; var xScale = chart.scales.x; markers.forEach(function (m) { var px = xScale.getPixelForValue(m.ts); px = Math.max(area.left, Math.min(area.right, px)); m.lineEl.style.left = px + 'px'; m.lineEl.style.top = area.top + 'px'; m.lineEl.style.height = (area.bottom - area.top) + 'px'; }); } function attachDrag(m) { function onPointerDown(e) { e.preventDefault(); dragging = m; m.handleEl.setPointerCapture && e.pointerId != null && m.handleEl.setPointerCapture(e.pointerId); document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); } function onPointerMove(e) { if (!dragging || dragging !== m || !chart || !chart.chartArea) return; var rect = chartHolder.getBoundingClientRect(); var area = chart.chartArea; var x = e.clientX - rect.left; x = Math.max(area.left, Math.min(area.right, x)); var ts = chart.scales.x.getValueForPixel(x); ts = Math.max(rangeStart, Math.min(rangeEnd, ts)); m.ts = ts; m.lineEl.style.left = x + 'px'; updateTableRow(m); } function onPointerUp() { dragging = null; document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); } m.handleEl.addEventListener('pointerdown', onPointerDown); m.lineEl.addEventListener('pointerdown', onPointerDown); } // ========================================================================== // Таблица значений // ========================================================================== function buildTable() { var headRow = ['<th>Маркер</th><th>Время</th>']; CFG.series.forEach(function (s) { headRow.push('<th><span class="marker-dot" style="background:' + s.color + '"></span>' + s.label + ', °C</th>'); }); tableHead.innerHTML = headRow.join(''); tableBody.innerHTML = ''; markers.forEach(function (m) { var tr = document.createElement('tr'); tr.id = 'row-' + uid + '-' + m.index; tableBody.appendChild(tr); updateTableRow(m); }); } function updateTableRow(m) { var tr = document.getElementById('row-' + uid + '-' + m.index); if (!tr) return; var cells = [ '<td><span class="marker-dot" style="background:' + m.color + '"></span>M' + (m.index + 1) + '</td>', '<td>' + fmtDisplay(m.ts) + '</td>' ]; CFG.series.forEach(function (s) { var v = valueAt(seriesData[s.tag], m.ts); cells.push('<td>' + (v === null ? '—' : v.toFixed(1)) + '</td>'); }); tr.innerHTML = cells.join(''); } // ========================================================================== // Основной обработчик кнопки «Показать» // ========================================================================== function onLoadClick() { var startVal = elStart.value; var endVal = elEnd.value; if (!startVal.trim() || !endVal.trim()) { setStatus('Укажите обе даты — начало и конец', true); return; } var startMs = parseRuDateTime(startVal); var endMs = parseRuDateTime(endVal); if (!isFinite(startMs) || !isFinite(endMs)) { setStatus('Неверный формат даты. Используйте ДД.ММ.ГГГГ ЧЧ:ММ:СС', true); return; } if (startMs >= endMs) { setStatus('Начало должно быть раньше конца', true); return; } btnLoad.disabled = true; setStatus('Загрузка данных...'); ensureChartJs(function () { requestData(startMs, endMs) .then(function (data) { seriesData = data; rangeStart = startMs; rangeEnd = endMs; var hasAnyPoints = CFG.series.some(function (s) { return (seriesData[s.tag] || []).length > 0; }); emptyState.style.display = hasAnyPoints ? 'none' : 'flex'; if (!hasAnyPoints) { setStatus('За указанный период данные не найдены', true); btnLoad.disabled = false; btnPrint.disabled = true; return; } buildChart(); createMarkers(); btnPrint.disabled = false; setStatus('Данные загружены: ' + fmtDisplay(startMs) + ' — ' + fmtDisplay(endMs)); }) .catch(function (err) { setStatus('Ошибка запроса: ' + err.message, true); }) .finally(function () { btnLoad.disabled = false; }); }); } function openPrintWindow() { if (!chart) { setStatus('Сначала загрузите данные', true); return; } var imgData = chart.toBase64Image('image/png', 1); var periodText = fmtDisplay(rangeStart) + ' — ' + fmtDisplay(rangeEnd); var rowsHtml = markers.map(function (m) { var cells = ['<td>M' + (m.index + 1) + '</td>', '<td>' + fmtDisplay(m.ts) + '</td>']; CFG.series.forEach(function (s) { var v = valueAt(seriesData[s.tag], m.ts); cells.push('<td>' + (v === null ? '—' : v.toFixed(1)) + '</td>'); }); return '<tr>' + cells.join('') + '</tr>'; }).join(''); var headHtml = ['<th>Маркер</th><th>Время</th>'] .concat(CFG.series.map(function (s) { return '<th>' + s.label + ', °C</th>'; })) .join(''); var printHtml = '<!DOCTYPE html><html><head><meta charset="utf-8">' + '<title>Температуры эксперимента</title>' + '<style>' + 'body{font-family:Arial,Helvetica,sans-serif;color:#111;padding:24px;margin:0;}' + 'h1{font-size:16px;margin:0 0 4px;}' + '.period{font-size:13px;color:#555;margin-bottom:16px;}' + 'img{width:100%;max-width:1000px;display:block;margin-bottom:20px;border:1px solid #ccc;}' + 'table{border-collapse:collapse;width:100%;max-width:1000px;font-size:13px;}' + 'th,td{border:1px solid #999;padding:6px 10px;text-align:left;}' + 'th{background:#eee;}' + '</style></head><body>' + '<h1>Температуры эксперимента</h1>' + '<div class="period">Период: ' + periodText + '</div>' + '<img src="' + imgData + '" alt="График температур" />' + '<table><thead><tr>' + headHtml + '</tr></thead><tbody>' + rowsHtml + '</tbody></table>' + '</body></html>'; // Печатаем через скрытый iframe в текущей странице (без создания // отдельного окна) — так надёжнее работает во встроенном браузере // IntraSCADA и не мешает работе полей ввода на основном экране. var oldFrame = document.getElementById('printFrame-' + uid); if (oldFrame) oldFrame.parentNode.removeChild(oldFrame); var iframe = document.createElement('iframe'); iframe.id = 'printFrame-' + uid; iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0'; iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0'; iframe.style.visibility = 'hidden'; document.body.appendChild(iframe); var cleaned = false; function cleanup() { if (cleaned) return; cleaned = true; setTimeout(function () { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000); } iframe.onload = function () { try { iframe.contentWindow.focus(); iframe.contentWindow.print(); } catch (e) { setStatus('Ошибка печати: ' + e.message, true); } cleanup(); }; var doc = iframe.contentWindow.document; doc.open(); doc.write(printHtml); doc.close(); // Подстраховка на случай, если onload не сработает в конкретном браузере. setTimeout(function () { if (!cleaned) { try { iframe.contentWindow.focus(); iframe.contentWindow.print(); } catch (e) { /* уже обработано выше при удачном сценарии */ } cleanup(); } }, 500); } btnLoad.addEventListener('click', onLoadClick); btnPrint.addEventListener('click', openPrintWindow); // Перерисовка маркеров при изменении размеров контейнера графика. if (window.ResizeObserver) { resizeObserver = new ResizeObserver(function () { if (chart) { chart.resize(); positionMarkers(); } }); resizeObserver.observe(chartWrap); } else { window.addEventListener('resize', function () { if (chart) { chart.resize(); positionMarkers(); } }); } // Уборка за собой при удалении виджета с экрана (если ihapi доступен). if (window.ihapi && window.ihapi.addEventListener) { window.ihapi.addEventListener(uid, 'destroy', function () { if (resizeObserver) resizeObserver.disconnect(); if (chart) chart.destroy(); }); } })(); </script>Файл для импорта, прикреплю чуть позже
Здравствуйте! Похоже, вам интересна эта беседа, но у вас пока нет учетной записи.
Вы устали просматривать одни и те же посты каждый раз, когда заходите на сайт? После регистрации, вам не придётся искать обсуждения в которых вы принимали участие, настройте уведомления о новых сообщениях так как вам это удобно (по электронной почте или уведомлением). У вас появится возможность сохранять закладки и ставить лайки постам, чтобы выразить свою благодарность другим участникам сообщества.
С вашими комментариями этот пост может стать ещё лучше 💗
Зарегистрироваться Войти