• Пример ГРАФИКА на HTML виджете.

    7
    3
    0 Голоса
    7 Сообщения
    136 Просмотры
    I
    Здравствуйте, на основе первой версии сделал версию с несколькими маркерами и печатью в файл. Прикрепляю исходный код REST API и виджета. Внешний вид: [image: 1788269451962-3d8bdb83-301c-4e4d-8139-9cb25c81ffaa-image.jpeg] REST API метод GET маршрут /forGraph module.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> Файл для импорта, прикреплю чуть позже
  • Вертикальный текст

    1
    1
    0 Голоса
    1 Сообщения
    42 Просмотры
    M
    Пример HTML виджета в шаблоне визуализации для вывода вертикального текста прижатого к низу и автоматическим переносом на следующую строку.@vt099.ihpack [image: 1788159128101-%D1%81%D0%BD%D0%B8%D0%BC%D0%BE%D0%BA-%D1%8D%D0%BA%D1%80%D0%B0%D0%BD%D0%B0-2026-08-31-%D0%B2-09.50.27.png]
  • Привязка локальных переменных для Графиков в HTML

    4
    2
    0 Голоса
    4 Сообщения
    67 Просмотры
    M
    Да вы все делаете правильно. Нужно еще добавить window.ihapi.addEventListener(uuid, "data", onData); чтобы при изменении переменных клиента вызывалась функция onData и данные обновлялись
  • Всплывающая пользовательская клавиатура

    1
    1
    0 Голоса
    1 Сообщения
    52 Просмотры
    M
    С версии 5.19.24 мы расширили возможности HTML виджета и добавили команды window.ihapi.getTriggerUuid, window.ihapi.getTriggerMeta для получения данных о связях элементов. Это позволило нам перехватывать ввод/вывод например с Input. Во вложении пример клавиатуры которая создана с помощью HTML виджета, которую можно вызываеть по клику мыши на Input.@di0015.ihpack [image: 1787118759319-%D1%81%D0%BD%D0%B8%D0%BC%D0%BE%D0%BA-%D1%8D%D0%BA%D1%80%D0%B0%D0%BD%D0%B0-2026-08-19-%D0%B2-08.51.07.png]
  • Всплывающая подсказка Tooltip. Шаблон визуализации.

    1
    0 Голоса
    1 Сообщения
    113 Просмотры
    M
    Многие из вас просили в системе реализовать всплывающие подсказки на элементах. Мы сделали лучше, теперь вы можете реализовать такие подсказки с помощью шаблона визуализации c HTML виджетом внутри, при этом расширив функционал как вам будет нужно. Пример шаблона визуализации во вложении@vt032.ihpack . Достаточно импортировать его в проект, вставьте шаблон tooltip в нужные шаблоны, в родительском шаблоне для шаблона tooltip выполните привязку и можно пользоваться. Напомню с версии 5.19.22 есть возможность использовать шаблон в шаблоне.
  • Редактируемый из интерфейса пользователя график

    Перенесена
    2
    0 Голоса
    2 Сообщения
    352 Просмотры
    M
    Добрый день. Есть пример такого графика. Он реализован на графике квадратичной функции, где с помощью мышки можно менять три точки, по сути три коэффициента a,b,c функции y = ax² − bx − c. Для удобства сделано три режима: День, Ночь, Эконом. Для каждого можно настроить свой график. Вот пример HTML виджета. <style type="text/css"> #mode-select { margin-bottom: 10px; font-size: 14px; padding: 5px; } #canvas { border: 1px solid #000; cursor: grab; display: block; margin: 0 auto; } #canvas:active { cursor: grabbing; } #formula { margin-top: 15px; font-size: 16px; font-weight: bold; text-align: center; padding: 5px; } #info { margin-top: 10px; color: #666; font-size: 12px; text-align: center; padding: 0 10px; } #tooltip { position: absolute; background: rgba(0, 0, 0, 0.8); color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; pointer-events: none; display: none; z-index: 10; } </style> <script type="text/javascript"> // Диагностика UUID (временно, для отладки — удалите после) console.log('Available UUID sources:', { template: '${uuid}', window_uuid: window.uuid, ihapi_uuid: window.ihapi?.uuid, global_uuid: typeof uuid !== 'undefined' ? uuid : 'undefined' }); // Используем глобальный uuid, если доступен const currentUuid = uuid || window.uuid || window.ihapi?.uuid || ''; let canvas, ctx, formulaDiv, tooltip, modeSelect; let points = [ {x: -20, y: 70}, {x: 0, y: 55}, {x: 10, y: 40} ]; let selectedPoint = null; let isDragging = false; let currentMode = 'day'; // Текущий режим по умолчанию let modeCoeffs = { // Коэффициенты для каждого режима day: {a: 0, b: 0, c: 0}, night: {a: 0, b: 0, c: 0}, eco: {a: 0, b: 0, c: 0} }; let currentCoeffs = {a: 0, b: 0, c: 0}; // Текущие для рисования // Параметры устройства и свойств const deviceId = 'd0228'; const modes = ['day', 'night', 'eco']; const fullProps = { day: `${deviceId}_propABC_day`, night: `${deviceId}_propABC_night`, eco: `${deviceId}_propABC_eco` }; // Параметры графика (фиксированные, как в начале) const margin = 50; const xMin = -30, xMax = 10; const yMin = 45, yMax = 75; // Функции преобразования координат (фиксированные) function toCanvasX(x) { return margin + (x - xMin) / (xMax - xMin) * (canvas.width - 2 * margin); } function toCanvasY(y) { return (canvas.height - margin) - (y - yMin) / (yMax - yMin) * (canvas.height - 2 * margin); } function fromCanvasX(canvasX) { return xMin + (canvasX - margin) / (canvas.width - 2 * margin) * (xMax - xMin); } function fromCanvasY(canvasY) { return yMin + ((canvas.height - margin) - canvasY) / (canvas.height - 2 * margin) * (yMax - yMin); } // Вычисление коэффициентов квадратичной кривой function computeQuadraticCoeffs() { if (points.length !== 3) return {a: 0, b: 0, c: 0}; let [p1, p2, p3] = points; let x1 = p1.x, y1 = p1.y; let x2 = p2.x, y2 = p2.y; let x3 = p3.x, y3 = p3.y; let d12 = x2 - x1; let d13 = x3 - x1; let dd12 = x2 * x2 - x1 * x1; let dd13 = x3 * x3 - x1 * x1; let dy12 = y2 - y1; let dy13 = y3 - y1; let det = dd12 * d13 - dd13 * d12; if (Math.abs(det) < 1e-10) { let n = 3; let sumX = x1 + x2 + x3; let sumY = y1 + y2 + y3; let sumXY = x1*y1 + x2*y2 + x3*y3; let sumX2 = x1*x1 + x2*x2 + x3*x3; let slope = (3 * sumXY - sumX * sumY) / (3 * sumX2 - sumX * sumX); let intercept = (sumY - slope * sumX) / 3; return {a: 0, b: slope, c: intercept}; } let a = (dy12 * d13 - dy13 * d12) / det; let b = (dd12 * dy13 - dd13 * dy12) / det; let c = y1 - a * x1 * x1 - b * x1; return {a, b, c}; } // Вычисление y по x и коэффициентам function computeY(x, coeffs) { return coeffs.a * x * x + coeffs.b * x + coeffs.c; } // Обновление коэффициентов в устройстве (JSON в свойство для режима) function updateCoeffsInDevice() { const coeffs = computeQuadraticCoeffs(); currentCoeffs = coeffs; // Сохраняем текущие const jsonStr = JSON.stringify(coeffs); if (window.ihapi && window.ihapi.deviceCommand) { window.ihapi.deviceCommand(deviceId, `propABC_${currentMode}`, jsonStr); console.log(`Sent JSON for ${currentMode}:`, jsonStr); // Для отладки } } // Обновление точек по коэффициентам (из подписки) function updatePointsFromCoeffs(a, b, c) { const coeffs = {a, b, c}; points.forEach(p => { p.y = Math.max(yMin, Math.min(yMax, computeY(p.x, coeffs))); }); } // Смена режима function switchMode(newMode) { currentMode = newMode; currentCoeffs = {...modeCoeffs[newMode]}; // Копируем updatePointsFromCoeffs(currentCoeffs.a, currentCoeffs.b, currentCoeffs.c); draw(); modeSelect.value = newMode; // Синхронизация селекта } // Рисование осей (фиксированные подписи, без наложения) function drawAxes() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#000'; ctx.lineWidth = 1; // X-ось ctx.beginPath(); ctx.moveTo(margin, canvas.height - margin); ctx.lineTo(canvas.width - margin, canvas.height - margin); ctx.stroke(); // Y-ось ctx.beginPath(); ctx.moveTo(margin, margin); ctx.lineTo(margin, canvas.height - margin); ctx.stroke(); // Подписи (подвинуты ниже/выше для избежания наложения) ctx.fillText('Уличная T (°C)', canvas.width / 2, canvas.height - 10); // X-label ниже ctx.save(); ctx.translate(10, canvas.height / 2); ctx.rotate(-Math.PI / 2); ctx.fillText('Теплоноситель T (°C)', 0, 0); // Y-label слева ctx.restore(); // Метки X (с отступом) for (let i = 0; i <= 5; i++) { let x = xMin + i * (xMax - xMin) / 5; let cx = toCanvasX(x); ctx.fillText(x.toFixed(0), cx - 10, canvas.height - margin + 20); // Отступ от оси ctx.beginPath(); ctx.moveTo(cx, canvas.height - margin - 5); ctx.lineTo(cx, canvas.height - margin + 5); ctx.stroke(); } // Метки Y (с отступом) for (let i = 0; i <= 5; i++) { let y = yMin + i * (yMax - yMin) / 5; let cy = toCanvasY(y); ctx.fillText(y.toFixed(0), 5, cy + 5); // Отступ слева ctx.beginPath(); ctx.moveTo(margin - 5, cy); ctx.lineTo(margin + 5, cy); ctx.stroke(); } } // Рисование кривой и точек function drawCurveAndPoints() { const coeffs = computeQuadraticCoeffs(); // Кривая ctx.strokeStyle = '#ff0000'; ctx.lineWidth = 2; ctx.beginPath(); let step = (xMax - xMin) / 100; for (let i = 0; i <= 100; i++) { let x = xMin + i * step; let y = Math.max(yMin, Math.min(yMax, computeY(x, coeffs))); let cx = toCanvasX(x); let cy = toCanvasY(y); if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy); } ctx.stroke(); // Формула formulaDiv.textContent = `y = ${coeffs.a.toFixed(3)}x² + ${coeffs.b.toFixed(3)}x + ${coeffs.c.toFixed(3)} (${currentMode})`; // Точки ctx.fillStyle = '#0000ff'; points.forEach((p, idx) => { let cx = toCanvasX(p.x); let cy = toCanvasY(p.y); ctx.beginPath(); ctx.arc(cx, cy, 5, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = '#000'; ctx.fillText(idx + 1, cx + 8, cy - 8); ctx.fillStyle = '#0000ff'; }); } // Рисование перекрестия function drawCrosshair(mouseX, mouseY) { if (mouseX < margin || mouseX > canvas.width - margin || mouseY < margin || mouseY > canvas.height - margin) return; ctx.strokeStyle = '#888'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); // Вертикальная ctx.beginPath(); ctx.moveTo(mouseX, margin); ctx.lineTo(mouseX, canvas.height - margin); ctx.stroke(); // Горизонтальная ctx.beginPath(); ctx.moveTo(margin, mouseY); ctx.lineTo(canvas.width - margin, mouseY); ctx.stroke(); ctx.setLineDash([]); } // Общая отрисовка function draw(mouseX = null, mouseY = null) { drawAxes(); drawCurveAndPoints(); if (mouseX !== null && mouseY !== null) { drawCrosshair(mouseX, mouseY); } } // Обработчики мыши function initMouseHandlers() { canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; if (isDragging && selectedPoint !== null) { const clampedX = Math.max(margin, Math.min(canvas.width - margin, mx)); const clampedY = Math.max(margin, Math.min(canvas.height - margin, my)); points[selectedPoint].x = fromCanvasX(clampedX); points[selectedPoint].y = fromCanvasY(clampedY); draw(); // Без crosshair во время dragging } else { const xReal = fromCanvasX(mx); if (mx >= margin && mx <= canvas.width - margin && my >= margin && my <= canvas.height - margin) { const yReal = Math.max(yMin, Math.min(yMax, computeY(xReal, currentCoeffs))); tooltip.style.display = 'block'; tooltip.innerHTML = `Уличная: ${xReal.toFixed(1)}°C<br>Котел: ${yReal.toFixed(1)}°C`; tooltip.style.left = `${e.pageX + 10}px`; tooltip.style.top = `${e.pageY - 10}px`; draw(mx, my); } else { tooltip.style.display = 'none'; draw(); } } }); canvas.addEventListener('mousedown', (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; selectedPoint = null; points.forEach((p, idx) => { const cx = toCanvasX(p.x); const cy = toCanvasY(p.y); if (Math.hypot(mx - cx, my - cy) < 10) { selectedPoint = idx; isDragging = true; tooltip.style.display = 'none'; } }); }); canvas.addEventListener('mouseup', () => { if (isDragging && selectedPoint !== null) { isDragging = false; selectedPoint = null; updateCoeffsInDevice(); draw(); // Финальное обновление без crosshair } }); canvas.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; draw(); // Очистить crosshair }); } // Update от подписки на коэффициенты (парсим JSON) function updateData(data) { let updated = false; modes.forEach(mode => { const fullProp = fullProps[mode]; if (data[fullProp]) { try { const parsed = JSON.parse(data[fullProp]); modeCoeffs[mode] = {a: parsed.a || 0, b: parsed.b || 0, c: parsed.c || 0}; if (mode === currentMode) { currentCoeffs = {...modeCoeffs[mode]}; updatePointsFromCoeffs(currentCoeffs.a, currentCoeffs.b, currentCoeffs.c); updated = true; } } catch (e) { console.warn(`Invalid JSON for ${mode}:`, data[fullProp]); } } }); if (updated) { draw(); } } // Init function init() { canvas = document.getElementById('canvas'); if (!canvas) { console.error('Canvas element not found'); return; } ctx = canvas.getContext('2d'); formulaDiv = document.getElementById('formula'); tooltip = document.getElementById('tooltip'); modeSelect = document.getElementById('mode-select'); if (!formulaDiv) { console.error('Formula div not found'); return; } // Настройка селекта режима modeSelect.addEventListener('change', (e) => { switchMode(e.target.value); }); // Безопасная настройка подписки и слушателей if (window.ihapi && currentUuid) { const localDestroy = function() { if (window.ihapi && window.ihapi.deviceUnsub) { window.ihapi.deviceUnsub(currentUuid, modes.map(m => fullProps[m])); } }; window.ihapi.addEventListener(currentUuid, 'destroy', localDestroy); window.ihapi.addEventListener(currentUuid, 'data', updateData); window.ihapi.deviceSub(currentUuid, modes.map(m => fullProps[m])); console.log('ihapi setup successful with UUID:', currentUuid); } else { console.warn('ihapi setup skipped (UUID or ihapi not available)'); } initMouseHandlers(); draw(); // Начальная отрисовка (обновится при 'data') } // Запуск init после загрузки DOM if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } </script> <select id="mode-select"> <option value="day">День</option> <option value="night">Ночь</option> <option value="eco">Эконом</option> </select> <canvas id="canvas" width="600" height="400"></canvas> <div id="formula">Формула: y = 0x² + 0x + 0</div> <div id="info">Перетаскивайте точки для настройки кривой. Выберите режим. Наведите мышь для перекрестия и тултипа с температурами. Изменения сохраняются в propABC_[режим].</div> <div id="tooltip"></div> Так же необходимо создать новый тип устройства, который будет состоять из трех свойств типа String propABC_day, propABC_night, propABC_eco. Создать экземпляр этого устройства в проекте и в HTML виджете указать id устройства в строке 72, переменная deviceId. Далее необходимо создать сценарий который будет запускаться например раз в час и пересчитывать температуру теплоносителя в зависимости от температуры на улице, режима работы и коэффициентов кривых. Вот пример сценария: /** * @desc * @version 5 */ const mode = Device("DN001"); const curves = Device("DN002"); const outdoor = Device("AI_003"); const kotel_temp = Device("DT_003"); startOnChange([mode.state, curves]) const script = { check() { return mode.state !== 3; }, start() { let curvesArr = []; curvesArr.push(JSON.parse(curves.propABC_day)); curvesArr.push(JSON.parse(curves.propABC_night)); curvesArr.push(JSON.parse(curves.propABC_eco)); const result = curvesArr[mode.state].a * outdoor.value * outdoor.value + curvesArr[mode.state].b * outdoor.value + curvesArr[mode.state].c const clipped = Math.max(45, Math.min(75, result)); this.log(Math.round(clipped)) kotel_temp.setValue("setpoint", Math.round(clipped)); } }; [image: 1777874455159-%D1%81%D0%BD%D0%B8%D0%BC%D0%BE%D0%BA-%D1%8D%D0%BA%D1%80%D0%B0%D0%BD%D0%B0-2026-05-04-%D0%B2-09.00.13-resized.png]