Files
2026-02-17 09:29:34 -06:00

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'; ?>