490 lines
15 KiB
PHP
490 lines
15 KiB
PHP
<?php // phpcs:ignoreFile
|
|
require __DIR__ . '/../../session.php';
|
|
require __DIR__ . '/../../userAccess.php';
|
|
|
|
$pageTitle = 'Static Multi-Tag Trend';
|
|
$pageSubtitle = 'Capture a three-hour snapshot for up to five historian tags';
|
|
$pageDescription = 'Select up to five tags and plot a zoomable three-hour history without auto-refresh.';
|
|
$assetBasePath = '../../';
|
|
$layoutWithoutSidebar = true;
|
|
$layoutReturnUrl = '../../overview.php';
|
|
$layoutReturnLabel = 'Back to overview';
|
|
|
|
require __DIR__ . '/../../includes/layout/header.php';
|
|
?>
|
|
|
|
<div class="app-content">
|
|
<section class="data-panel trend-panel">
|
|
<header class="trend-panel__header">
|
|
<div>
|
|
<h2 id="chartTitle">Static multi-tag snapshot</h2>
|
|
<p>Pick up to five tags, then load a three-hour trend you can pan and zoom.</p>
|
|
</div>
|
|
<span class="trend-status trend-status--stopped" id="chartStatus">IDLE</span>
|
|
</header>
|
|
|
|
<div class="trend-control-grid">
|
|
<div class="trend-control">
|
|
<label for="tagSelect1">Tag 1</label>
|
|
<select id="tagSelect1" class="tag-select">
|
|
<option value="">Loading tags...</option>
|
|
</select>
|
|
</div>
|
|
<div class="trend-control">
|
|
<label for="tagSelect2">Tag 2</label>
|
|
<select id="tagSelect2" class="tag-select">
|
|
<option value="">Loading tags...</option>
|
|
</select>
|
|
</div>
|
|
<div class="trend-control">
|
|
<label for="tagSelect3">Tag 3</label>
|
|
<select id="tagSelect3" class="tag-select">
|
|
<option value="">Loading tags...</option>
|
|
</select>
|
|
</div>
|
|
<div class="trend-control">
|
|
<label for="tagSelect4">Tag 4</label>
|
|
<select id="tagSelect4" class="tag-select">
|
|
<option value="">Loading tags...</option>
|
|
</select>
|
|
</div>
|
|
<div class="trend-control">
|
|
<label for="tagSelect5">Tag 5</label>
|
|
<select id="tagSelect5" class="tag-select">
|
|
<option value="">Loading tags...</option>
|
|
</select>
|
|
</div>
|
|
<div class="trend-control">
|
|
<label>Time window</label>
|
|
<div class="time-window-display">Last 3 hours (static)</div>
|
|
</div>
|
|
<div class="trend-control">
|
|
<label>Controls</label>
|
|
<div class="trend-control__actions">
|
|
<button type="button" class="button button--success" id="loadBtn">Load trend</button>
|
|
<button type="button" class="button button--ghost" id="resetZoomBtn" disabled>Reset zoom</button>
|
|
<button type="button" class="button button--ghost" id="clearBtn">Clear</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="status" class="trend-connection trend-connection--disconnected">
|
|
Select at least one tag and click Load trend to build a snapshot
|
|
</div>
|
|
|
|
<div id="statsWrapper" class="trend-stat-grid" hidden></div>
|
|
|
|
<div class="trend-chart">
|
|
<canvas id="staticChart"></canvas>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
|
<script
|
|
src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"
|
|
></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
|
|
<script
|
|
src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js"
|
|
></script>
|
|
|
|
<script>
|
|
if (window.Chart && window['chartjs-plugin-zoom']) {
|
|
Chart.register(window['chartjs-plugin-zoom']);
|
|
}
|
|
|
|
const MAX_TAGS = 5;
|
|
const DATASET_STYLES = [
|
|
{ border: '#3498db', background: 'rgba(52, 152, 219, 0.15)' },
|
|
{ border: '#27ae60', background: 'rgba(39, 174, 96, 0.15)' },
|
|
{ border: '#f39c12', background: 'rgba(243, 156, 18, 0.15)' },
|
|
{ border: '#e74c3c', background: 'rgba(231, 76, 60, 0.15)' },
|
|
{ border: '#9b59b6', background: 'rgba(155, 89, 182, 0.15)' }
|
|
];
|
|
|
|
let chart = null;
|
|
let lastWindow = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadTags();
|
|
document.getElementById('loadBtn').addEventListener('click', handleLoad);
|
|
document.getElementById('resetZoomBtn').addEventListener('click', resetZoom);
|
|
document.getElementById('clearBtn').addEventListener('click', clearChart);
|
|
});
|
|
|
|
function getTagSelects() {
|
|
return Array.from(document.querySelectorAll('.tag-select'));
|
|
}
|
|
|
|
function setStatus(state, message) {
|
|
const statusElement = document.getElementById('status');
|
|
const chartStatus = document.getElementById('chartStatus');
|
|
const classSuffix = state === 'connected' ? 'connected' : 'disconnected';
|
|
|
|
statusElement.className = `trend-connection trend-connection--${classSuffix}`;
|
|
statusElement.textContent = message;
|
|
|
|
if (state === 'connected') {
|
|
chartStatus.className = 'trend-status trend-status--running';
|
|
chartStatus.textContent = 'READY';
|
|
} else if (state === 'loading') {
|
|
chartStatus.className = 'trend-status trend-status--stopped';
|
|
chartStatus.textContent = 'LOADING';
|
|
} else if (state === 'error') {
|
|
chartStatus.className = 'trend-status trend-status--stopped';
|
|
chartStatus.textContent = 'ERROR';
|
|
} else {
|
|
chartStatus.className = 'trend-status trend-status--stopped';
|
|
chartStatus.textContent = 'IDLE';
|
|
}
|
|
}
|
|
|
|
function loadTags() {
|
|
const selects = getTagSelects();
|
|
const placeholders = [
|
|
'Select primary tag...',
|
|
'Optional tag 2...',
|
|
'Optional tag 3...',
|
|
'Optional tag 4...',
|
|
'Optional tag 5...'
|
|
];
|
|
|
|
selects.forEach((select) => {
|
|
select.innerHTML = '<option value="">Loading tags...</option>';
|
|
select.disabled = true;
|
|
});
|
|
|
|
fetch('../live/get_tags.php', {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json'
|
|
},
|
|
cache: 'no-cache'
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`Tag request failed: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((payload) => {
|
|
if (!payload.success || !Array.isArray(payload.tags) || payload.tags.length === 0) {
|
|
throw new Error(payload.error || 'No tags available');
|
|
}
|
|
|
|
selects.forEach((select, index) => {
|
|
const placeholder = placeholders[index] || 'Optional tag...';
|
|
select.innerHTML = `<option value="">${placeholder}</option>`;
|
|
payload.tags.forEach((tag) => {
|
|
if (!tag || !tag.name) {
|
|
return;
|
|
}
|
|
const option = document.createElement('option');
|
|
option.value = tag.name;
|
|
option.textContent = tag.name;
|
|
select.appendChild(option);
|
|
});
|
|
select.disabled = false;
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to load tags:', error);
|
|
selects.forEach((select) => {
|
|
select.innerHTML = `<option value="">Error: ${error.message}</option>`;
|
|
select.disabled = true;
|
|
});
|
|
setStatus('error', 'Unable to load tags. Refresh and try again.');
|
|
});
|
|
}
|
|
|
|
function selectedTags() {
|
|
return getTagSelects()
|
|
.map((select) => select.value.trim())
|
|
.filter((value, index, array) => value !== '' && array.indexOf(value) === index);
|
|
}
|
|
|
|
function handleLoad() {
|
|
const tags = selectedTags();
|
|
if (tags.length === 0) {
|
|
setStatus('error', 'Choose at least one tag to build the chart.');
|
|
return;
|
|
}
|
|
|
|
setStatus('loading', 'Loading historian data...');
|
|
document.getElementById('loadBtn').disabled = true;
|
|
document.getElementById('resetZoomBtn').disabled = true;
|
|
|
|
fetch('get_trend_data.php', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({ tags })
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`Trend request failed: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((payload) => {
|
|
if (!payload.success || !Array.isArray(payload.series) || payload.series.length === 0) {
|
|
throw new Error(payload.error || 'No data returned for the selected tags.');
|
|
}
|
|
|
|
lastWindow = payload.window || null;
|
|
renderChart(payload.series);
|
|
renderStats(payload.series);
|
|
updateTitle(payload.series);
|
|
setStatus('connected', buildStatusMessage(payload));
|
|
document.getElementById('resetZoomBtn').disabled = false;
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to load trend data:', error);
|
|
clearChart();
|
|
setStatus('error', error.message);
|
|
})
|
|
.finally(() => {
|
|
document.getElementById('loadBtn').disabled = false;
|
|
});
|
|
}
|
|
|
|
function renderChart(series) {
|
|
const ctx = document.getElementById('staticChart').getContext('2d');
|
|
|
|
if (chart) {
|
|
chart.destroy();
|
|
}
|
|
|
|
const datasets = series.map((entry, index) => {
|
|
const style = DATASET_STYLES[index % DATASET_STYLES.length];
|
|
return {
|
|
label: entry.tag,
|
|
data: entry.points.map((point) => ({ x: point.timestamp, y: point.value })),
|
|
borderColor: style.border,
|
|
backgroundColor: style.background,
|
|
borderWidth: 2,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 3,
|
|
pointHitRadius: 6,
|
|
fill: false,
|
|
tension: 0.1
|
|
};
|
|
});
|
|
|
|
const timeBounds = getTimeBounds(series);
|
|
|
|
chart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: { datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
animation: { duration: 200 },
|
|
scales: {
|
|
x: {
|
|
type: 'time',
|
|
adapters: {
|
|
date: {
|
|
zone: 'local'
|
|
}
|
|
},
|
|
time: {
|
|
unit: 'minute',
|
|
displayFormats: {
|
|
minute: 'HH:mm',
|
|
hour: 'HH:mm',
|
|
second: 'HH:mm:ss'
|
|
}
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: 'Timestamp'
|
|
},
|
|
min: timeBounds.min,
|
|
max: timeBounds.max
|
|
},
|
|
y: {
|
|
type: 'linear',
|
|
title: {
|
|
display: true,
|
|
text: 'Value'
|
|
},
|
|
ticks: {
|
|
callback: (value) => Number(value).toFixed(2)
|
|
}
|
|
}
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top'
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label(context) {
|
|
if (context.parsed.y === null || Number.isNaN(context.parsed.y)) {
|
|
return `${context.dataset.label}: --`;
|
|
}
|
|
return `${context.dataset.label}: ${Number(context.parsed.y).toFixed(2)}`;
|
|
}
|
|
}
|
|
},
|
|
zoom: {
|
|
limits: {
|
|
x: { min: timeBounds.min, max: timeBounds.max }
|
|
},
|
|
pan: {
|
|
enabled: true,
|
|
modifierKey: 'ctrl'
|
|
},
|
|
zoom: {
|
|
wheel: { enabled: true },
|
|
pinch: { enabled: true },
|
|
drag: { enabled: true },
|
|
mode: 'x'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function getTimeBounds(series) {
|
|
let min = null;
|
|
let max = null;
|
|
series.forEach((entry) => {
|
|
entry.points.forEach((point) => {
|
|
const ts = new Date(point.timestamp).getTime();
|
|
if (!Number.isFinite(ts)) {
|
|
return;
|
|
}
|
|
if (min === null || ts < min) {
|
|
min = ts;
|
|
}
|
|
if (max === null || ts > max) {
|
|
max = ts;
|
|
}
|
|
});
|
|
});
|
|
if (min === null || max === null) {
|
|
const now = Date.now();
|
|
return { min: now - (3 * 60 * 60 * 1000), max: now };
|
|
}
|
|
return { min, max };
|
|
}
|
|
|
|
function renderStats(series) {
|
|
const statsWrapper = document.getElementById('statsWrapper');
|
|
statsWrapper.innerHTML = '';
|
|
|
|
const fragments = document.createDocumentFragment();
|
|
|
|
series.forEach((entry, index) => {
|
|
const styleClass = `trend-stat--series${(index % DATASET_STYLES.length) + 1}`;
|
|
const statCard = document.createElement('div');
|
|
statCard.className = `trend-stat ${styleClass}`;
|
|
statCard.innerHTML = `
|
|
<span class="trend-stat__value">${formatStat(entry.stats?.latest)}</span>
|
|
<span class="trend-stat__label">${entry.tag} latest</span>
|
|
`;
|
|
fragments.appendChild(statCard);
|
|
|
|
const minCard = document.createElement('div');
|
|
minCard.className = `trend-stat ${styleClass}`;
|
|
minCard.innerHTML = `
|
|
<span class="trend-stat__value">${formatStat(entry.stats?.min)}</span>
|
|
<span class="trend-stat__label">${entry.tag} min</span>
|
|
`;
|
|
fragments.appendChild(minCard);
|
|
|
|
const maxCard = document.createElement('div');
|
|
maxCard.className = `trend-stat ${styleClass}`;
|
|
maxCard.innerHTML = `
|
|
<span class="trend-stat__value">${formatStat(entry.stats?.max)}</span>
|
|
<span class="trend-stat__label">${entry.tag} max</span>
|
|
`;
|
|
fragments.appendChild(maxCard);
|
|
|
|
const countCard = document.createElement('div');
|
|
countCard.className = 'trend-stat';
|
|
countCard.innerHTML = `
|
|
<span class="trend-stat__value">${entry.stats?.count ?? 0}</span>
|
|
<span class="trend-stat__label">${entry.tag} points</span>
|
|
`;
|
|
fragments.appendChild(countCard);
|
|
});
|
|
|
|
if (lastWindow) {
|
|
const windowCard = document.createElement('div');
|
|
windowCard.className = 'trend-stat';
|
|
windowCard.innerHTML = `
|
|
<span class="trend-stat__value">${formatWindow(lastWindow)}</span>
|
|
<span class="trend-stat__label">Snapshot window</span>
|
|
`;
|
|
fragments.appendChild(windowCard);
|
|
}
|
|
|
|
statsWrapper.appendChild(fragments);
|
|
statsWrapper.hidden = false;
|
|
}
|
|
|
|
function formatStat(value) {
|
|
if (value === null || value === undefined) {
|
|
return '--';
|
|
}
|
|
return Number(value).toFixed(2);
|
|
}
|
|
|
|
function formatWindow(window) {
|
|
if (!window || !window.start || !window.end) {
|
|
return 'n/a';
|
|
}
|
|
const start = moment(window.start).format('YYYY-MM-DD HH:mm');
|
|
const end = moment(window.end).format('YYYY-MM-DD HH:mm');
|
|
return `${start} → ${end}`;
|
|
}
|
|
|
|
function updateTitle(series) {
|
|
const title = document.getElementById('chartTitle');
|
|
if (!series || series.length === 0) {
|
|
title.textContent = 'Static multi-tag snapshot';
|
|
return;
|
|
}
|
|
title.textContent = series.map((entry) => entry.tag).join(', ');
|
|
}
|
|
|
|
function buildStatusMessage(payload) {
|
|
const parts = [];
|
|
if (payload.series) {
|
|
const total = payload.series.reduce((sum, entry) => sum + (entry.stats?.count ?? 0), 0);
|
|
parts.push(`${total} point${total === 1 ? '' : 's'} loaded`);
|
|
}
|
|
if (payload.window && payload.window.start && payload.window.end) {
|
|
parts.push(`Window ${moment(payload.window.start).format('HH:mm')} - ${moment(payload.window.end).format('HH:mm')}`);
|
|
}
|
|
return parts.join(' · ') || 'Snapshot ready';
|
|
}
|
|
|
|
function resetZoom() {
|
|
if (chart) {
|
|
chart.resetZoom();
|
|
}
|
|
}
|
|
|
|
function clearChart() {
|
|
if (chart) {
|
|
chart.destroy();
|
|
chart = null;
|
|
}
|
|
document.getElementById('statsWrapper').hidden = true;
|
|
document.getElementById('resetZoomBtn').disabled = true;
|
|
updateTitle([]);
|
|
setStatus('idle', 'Chart cleared. Select tags and load a new snapshot.');
|
|
}
|
|
</script>
|
|
|
|
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|