Intra Forum
    • Категории
    • Последние
    • Метки
    • Популярные
    • Зарегистрироваться
    • Войти
    1. Главная
    2. intg76
    3. Сообщения
    I
    Не в сети
    • Профиль
    • Подписки 0
    • Подписчики 0
    • Темы 0
    • Сообщения 1
    • Группы 0

    Сообщения

    Последние Лучшие сообщения Спорные
    • RE: Пример ГРАФИКА на HTML виджете.

      Здравствуйте, на основе первой версии сделал версию с несколькими маркерами и печатью в файл. Прикрепляю исходный код REST API и виджета. Внешний вид:
      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>
      
      

      Файл для импорта, прикреплю чуть позже

      написал в HTML виджеты
      I
      intg76