Folder reorganize 1
This commit is contained in:
615
trends/live/singlechart.php
Normal file
615
trends/live/singlechart.php
Normal file
@@ -0,0 +1,615 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Single Chart Trending';
|
||||
$pageSubtitle = 'Monitor one or two tags with dual axes';
|
||||
$pageDescription = 'Launch the single chart beta to track critical process data in real time.';
|
||||
$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">Real-time process trend</h2>
|
||||
<p>Select a primary tag and optional secondary tag to start streaming live data.</p>
|
||||
</div>
|
||||
<span class="trend-status trend-status--stopped" id="chartStatus">STOPPED</span>
|
||||
</header>
|
||||
|
||||
<div class="trend-control-grid">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect1">Primary tag</label>
|
||||
<select id="tagSelect1">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect2">Secondary tag</label>
|
||||
<select id="tagSelect2">
|
||||
<option value="">Optional secondary...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="updateInterval">Update interval</label>
|
||||
<select id="updateInterval">
|
||||
<option value="1000">Every 1 second</option>
|
||||
<option value="5000" selected>Every 5 seconds</option>
|
||||
<option value="10000">Every 10 seconds</option>
|
||||
<option value="30000">Every 30 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label>Time window</label>
|
||||
<div class="time-window-display">Last 15 minutes (sliding)</div>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="startBtn">Controls</label>
|
||||
<div class="trend-control__actions">
|
||||
<button type="button" class="button button--success" id="startBtn" onclick="startRealTime()">Start</button>
|
||||
<button type="button" class="button button--danger" id="stopBtn" onclick="stopRealTime()" disabled>Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="trend-connection trend-connection--disconnected">
|
||||
Disconnected — Select a primary tag and click Start to begin trending
|
||||
</div>
|
||||
|
||||
<div class="trend-stat-grid">
|
||||
<div class="trend-stat">
|
||||
<span id="currentValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="tag1Label">Primary current</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="currentValue2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label" id="tag2Label">Secondary current</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="minValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Primary min</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="maxValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Primary max</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="minValue2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Secondary min</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="maxValue2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Secondary max</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="dataPoints" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Total points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="lastUpdate" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Last update</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart">
|
||||
<canvas id="realtimeChart"></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/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>
|
||||
let chart = null;
|
||||
let updateTimer = null;
|
||||
let isRunning = false;
|
||||
let lastTimestamp1 = null;
|
||||
let lastTimestamp2 = null;
|
||||
let chartData1 = [];
|
||||
let chartData2 = [];
|
||||
let timeWindowMinutes = 15;
|
||||
|
||||
// Initialize Chart.js with dual datasets
|
||||
function initChart() {
|
||||
const ctx = document.getElementById('realtimeChart').getContext('2d');
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: 'Primary Tag',
|
||||
data: [],
|
||||
borderColor: '#3498db',
|
||||
backgroundColor: 'rgba(52, 152, 219, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y1'
|
||||
}, {
|
||||
label: 'Secondary Tag',
|
||||
data: [],
|
||||
borderColor: '#27ae60',
|
||||
backgroundColor: 'rgba(39, 174, 96, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y2',
|
||||
hidden: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 300
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
second: 'HH:mm:ss',
|
||||
minute: 'HH:mm',
|
||||
hour: 'HH:mm'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time (Last 15 Minutes)',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
min: function() {
|
||||
return moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
},
|
||||
max: function() {
|
||||
return moment().toDate();
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Primary Tag Value',
|
||||
color: '#3498db',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: true,
|
||||
color: 'rgba(52, 152, 219, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#3498db',
|
||||
font: { weight: 'bold' }
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Secondary Tag Value',
|
||||
color: '#27ae60',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: '#27ae60',
|
||||
font: { weight: 'bold' }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
font: { size: 12, weight: 'bold' },
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
},
|
||||
onClick: function(e, legendItem, legend) {
|
||||
const index = legendItem.datasetIndex;
|
||||
const ci = legend.chart;
|
||||
const dataset = ci.data.datasets[index];
|
||||
|
||||
dataset.hidden = !dataset.hidden;
|
||||
|
||||
if (index === 1) {
|
||||
ci.options.scales.y2.display = !dataset.hidden;
|
||||
}
|
||||
|
||||
ci.update();
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(44, 62, 80, 0.9)',
|
||||
titleColor: '#3498db',
|
||||
bodyColor: '#ecf0f1',
|
||||
borderColor: '#3498db',
|
||||
borderWidth: 1,
|
||||
titleFont: { size: 13, weight: 'bold' },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return moment(context[0].parsed.x).format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
afterTitle: function(context) {
|
||||
const now = moment();
|
||||
const pointTime = moment(context[0].parsed.x);
|
||||
const secondsAgo = now.diff(pointTime, 'seconds');
|
||||
return `(${secondsAgo} seconds ago)`;
|
||||
},
|
||||
label: function(context) {
|
||||
const datasetLabel = context.dataset.label;
|
||||
const value = context.parsed.y.toFixed(2);
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}); // This closing brace and parenthesis were missing!
|
||||
}
|
||||
|
||||
// Load available tags for both dropdowns
|
||||
async function loadTags() {
|
||||
const select1 = document.getElementById('tagSelect1');
|
||||
const select2 = document.getElementById('tagSelect2');
|
||||
|
||||
if (!select1 || !select2) {
|
||||
console.error('Tag select elements not found in DOM.');
|
||||
return;
|
||||
}
|
||||
|
||||
const setMessage = (message) => {
|
||||
select1.innerHTML = `<option value="">${message}</option>`;
|
||||
select2.innerHTML = `<option value="">${message}</option>`;
|
||||
};
|
||||
|
||||
setMessage('Loading tags...');
|
||||
|
||||
try {
|
||||
const response = await fetch('get_tags.php', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
select1.innerHTML = '<option value="">Select primary tag...</option>';
|
||||
select2.innerHTML = '<option value="">Select secondary tag...</option>';
|
||||
|
||||
if (data.success && Array.isArray(data.tags) && data.tags.length > 0) {
|
||||
data.tags.forEach((tag) => {
|
||||
if (!tag || !tag.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option1 = document.createElement('option');
|
||||
option1.value = tag.name;
|
||||
option1.textContent = tag.name;
|
||||
select1.appendChild(option1);
|
||||
|
||||
const option2 = document.createElement('option');
|
||||
option2.value = tag.name;
|
||||
option2.textContent = tag.name;
|
||||
select2.appendChild(option2);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = data.error || 'No tags available';
|
||||
setMessage(errorMessage);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
setMessage(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data for one or both tags
|
||||
async function fetchTagData(tagName, datasetIndex) {
|
||||
if (!tagName) return null;
|
||||
|
||||
try {
|
||||
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
const startTime = moment().subtract(timeWindowMinutes, 'minutes').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
let url = `realtime_data.php?tag=${encodeURIComponent(tagName)}&limit=1500&start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
const formattedData = data.data.map(point => ({
|
||||
x: new Date(point.x),
|
||||
y: point.y
|
||||
}));
|
||||
|
||||
const cutoffTime = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
return formattedData.filter(point => point.x >= cutoffTime);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${tagName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Modified fetchData for dual tags
|
||||
async function fetchData() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
|
||||
if (!tagName1) return;
|
||||
|
||||
try {
|
||||
const data1 = await fetchTagData(tagName1, 0);
|
||||
|
||||
if (data1 !== null) {
|
||||
chartData1 = data1;
|
||||
chart.data.datasets[0].data = chartData1;
|
||||
chart.data.datasets[0].label = tagName1;
|
||||
}
|
||||
|
||||
if (tagName2 && tagName2 !== tagName1) {
|
||||
const data2 = await fetchTagData(tagName2, 1);
|
||||
|
||||
if (data2 !== null) {
|
||||
chartData2 = data2;
|
||||
chart.data.datasets[1].data = chartData2;
|
||||
chart.data.datasets[1].label = tagName2;
|
||||
chart.data.datasets[1].hidden = false;
|
||||
chart.options.scales.y2.display = true;
|
||||
}
|
||||
} else {
|
||||
chartData2 = [];
|
||||
chart.data.datasets[1].data = [];
|
||||
chart.data.datasets[1].hidden = true;
|
||||
chart.options.scales.y2.display = false;
|
||||
}
|
||||
|
||||
chart.options.scales.x.min = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
chart.options.scales.x.max = moment().toDate();
|
||||
|
||||
chart.update('active');
|
||||
|
||||
updateStatus('connected', `Connected - Last update: ${new Date().toLocaleTimeString()}`);
|
||||
updateStatistics();
|
||||
updateChartTitle();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
updateStatus('disconnected', `Connection error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update chart title
|
||||
function updateChartTitle() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
|
||||
let title = 'Real-Time Process Trend';
|
||||
if (tagName1) {
|
||||
title = tagName1;
|
||||
if (tagName2) {
|
||||
title += ` & ${tagName2}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chartTitle').textContent = title;
|
||||
}
|
||||
|
||||
// Enhanced statistics for dual tags
|
||||
function updateStatistics() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
|
||||
document.getElementById('tag1Label').textContent = tagName1 ? `${tagName1} Current` : 'Primary Current';
|
||||
document.getElementById('tag2Label').textContent = tagName2 ? `${tagName2} Current` : 'Secondary Current';
|
||||
|
||||
// Primary tag statistics
|
||||
if (chartData1.length > 0) {
|
||||
const values1 = chartData1.map(point => point.y);
|
||||
const current1 = values1[values1.length - 1];
|
||||
const min1 = Math.min(...values1);
|
||||
const max1 = Math.max(...values1);
|
||||
|
||||
document.getElementById('currentValue1').textContent = current1.toFixed(2);
|
||||
document.getElementById('minValue1').textContent = min1.toFixed(2);
|
||||
document.getElementById('maxValue1').textContent = max1.toFixed(2);
|
||||
} else {
|
||||
document.getElementById('currentValue1').textContent = '--';
|
||||
document.getElementById('minValue1').textContent = '--';
|
||||
document.getElementById('maxValue1').textContent = '--';
|
||||
}
|
||||
|
||||
// Secondary tag statistics
|
||||
if (chartData2.length > 0) {
|
||||
const values2 = chartData2.map(point => point.y);
|
||||
const current2 = values2[values2.length - 1];
|
||||
const min2 = Math.min(...values2);
|
||||
const max2 = Math.max(...values2);
|
||||
|
||||
document.getElementById('currentValue2').textContent = current2.toFixed(2);
|
||||
document.getElementById('minValue2').textContent = min2.toFixed(2);
|
||||
document.getElementById('maxValue2').textContent = max2.toFixed(2);
|
||||
} else {
|
||||
document.getElementById('currentValue2').textContent = '--';
|
||||
document.getElementById('minValue2').textContent = '--';
|
||||
document.getElementById('maxValue2').textContent = '--';
|
||||
}
|
||||
|
||||
const totalPoints = chartData1.length + chartData2.length;
|
||||
document.getElementById('dataPoints').textContent = totalPoints;
|
||||
document.getElementById('lastUpdate').textContent = moment().format('HH:mm:ss');
|
||||
}
|
||||
|
||||
// Update status display
|
||||
function updateStatus(type, message) {
|
||||
const status = document.getElementById('status');
|
||||
const chartStatus = document.getElementById('chartStatus');
|
||||
const stateClass = type === 'connected' ? 'connected' : 'disconnected';
|
||||
|
||||
status.className = `trend-connection trend-connection--${stateClass}`;
|
||||
status.textContent = message;
|
||||
|
||||
if (stateClass === 'connected') {
|
||||
chartStatus.className = 'trend-status trend-status--running';
|
||||
chartStatus.textContent = 'RUNNING';
|
||||
} else {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'STOPPED';
|
||||
}
|
||||
}
|
||||
|
||||
// Start real-time updates
|
||||
function startRealTime() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
if (!tagName1) {
|
||||
alert('Please select a primary tag first');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
lastTimestamp1 = null;
|
||||
lastTimestamp2 = null;
|
||||
|
||||
const interval = parseInt(document.getElementById('updateInterval').value);
|
||||
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = false;
|
||||
document.getElementById('tagSelect1').disabled = true;
|
||||
document.getElementById('tagSelect2').disabled = true;
|
||||
|
||||
fetchData();
|
||||
|
||||
updateTimer = setInterval(() => {
|
||||
fetchData();
|
||||
}, interval);
|
||||
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
const statusMessage = tagName2 ?
|
||||
`Trending ${tagName1} & ${tagName2} - Update every ${interval/1000}s` :
|
||||
`Trending ${tagName1} - Update every ${interval/1000}s`;
|
||||
|
||||
updateStatus('connected', statusMessage);
|
||||
}
|
||||
|
||||
// Stop real-time updates
|
||||
function stopRealTime() {
|
||||
isRunning = false;
|
||||
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
|
||||
document.getElementById('startBtn').disabled = false;
|
||||
document.getElementById('stopBtn').disabled = true;
|
||||
document.getElementById('tagSelect1').disabled = false;
|
||||
document.getElementById('tagSelect2').disabled = false;
|
||||
|
||||
updateStatus('disconnected', 'Real-time updates stopped - Select tags and click Start to resume');
|
||||
}
|
||||
|
||||
// Clear chart data
|
||||
function clearChart() {
|
||||
chartData1 = [];
|
||||
chartData2 = [];
|
||||
lastTimestamp1 = null;
|
||||
lastTimestamp2 = null;
|
||||
|
||||
if (chart) {
|
||||
chart.data.datasets[0].data = [];
|
||||
chart.data.datasets[1].data = [];
|
||||
chart.data.datasets[1].hidden = true;
|
||||
chart.options.scales.y2.display = false;
|
||||
chart.update();
|
||||
}
|
||||
|
||||
// Reset all statistics
|
||||
document.getElementById('currentValue1').textContent = '--';
|
||||
document.getElementById('currentValue2').textContent = '--';
|
||||
document.getElementById('minValue1').textContent = '--';
|
||||
document.getElementById('maxValue1').textContent = '--';
|
||||
document.getElementById('minValue2').textContent = '--';
|
||||
document.getElementById('maxValue2').textContent = '--';
|
||||
document.getElementById('dataPoints').textContent = '0';
|
||||
document.getElementById('lastUpdate').textContent = '--';
|
||||
|
||||
// Reset title
|
||||
document.getElementById('chartTitle').textContent = 'Real-Time Process Trend';
|
||||
}
|
||||
|
||||
// Handle interval changes
|
||||
document.getElementById('updateInterval').addEventListener('change', function() {
|
||||
if (isRunning) {
|
||||
stopRealTime();
|
||||
setTimeout(startRealTime, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle secondary tag changes during runtime
|
||||
document.getElementById('tagSelect2').addEventListener('change', function() {
|
||||
if (isRunning) {
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initChart();
|
||||
|
||||
// Add a small delay to ensure DOM is fully ready
|
||||
setTimeout(loadTags, 100);
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
Reference in New Issue
Block a user