Intra Forum
    • Категории
    • Последние
    • Метки
    • Популярные
    • Зарегистрироваться
    • Войти

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

    Запланировано Прикреплена Закрыта Перенесена HTML виджеты
    7 Сообщения 3 Posters 136 Просмотры 3 Watching
    Загружаем больше сообщений
    • Сначала старые
    • Сначала новые
    • По количеству голосов
    Ответить
    • Ответить, создав новую тему
    Авторизуйтесь, чтобы ответить
    Эта тема была удалена. Только пользователи с правом управления темами могут её видеть.
    • G Не в сети
      grinsva
      отредактировано grinsva

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

      Пример как все выглядит:
      cf7fd5a4-793a-43f7-8daf-9a8b26d5c5a0.jpg

      Процесс:

      • создаем контейнер
      • вставляем HTML виджет
      • вставляем код.

      Созданы несколько локальных переменных:

      • переменная с результирующим объектом из точек;

      • 2 переменные для включения/отключения масштабирования по осям;
        738f69e1-103c-4beb-937c-28c009faf55a-image.jpeg

      • переменная с массивом типа [1,1,1,1] для включения отображения или скрытия каких то графиков, т.к. при использовании встроенного функционала при обновлении графика все кривые вновь появляются и надо их снова отключать.
        350b1a56-d6bd-4edd-afc6-9a378a2a0edd-image.jpeg

      Изменение настроечных переменных график отслеживает по подписке.

      Входящие данные для графиков формируются в скрипте визуализации(можно делать каким угодно образом, не обязательно так). В моем случае я формирую такой же объект для графика как в стандартном обработчике и с ним работаю.

      Итоговый код (комментарии есть, если не понятно закидываем в ИИ):

      G 1 ответ Последний ответ Ответить Цитировать 0
      • G Не в сети
        grinsva @grinsva
        отредактировано

        Добавил в версию при отсутствии интернета или доступа к внешним ресурсам загружать библиотеки с папки \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>
        
        M 1 ответ Последний ответ Ответить Цитировать 0
        • G Не в сети
          grinsva
          отредактировано grinsva

          В папку \anyfiles поместить 2 файла с библиотекой

          1. chart.js - код для него можно скачать по ссылке https://cdn.jsdelivr.net/npm/chart.js и вставить в файл
          2. chartjs - plugin-zoom.js - код для него можно скачать по ссылке https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom и вставить в файл
          M 2 ответов Последний ответ Ответить Цитировать 0
          • M Не в сети
            maksimvershinin Intra Support @grinsva
            отредактировано maksimvershinin

            @grinsva Контейнер с готовым HTML виджетом доступен во вложении vc291_1.ihpack. Установить можно через импорт.

            1 ответ Последний ответ Ответить Цитировать 0
            • M Не в сети
              maksimvershinin Intra Support @grinsva
              отредактировано

              @grinsva Для того, чтобы ваш HTML виджет мог работать через p2p, то вам необходимо понимать откуда закачивать сторонние библиотеки. Для этого в HTML виджете можете воспользоваться window.location.hostname, который вернет hostname из адресной строки. В случае подключения через p2p вернется p2p.ih-systems.com. В этом случае ссылку на библиотеку делаем из публичных cdn, если запускается локально, без доступа в интернет, то библиотеку берем из "/files/chart.js"

              1 ответ Последний ответ Ответить Цитировать 0
              • M Не в сети
                maksimvershinin Intra Support @grinsva
                отредактировано

                @grinsva Так же прошу обратить внимание, что данные по точкам, которые необходимо отрисовать на графике, желательно получать через RESTApi, а не через переменные клиента, так как это большой объем данных и от этого может пострадать производительность системы.

                1 ответ Последний ответ Ответить Цитировать 0
                • I Не в сети
                  intg76
                  отредактировано

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

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

                  1 ответ Последний ответ Ответить Цитировать 0

                  Здравствуйте! Похоже, вам интересна эта беседа, но у вас пока нет учетной записи.

                  Вы устали просматривать одни и те же посты каждый раз, когда заходите на сайт? После регистрации, вам не придётся искать обсуждения в которых вы принимали участие, настройте уведомления о новых сообщениях так как вам это удобно (по электронной почте или уведомлением). У вас появится возможность сохранять закладки и ставить лайки постам, чтобы выразить свою благодарность другим участникам сообщества.

                  С вашими комментариями этот пост может стать ещё лучше 💗

                  Зарегистрироваться Войти
                  • Первое сообщение
                    Последнее сообщение