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