';
// Loop through data to print each button
if (!empty(data.buttons)) {
diff --git a/www/public/resources/js/classes/Container.js b/www/public/resources/js/classes/Container.js
index 6707f3e2..40c664e0 100644
--- a/www/public/resources/js/classes/Container.js
+++ b/www/public/resources/js/classes/Container.js
@@ -55,23 +55,22 @@ class Container {
morphdom($('.reloadable-container[container="' + container + '"]')[0], jsonValue.message, {
// Avoid some elements to be updated
onBeforeElUpdated: function (fromEl, toEl) {
- // Case the element is a video and it is currently playing, do not update it
- if (fromEl.tagName === 'VIDEO' && !fromEl.paused) {
- return false;
+ // Check container-specific rules first
+ if (typeof morphdomSkipRules !== 'undefined' && morphdomSkipRules[container]) {
+ if (this._shouldSkipElement(fromEl, toEl, morphdomSkipRules[container])) {
+ return false;
+ }
}
-
- // Case the element is a checkbox and it is currently checked, do not update it
- if (fromEl.tagName === 'INPUT' && fromEl.type === 'checkbox' && fromEl.checked) {
- return false;
- }
-
- // Case the element is a canvas (e.g. ChartJS), do not update it
- if (fromEl.tagName === 'CANVAS') {
- return false;
+
+ // Check default rules
+ if (typeof defaultMorphdomSkipRules !== 'undefined') {
+ if (this._shouldSkipElement(fromEl, toEl, defaultMorphdomSkipRules)) {
+ return false;
+ }
}
-
+
return true;
- }
+ }.bind(this)
});
} else {
// If an identifier is provided, reload only that specific container
@@ -89,6 +88,11 @@ class Container {
}
}
+ // Execute post reload function if exists
+ if (typeof postReloadFunctions !== 'undefined' && typeof postReloadFunctions[container] === 'function') {
+ postReloadFunctions[container]();
+ }
+
// Reload opened or closed elements that were opened/closed before reloading
mylayout.reloadOpenedClosedElements();
@@ -104,4 +108,61 @@ class Container {
}
});
}
+
+ /**
+ * Check if an element should be skipped based on declarative rules
+ * @param {Element} fromEl
+ * @param {Element} toEl
+ * @param {Array} rules
+ * @returns {boolean}
+ */
+ _shouldSkipElement(fromEl, toEl, rules) {
+ for (const rule of rules) {
+ // Check if element matches the rule selector
+ if (this._elementMatches(fromEl, rule.element)) {
+ switch (rule.skipIf) {
+ case 'playing':
+ if (!fromEl.paused) {
+ return true;
+ }
+ break;
+ case 'sameAttribute':
+ if (rule.attribute && fromEl.getAttribute(rule.attribute) === toEl.getAttribute(rule.attribute)) {
+ return true;
+ }
+ break;
+ case 'checked':
+ if (fromEl.checked) {
+ return true;
+ }
+ break;
+ case 'always':
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check if element matches a selector (simple implementation)
+ * @param {Element} element
+ * @param {string} selector
+ * @returns {boolean}
+ */
+ _elementMatches(element, selector) {
+ // Handle simple cases like 'VIDEO', 'CANVAS', 'INPUT[type="checkbox"]'
+ if (selector === element.tagName) {
+ return true;
+ }
+
+ // Handle attribute selectors like 'INPUT[type="checkbox"]'
+ const match = selector.match(/^(\w+)\[([^=]+)="([^"]+)"\]$/);
+ if (match) {
+ const [, tagName, attr, value] = match;
+ return element.tagName === tagName && element.getAttribute(attr) === value;
+ }
+
+ return false;
+ }
}
diff --git a/www/public/resources/js/classes/Cookie.js b/www/public/resources/js/classes/Cookie.js
index cc4310b4..b5469376 100644
--- a/www/public/resources/js/classes/Cookie.js
+++ b/www/public/resources/js/classes/Cookie.js
@@ -53,4 +53,13 @@ class Cookie {
return true;
}
+
+ /**
+ * Delete cookie by name
+ * @param {*} cname
+ */
+ delete(cname)
+ {
+ this.set(cname, "", -1);
+ }
}
diff --git a/www/public/resources/js/classes/EChart.js b/www/public/resources/js/classes/EChart.js
index 18153dd4..8f9a871b 100644
--- a/www/public/resources/js/classes/EChart.js
+++ b/www/public/resources/js/classes/EChart.js
@@ -12,13 +12,17 @@ class EChart
static instances = {};
// If on mobile, default to 1 day range, otherwise 3 days
- constructor(type, id, autoUpdate = true, autoUpdateInterval = 15000, days = window.innerWidth < 600 ? 1 : 3)
+ constructor(type, id, autoUpdate = true, autoUpdateInterval = 15000, days = 1, wasInNaturalState = true, periodChanged = false, preservedCurrentType = null)
{
this.id = id;
this.type = type;
+ this.currentType = preservedCurrentType || type; // Current type (can change with magicType)
+ this._preservedColors = null; // Store colors when switching types
this.autoUpdate = autoUpdate;
this.autoUpdateInterval = autoUpdateInterval;
this.days = days;
+ this._wasInNaturalState = wasInNaturalState;
+ this._periodChanged = periodChanged;
this.datasets = [];
this.labels = [];
this.chartOptions = [];
@@ -36,12 +40,22 @@ class EChart
}
},
tooltip: {
- trigger: 'axis',
+ show: true,
+ trigger: 'item',
+ confine: true, // Force tooltip to stay within container boundaries
backgroundColor: 'rgba(0, 0, 0, 0.8)',
textStyle: {
color: '#fff'
},
formatter: (params) => {
+ if (!params) return '';
+
+ // For pie charts
+ if (params.seriesType === 'pie') {
+ return `${params.marker} ${params.name}: ${params.value} (${params.percent}%)`;
+ }
+
+ // For line charts (existing code)
if (params.length === 0) return '';
const timestamp = params[0].axisValue;
@@ -108,10 +122,10 @@ class EChart
saveAsImage: {
show: true,
},
- // magicType: {
- // show: true,
- // type: ['line', 'bar']
- // }
+ magicType: {
+ show: false, // Disabled by default, enabled conditionally
+ type: ['line', 'bar']
+ }
},
iconStyle: {
borderColor: '#8A99AA',
@@ -170,14 +184,14 @@ class EChart
show: false,
start: 80,
end: 100,
- height: 40,
- bottom: 30,
+ height: 32,
+ bottom: 0,
showPlayBtn: false,
textStyle: {
color: '#8A99AA'
},
- borderColor: '#8A99AA',
- borderRadius: 6,
+ borderColor: '#8a99aa54',
+ borderRadius: 4,
brushSelect: false
}
],
@@ -190,10 +204,8 @@ class EChart
// Register this instance in the static registry
EChart.instances[this.id] = this;
- // Call the appropriate chart creation method based on the type
- if (typeof this[type] === 'function') {
- this[type](id);
- }
+ // Create or update the chart
+ this.createOrUpdateChart(id);
// Start auto-update
this.startAutoUpdate();
@@ -290,41 +302,49 @@ class EChart
get(id)
{
return new Promise((resolve, reject) => {
- try {
- ajaxRequest(
- // Controller:
- 'chart',
- // Action:
- 'get',
- // Data:
- {
- id: id,
- days: this.days,
- sourceGetParameters: getGetParams()
- },
- // Print success alert:
- false,
- // Print error alert:
- true
- ).then(() => {
- // Parse the response and store it in the class properties
- this.datasets = jsonValue.message.datasets;
- this.labels = jsonValue.message.labels;
- this.chartOptions = jsonValue.message.options;
-
- // For debugging purposes only
- // console.log("datasets: " + JSON.stringify(this.datasets));
- // console.log("labels: " + JSON.stringify(this.labels));
- // console.log("options: " + JSON.stringify(this.chartOptions));
-
- // Resolve promise
- resolve('Chart data retrieved');
- });
+ ajaxRequest(
+ // Controller:
+ 'chart',
+ // Action:
+ 'get',
+ // Data:
+ {
+ id: id,
+ days: this.days,
+ sourceGetParameters: getGetParams()
+ },
+ // Print success alert:
+ false,
+ // Print error alert:
+ true
+ ).then(() => {
+ // Parse the response and store it in the class properties
+ this.datasets = jsonValue.message.datasets;
+ this.labels = jsonValue.message.labels;
+ this.chartOptions = jsonValue.message.options;
+
+ // For debugging purposes only
+ // console.log("datasets: " + JSON.stringify(this.datasets));
+ // console.log("labels: " + JSON.stringify(this.labels));
+ // console.log("options: " + JSON.stringify(this.chartOptions));
+
+ // Resolve promise
+ resolve('Chart data retrieved');
+ }).catch(error => {
+ // Stop auto-update to prevent further errors
+ this.stopAutoUpdate();
+
+ // Remove loading spinner
+ $('#' + id + '-loading').remove();
+
+ // Replace chart with error message
+ $('#' + id).html('
Failed to get chart data: ' + error.toLowerCase() + '
');
- } catch (error) {
// Reject promise
reject('Failed to get chart data: ' + error);
- }
+
+ return;
+ });
});
}
@@ -333,33 +353,494 @@ class EChart
*/
buildSeries()
{
- return this.datasets.map(dataset => {
- const data = dataset.data.map((v, i) => [this.labels[i], v]);
- return {
- name: dataset.name,
- type: 'line',
- color: dataset.color,
- smooth: true,
- symbol: 'none',
- lineStyle: {
- width: 2,
- color: dataset.color
- },
- areaStyle: {
- opacity: 0.25,
- color: dataset.color
- },
- data: data
- };
- });
+ if (this.currentType === 'line') {
+ return this.datasets.map((dataset, datasetIndex) => {
+ const data = dataset.data.map((v, i) => [this.labels[i], v]);
+ // Use preserved color first, then dataset color, then default
+ const lineColor = (this._preservedColors && this._preservedColors[datasetIndex]) ||
+ dataset.color || '#15bf7f';
+
+ // Determine if we have large dataset
+ const isLargeDataset = this.days > 3; // Consider large if showing more than 3 days of data (configurable threshold)
+
+ return {
+ name: dataset.name,
+ type: 'line',
+ color: lineColor,
+ smooth: true,
+ symbol: 'none',
+ sampling: isLargeDataset ? 'lttb' : undefined, // Use LTTB sampling for large datasets
+ lineStyle: {
+ width: 2,
+ color: lineColor
+ },
+ areaStyle: {
+ opacity: 0.25,
+ color: lineColor
+ },
+ data: data
+ };
+ });
+ }
+
+ if (this.currentType === 'bar') {
+ return this.datasets.map((dataset, datasetIndex) => {
+ const data = dataset.data.map((v, i) => {
+ const item = [this.labels[i], v];
+
+ // If we have individual colors defined, apply them
+ if (dataset.colors && dataset.colors[i]) {
+ // For bar charts, we need to return an object with itemStyle
+ return {
+ value: item,
+ itemStyle: {
+ borderRadius: [4, 4, 0, 0],
+ color: dataset.colors[i]
+ }
+ };
+ }
+
+ return item;
+ });
+
+ // Use preserved color first, then dataset color, then default
+ const barColor = (this._preservedColors && this._preservedColors[datasetIndex]) ||
+ dataset.color || '#15bf7f';
+
+ return {
+ name: dataset.name,
+ type: 'bar',
+ color: barColor, // Use preserved/dataset/default color
+ barMaxWidth: 30, // Maximum width of each bar in pixels
+ itemStyle: {
+ borderRadius: [4, 4, 0, 0],
+ color: barColor // Use same color
+ },
+ emphasis: {
+ itemStyle: {
+ opacity: 0.8
+ }
+ },
+ data: data
+ };
+ });
+ }
+
+ if (this.currentType === 'barHorizontal') {
+ return this.datasets.map(dataset => {
+ const data = dataset.data.map((v, i) => {
+ // For horizontal bars, just use the value (not [label, value])
+ // Labels are handled by yAxis.data
+ const item = v;
+
+ // If we have individual colors defined, apply them
+ if (dataset.colors && dataset.colors[i]) {
+ // For bar charts, we need to return an object with itemStyle
+ return {
+ value: item,
+ itemStyle: {
+ borderRadius: [0, 4, 4, 0], // Right rounded for horizontal
+ color: dataset.colors[i]
+ }
+ };
+ }
+
+ return item;
+ });
+
+ return {
+ name: dataset.name,
+ type: 'bar',
+ color: dataset.color, // Default color if no individual colors
+ barMaxWidth: 30, // Maximum height for horizontal bars
+ itemStyle: {
+ borderRadius: [0, 4, 4, 0], // Right rounded for horizontal
+ color: dataset.color // Default color
+ },
+ emphasis: {
+ itemStyle: {
+ opacity: 0.8
+ }
+ },
+ data: data
+ };
+ });
+ }
+
+ if (this.currentType === 'pie') {
+ return this.datasets.map(dataset => {
+ const data = dataset.data.map((v, i) => {
+ const item = {
+ name: this.labels[i],
+ value: v
+ };
+
+ // Add custom color if defined
+ if (dataset.colors && dataset.colors[i]) {
+ item.itemStyle = {
+ color: dataset.colors[i]
+ };
+ }
+
+ return item;
+ });
+
+ return {
+ name: dataset.name,
+ type: 'pie',
+ radius: ['0%', '70%'],
+ center: ['50%', '50%'],
+ itemStyle: {
+ borderRadius: 3
+ },
+ label: {
+ color: '#8A99AA'
+ },
+ data: data
+ };
+ });
+ }
+
+ if (this.currentType === 'nightingale') {
+ return this.datasets.map(dataset => {
+ const data = dataset.data.map((v, i) => {
+ const item = {
+ name: this.labels[i],
+ value: v
+ };
+
+ // Add custom color if defined
+ if (dataset.colors && dataset.colors[i]) {
+ item.itemStyle = {
+ color: dataset.colors[i]
+ };
+ }
+
+ return item;
+ });
+
+ // Get radius values from chartOptions if defined, otherwise use defaults
+ let innerRadius = '0%';
+ let outerRadius = '65%';
+
+ if (this.chartOptions) {
+ if (this.chartOptions.innerRadius !== undefined) {
+ innerRadius = this.chartOptions.innerRadius;
+ }
+ if (this.chartOptions.outerRadius !== undefined) {
+ outerRadius = this.chartOptions.outerRadius;
+ }
+ }
+
+ return {
+ name: dataset.name,
+ type: 'pie',
+ roseType: 'radius',
+ radius: [innerRadius, outerRadius], // Configurable radius values
+ center: ['50%', '50%'],
+ itemStyle: {
+ borderRadius: 2
+ },
+ label: {
+ color: '#8A99AA',
+ position: 'outside'
+ },
+ labelLine: {
+ show: true,
+ lineStyle: {
+ color: '#8A99AA'
+ }
+ },
+ data: data
+ };
+ });
+ }
+
+ if (this.type === 'doughnut') {
+ return this.datasets.map(dataset => {
+ const data = dataset.data.map((v, i) => {
+ const item = {
+ name: this.labels[i],
+ value: v
+ };
+
+ // Add custom color if defined
+ if (dataset.colors && dataset.colors[i]) {
+ item.itemStyle = {
+ color: dataset.colors[i]
+ };
+ }
+
+ return item;
+ });
+
+ // Get radius values from chartOptions if defined, otherwise use defaults
+ let innerRadius = '40%';
+ let outerRadius = '90%';
+
+ if (this.chartOptions) {
+ if (this.chartOptions.innerRadius !== undefined) {
+ innerRadius = this.chartOptions.innerRadius;
+ }
+ if (this.chartOptions.outerRadius !== undefined) {
+ outerRadius = this.chartOptions.outerRadius;
+ }
+ }
+
+ const seriesConfig = {
+ name: dataset.name,
+ type: 'pie',
+ radius: [innerRadius, outerRadius], // Configurable radius values
+ center: ['50%', '50%'],
+ avoidLabelOverlap: false,
+ itemStyle: {
+ borderRadius: 3
+ },
+ label: {
+ show: false,
+ position: 'center'
+ },
+ labelLine: {
+ show: false
+ },
+ data: data
+ };
+
+ // Handle emphasis (hover effects)
+ if (this.chartOptions.emphasis?.disabled === true) {
+ // Completely disable hover effects
+ seriesConfig.emphasis = {
+ disabled: true
+ };
+ } else {
+ // Default emphasis behavior
+ seriesConfig.emphasis = {
+ label: {
+ show: false
+ }
+ };
+ }
+
+ return seriesConfig;
+ });
+ }
+
+ if (this.currentType === 'points' || this.currentType === 'scatter') {
+ // Check if labels should be displayed instead of or with points
+ const showAsLabels = this.chartOptions?.showAsLabels || false;
+ const showBothLabelsAndPoints = this.chartOptions?.showBothLabelsAndPoints || false;
+
+ if (showAsLabels && !showBothLabelsAndPoints) {
+ // Using individual series approach for adaptive positioning
+
+ // Create a separate series for each point to control individual label positioning
+ const allSeries = [];
+
+ this.datasets.forEach((dataset, datasetIndex) => {
+ const pointColor = (this._preservedColors && this._preservedColors[datasetIndex]) || dataset.color || '#15bf7f';
+
+ dataset.data.forEach((value, pointIndex) => {
+ // Determine position for this specific point
+ let position = 'top';
+ if (pointIndex === 0) {
+ position = 'right';
+ } else if (pointIndex === dataset.data.length - 1) {
+ position = 'left'; // Last point: completely to the left
+ }
+
+ // Point positioning: first=insideTopRight, last=left, others=top
+ const seriesConfig = {
+ name: dataset.name,
+ type: 'scatter',
+ color: pointColor,
+ symbol: 'circle',
+ symbolSize: 8,
+ itemStyle: {
+ color: pointColor,
+ opacity: 1,
+ // borderColor: '#ffffff',
+ borderWidth: 1
+ },
+ emphasis: {
+ itemStyle: {
+ opacity: 1,
+ borderWidth: 2
+ }
+ },
+ data: [{
+ value: [this.labels[pointIndex], value],
+ snapshotId: dataset.snapshotIds ? dataset.snapshotIds[pointIndex] : null
+ }],
+ label: {
+ show: true,
+ position: position,
+ color: this.chartOptions?.labelColor || '#FFFFFF',
+ fontSize: this.chartOptions?.labelFontSize || 16,
+ fontWeight: this.chartOptions?.labelFontWeight || 'bold',
+ fontFamily: this.chartOptions?.labelFontFamily || 'Arial, sans-serif',
+ backgroundColor: this.chartOptions?.labelBackground || '#000000',
+ borderRadius: this.chartOptions?.labelBorderRadius || 8,
+ padding: this.chartOptions?.labelPadding || [10, 15],
+ formatter: (params) => {
+ const timestamp = params.value[0];
+ const date = new Date(Number(timestamp));
+
+ const dateFormat = this.chartOptions?.labelDateFormat || 'fr-FR';
+ const dateOptions = this.chartOptions?.labelDateOptions || {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric'
+ };
+
+ let formattedDate = date.toLocaleDateString(dateFormat, dateOptions);
+
+ // Remplacer le séparateur par celui configuré (défaut: tiret)
+ const separator = this.chartOptions?.labelDateSeparator || '-';
+ if (separator !== '/') {
+ formattedDate = formattedDate.replace(/\//g, separator);
+ }
+
+ return formattedDate;
+ }
+ },
+ // Hide from legend since we're creating multiple series for one dataset
+ legendHoverLink: false
+ };
+
+ // Add borders and shadows if enabled
+ if (this.chartOptions?.labelBorder !== false && this.chartOptions?.labelBorderWidth !== 0) {
+ seriesConfig.label.borderColor = this.chartOptions?.labelBorderColor || '#FFFFFF';
+ seriesConfig.label.borderWidth = this.chartOptions?.labelBorderWidth || 2;
+ }
+
+ if (this.chartOptions?.labelShadow !== false) {
+ seriesConfig.label.shadowColor = this.chartOptions?.labelShadowColor || 'rgba(0, 0, 0, 0.8)';
+ seriesConfig.label.shadowBlur = this.chartOptions?.labelShadowBlur || 5;
+ seriesConfig.label.shadowOffsetX = this.chartOptions?.labelShadowOffsetX || 2;
+ seriesConfig.label.shadowOffsetY = this.chartOptions?.labelShadowOffsetY || 2;
+ }
+
+ allSeries.push(seriesConfig);
+ });
+ });
+
+ return allSeries;
+ }
+
+ // Original logic for non-labels mode and labels+points mode
+ return this.datasets.map((dataset, datasetIndex) => {
+ const data = dataset.data.map((v, i) => ({
+ value: [this.labels[i], v],
+ snapshotId: dataset.snapshotIds ? dataset.snapshotIds[i] : null
+ }));
+ // Use preserved color first, then dataset color, then default
+ const pointColor = (this._preservedColors && this._preservedColors[datasetIndex]) || dataset.color || '#15bf7f';
+
+ // Get symbol size from chartOptions if defined, otherwise use default
+ const symbolSize = this.chartOptions?.symbolSize || 8;
+
+ // const labelFormat = this.chartOptions?.labelFormat || 'date'; // 'date', 'value', 'name', 'custom'
+
+ // Configure the series
+ const seriesConfig = {
+ name: dataset.name,
+ type: 'scatter',
+ color: pointColor,
+ symbol: 'circle',
+ data: data
+ };
+
+ // Configuration spécifique selon le mode d'affichage
+ if (showBothLabelsAndPoints) {
+ // Mode points + labels
+ seriesConfig.symbolSize = symbolSize;
+ seriesConfig.itemStyle = {
+ color: pointColor,
+ opacity: 1,
+ // borderColor: '#ffffff',
+ borderWidth: 1
+ };
+ seriesConfig.emphasis = {
+ itemStyle: {
+ opacity: 1,
+ borderWidth: 2
+ }
+ };
+
+ seriesConfig.label = {
+ show: true,
+ position: this.chartOptions?.labelPosition || 'top',
+ color: this.chartOptions?.labelColor || '#000000',
+ fontSize: this.chartOptions?.labelFontSize || 12,
+ fontWeight: this.chartOptions?.labelFontWeight || 'bold',
+ fontFamily: this.chartOptions?.labelFontFamily || 'Arial, sans-serif',
+ backgroundColor: this.chartOptions?.labelBackground || 'rgba(255, 255, 255, 0.9)',
+ borderRadius: this.chartOptions?.labelBorderRadius || 4,
+ padding: this.chartOptions?.labelPadding || [6, 10],
+ formatter: (params) => {
+ const timestamp = params.value[0];
+ const date = new Date(Number(timestamp));
+
+ // Format de date configurable via PHP
+ const dateFormat = this.chartOptions?.labelDateFormat || 'fr-FR';
+ const dateOptions = this.chartOptions?.labelDateOptions || {
+ day: '2-digit',
+ month: '2-digit'
+ };
+
+ let formattedDate = date.toLocaleDateString(dateFormat, dateOptions);
+
+ // Remplacer le séparateur par celui configuré (défaut: tiret)
+ const separator = this.chartOptions?.labelDateSeparator || '-';
+ if (separator !== '/') {
+ formattedDate = formattedDate.replace(/\//g, separator);
+ }
+
+ return formattedDate;
+ }
+ };
+
+ // Ajouter les bordures seulement si activées
+ if (this.chartOptions?.labelBorder !== false && this.chartOptions?.labelBorderWidth !== 0) {
+ seriesConfig.label.borderColor = this.chartOptions?.labelBorderColor || '#333333';
+ seriesConfig.label.borderWidth = this.chartOptions?.labelBorderWidth || 1;
+ }
+
+ // Ajouter l'ombre seulement si activée
+ if (this.chartOptions?.labelShadow !== false) {
+ seriesConfig.label.shadowColor = this.chartOptions?.labelShadowColor || 'rgba(0, 0, 0, 0.3)';
+ seriesConfig.label.shadowBlur = this.chartOptions?.labelShadowBlur || 3;
+ seriesConfig.label.shadowOffsetX = this.chartOptions?.labelShadowOffsetX || 1;
+ seriesConfig.label.shadowOffsetY = this.chartOptions?.labelShadowOffsetY || 1;
+ }
+ } else {
+ // Mode points uniquement (défaut)
+ seriesConfig.symbolSize = symbolSize;
+ seriesConfig.itemStyle = {
+ color: pointColor,
+ opacity: 0.9,
+ borderColor: '#ffffff',
+ borderWidth: 1
+ };
+ seriesConfig.emphasis = {
+ itemStyle: {
+ opacity: 1,
+ borderWidth: 2
+ }
+ };
+ }
+
+ return seriesConfig;
+ });
+ }
}
/**
- * Create or update a line chart (area in practice)
- * @param {*} id
- * @returns
+ * Generic method to create or update a chart
+ * @param {string} id - Chart ID
+ * @returns {Promise}
*/
- async line(id)
+ async createOrUpdateChart(id)
{
await this.get(id);
@@ -394,6 +875,126 @@ class EChart
const series = this.buildSeries();
options.series = series;
+ // For pie charts, hide axes and grid
+ if (this.type === 'pie') {
+ options.xAxis.show = false;
+ options.yAxis.show = false;
+ options.grid.show = false;
+ // Disable dataZoom to avoid conflicts with page scroll
+ options.dataZoom = [];
+ }
+
+ // For nightingale charts, hide axes and grid (same as pie)
+ if (this.type === 'nightingale') {
+ options.xAxis.show = false;
+ options.yAxis.show = false;
+ options.grid.show = false;
+ // Disable dataZoom to avoid conflicts with page scroll
+ options.dataZoom = [];
+ }
+
+ // For doughnut charts, hide axes and grid (same as pie)
+ if (this.type === 'doughnut') {
+ options.xAxis.show = false;
+ options.yAxis.show = false;
+ options.grid.show = false;
+ // Disable dataZoom to avoid conflicts with page scroll
+ options.dataZoom = [];
+ }
+
+ // For line charts, adjust tooltip trigger and add axis pointer
+ if (this.type === 'line') {
+ options.tooltip.trigger = 'axis';
+ options.tooltip.axisPointer = {
+ type: 'line',
+ animation: false,
+ label: {
+ backgroundColor: '#505765'
+ }
+ };
+ }
+
+ // For points/scatter charts, adjust tooltip trigger and axis configuration
+ if (this.type === 'points' || this.type === 'scatter') {
+ options.tooltip.trigger = 'item';
+ options.tooltip.formatter = (params) => {
+ if (!params) return '';
+
+ const timestamp = params.value[0];
+ const value = params.value[1];
+ const d = new Date(Number(timestamp));
+
+ // Skip generic series names like 'series0', 'series1', etc.
+ const shouldShowSeriesName = params.seriesName &&
+ !params.seriesName.match(/^series\d+$/i) &&
+ !params.seriesName.toLowerCase().includes('series');
+
+ let result = '';
+ if (shouldShowSeriesName) {
+ result += '
' + params.seriesName + '';
+ }
+
+ // result += 'Time: ' + d.toLocaleString(undefined, {
+ // year: 'numeric', month: 'short', day: '2-digit',
+ // hour: '2-digit', minute: '2-digit', second: '2-digit',
+ // hour12: false
+ // }) + '
';
+
+ // Configurable unit and precision from PHP options
+ const unit = this.chartOptions?.tooltip?.valueUnit || '';
+ const precision = this.chartOptions?.tooltip?.valuePrecision || 2;
+ const formattedValue = value.toFixed(precision);
+
+ result += unit ? `${formattedValue} ${unit}` : formattedValue;
+
+ return result;
+ };
+
+ // Ajuster les marges si des labels sont affichés pour éviter le débordement
+ if (this.chartOptions?.showAsLabels || this.chartOptions?.showBothLabelsAndPoints) {
+ options.grid.containLabel = true; // ECharts ajuste automatiquement pour contenir les labels
+ }
+ }
+
+ // For bar charts, adjust axis configuration
+ if (this.type === 'bar') {
+ // For bar charts, we usually use categories on the X axis
+ options.xAxis.type = 'category';
+ options.xAxis.data = this.labels;
+ options.xAxis.boundaryGap = true; // Add space around bars
+ options.xAxis.axisLabel.rotate = 45;
+ options.xAxis.axisLabel.interval = 0; // Show all labels
+ options.xAxis.axisLabel.fontSize = 12;
+ options.xAxis.axisLabel.textStyle = {
+ color: '#8A99AA'
+ };
+ }
+
+ // For horizontal bar charts, just swap the axes (same as vertical but inverted)
+ if (this.type === 'barHorizontal') {
+ // Y axis: categories (instead of X for vertical bars)
+ options.yAxis.type = 'category';
+ options.yAxis.data = this.labels;
+ options.yAxis.inverse = true; // Invert Y-axis to show first data at top
+ options.yAxis.axisLabel = {
+ color: '#8A99AA'
+ };
+
+ // X axis: values (instead of Y for vertical bars)
+ options.xAxis.type = 'value';
+ options.xAxis.boundaryGap = [0, 0.01];
+ options.xAxis.axisLabel = {
+ color: '#8A99AA'
+ };
+ options.xAxis.splitLine = {
+ show: false // Hide vertical grid lines
+ };
+ delete options.xAxis.data;
+
+ // Disable dataZoom to avoid conflicts with page scroll
+ options.dataZoom = [];
+ }
+
// Set title
if (this.chartOptions.title?.text) {
options.title.text = this.chartOptions.title.text;
@@ -449,52 +1050,117 @@ class EChart
}
// Toolbar show / hide
- if (this.chartOptions.toolbar?.show === false) {
+ if (this.chartOptions.toolbox?.show === false) {
options.toolbox.show = false;
}
+ // Enable magicType only for line, bar and scatter/points charts
+ if (this.type === 'line' || this.type === 'bar' || this.type === 'points' || this.type === 'scatter') {
+ options.toolbox.feature.magicType.show = true;
+ // Add scatter to the available types if it's a points chart
+ if (this.type === 'points' || this.type === 'scatter') {
+ options.toolbox.feature.magicType.type = ['line', 'bar', 'scatter'];
+ }
+ }
+
+ // Enable dataZoom only for line and bar charts
+ if (this.type === 'line' || this.type === 'bar') {
+ options.toolbox.feature.dataZoom.show = true;
+ }
+
+ // Merge tooltip options from server configuration (deep merge for nested properties like axisPointer)
+ if (this.chartOptions.tooltip) {
+ for (const key in this.chartOptions.tooltip) {
+ if (this.chartOptions.tooltip[key] && typeof this.chartOptions.tooltip[key] === 'object' && !Array.isArray(this.chartOptions.tooltip[key])) {
+ options.tooltip[key] = { ...options.tooltip[key], ...this.chartOptions.tooltip[key] };
+ } else {
+ options.tooltip[key] = this.chartOptions.tooltip[key];
+ }
+ }
+ }
+
+ // Tooltip show / hide (explicit override if needed)
+ if (this.chartOptions.tooltip?.show === false) {
+ options.tooltip.show = false;
+ }
+
// Legend
if (this.chartOptions.legend?.show === true) {
options.legend.show = true;
// If legend is show, adjust grid bottom to make room
- options.grid.bottom = '40px';
+ options.grid.bottom = '30px';
}
- // Window size for initial zoom (default 15 points)
- const visibleCount = this.chartOptions?.['init-zoom'] ?? 15;
+ // Determine if we have large dataset (used for slider and other optimizations)
const totalPoints = this.labels.length;
- if (totalPoints > visibleCount) {
- // Find the range that contains the most recent data with actual values
- let hasDataInRange = false;
- let startPercent = Math.max(0, ((totalPoints - visibleCount) / totalPoints) * 100);
+ const isLargeDataset = this.days > 3;
+
+ // DataZoom slider show / hide (auto-enable for large datasets on line/bar charts)
+ if (this.chartOptions.dataZoom?.slider?.show === true ||
+ (isLargeDataset && totalPoints > 1 && (this.type === 'line' || this.type === 'bar'))) {
+ options.dataZoom[1].show = true;
+ // Adjust grid bottom to make room for the slider
+ options.grid.bottom = '45px';
+ }
+
+ // Window size for initial zoom (default 15 points) - only for line and points/scatter charts
+ if (this.type === 'line' || this.type === 'points' || this.type === 'scatter') {
+ const visibleCount = this.chartOptions?.['init-zoom'] ?? 15;
- // Check if there's actual data in the calculated range
- for (let i = Math.floor(totalPoints * startPercent / 100); i < totalPoints; i++) {
- for (let dataset of this.datasets) {
- if (dataset.data[i] && dataset.data[i] !== 0) {
- hasDataInRange = true;
- break;
+ // If period changed, always show all data so user can see the new time range
+ if (this._periodChanged) {
+ options.dataZoom[0].start = 0;
+ options.dataZoom[0].end = 100;
+ options.dataZoom[1].start = 0;
+ options.dataZoom[1].end = 100;
+ }
+ // If the chart was previously zoomed/panned, show all data instead of applying initial zoom
+ else if (this._wasInNaturalState === false) {
+ options.dataZoom[0].start = 0;
+ options.dataZoom[0].end = 100;
+ options.dataZoom[1].start = 0;
+ options.dataZoom[1].end = 100;
+ } else if (isLargeDataset) {
+ // For large datasets (> 100 points), show only a percentage initially (about 15-20 days for 6 months)
+ const endPercentage = Math.max(10, Math.min(20, (visibleCount / totalPoints) * 100));
+ options.dataZoom[0].start = 100 - endPercentage;
+ options.dataZoom[0].end = 100;
+ options.dataZoom[1].start = 100 - endPercentage;
+ options.dataZoom[1].end = 100;
+ } else if (totalPoints > visibleCount) {
+ // Apply normal initial zoom logic only if chart was in natural state
+ // Find the range that contains the most recent data with actual values
+ let hasDataInRange = false;
+ let startPercent = Math.max(0, ((totalPoints - visibleCount) / totalPoints) * 100);
+
+ // Check if there's actual data in the calculated range
+ for (let i = Math.floor(totalPoints * startPercent / 100); i < totalPoints; i++) {
+ for (let dataset of this.datasets) {
+ if (dataset.data[i] && dataset.data[i] !== 0) {
+ hasDataInRange = true;
+ break;
+ }
}
+ if (hasDataInRange) break;
}
- if (hasDataInRange) break;
- }
-
- // If no data in the default range, show the full range instead
- if (!hasDataInRange) {
- startPercent = 0;
+
+ // If no data in the default range, show the full range instead
+ if (!hasDataInRange) {
+ startPercent = 0;
+ options.dataZoom[0].end = 100;
+ options.dataZoom[1].end = 100;
+ }
+
+ options.dataZoom[0].start = startPercent;
+ options.dataZoom[1].start = startPercent;
+ } else {
+ // If we have few data points, show everything
+ options.dataZoom[0].start = 0;
options.dataZoom[0].end = 100;
+ options.dataZoom[1].start = 0;
options.dataZoom[1].end = 100;
}
-
- options.dataZoom[0].start = startPercent;
- options.dataZoom[1].start = startPercent;
- } else {
- // If we have few data points, show everything
- options.dataZoom[0].start = 0;
- options.dataZoom[0].end = 100;
- options.dataZoom[1].start = 0;
- options.dataZoom[1].end = 100;
}
// Initialize EChart with Canvas renderer for better performance
@@ -505,11 +1171,66 @@ class EChart
// Set options and render
chart.setOption(options);
+ // Force zoom reset if period changed
+ if (this._periodChanged) {
+ chart.dispatchAction({
+ type: 'dataZoom',
+ start: 0,
+ end: 100
+ });
+ }
+
// Remove spinner
- $('#' + id + '-loading').remove();
+ $('#' + id + '-loading').hide();
chartElement._chartInstance = chart;
+ // Listen for magicType changes to preserve chart type
+ chart.on('magictypechanged', (params) => {
+ // Store current colors before type change
+ const currentOption = chart.getOption();
+ if (currentOption.series && currentOption.series.length > 0) {
+ this._preservedColors = currentOption.series.map(serie => serie.color);
+ console.info('EChart: preserved colors:', this._preservedColors);
+ }
+
+ this.currentType = params.currentType;
+ console.info('EChart: magicType changed to', params.currentType, 'for chart', this.id);
+ });
+
+ // Add click event if configured in chartOptions
+ if (this.chartOptions.clickCallback?.enabled === true) {
+ chart.on('click', (params) => {
+ let urlValue = null;
+
+ // For points/scatter charts with snapshotId data
+ if ((this.type === 'points' || this.type === 'scatter') &&
+ params.componentType === 'series' &&
+ params.data &&
+ params.data.snapshotId) {
+ urlValue = params.data.snapshotId;
+ }
+ // Fallback to original behavior for other chart types
+ else if (params.componentType === 'series' && params.name) {
+ urlValue = params.name;
+ }
+
+ if (urlValue) {
+ // Build URL with the configured pattern
+ let url = this.chartOptions.clickCallback.url;
+ // Replace placeholder with the value
+ url = url.replace('{value}', encodeURIComponent(urlValue));
+
+ // Open in new tab or same tab based on configuration
+ if (this.chartOptions.clickCallback.newTab !== false) {
+ window.open(url, '_blank');
+ } else {
+ window.location.href = url;
+ }
+ }
+ });
+ }
+
// Handle window resize
window.addEventListener('resize', () => {
chart.resize();
@@ -563,7 +1284,7 @@ class EChart
});
}
- $('#' + this.id + '-loading').remove();
+ $('#' + this.id + '-loading').hide();
}
}
@@ -635,12 +1356,75 @@ EChart.destroyInstance = function(chartId) {
const instance = EChart.instances[chartId];
if (instance) {
instance.stopAutoUpdate();
+
+ // Clean up DOM element reference
+ const chartElement = document.querySelector("#" + chartId);
+ if (chartElement && chartElement._chartInstance) {
+ chartElement._chartInstance.dispose();
+ delete chartElement._chartInstance;
+ }
+
delete EChart.instances[chartId];
return true;
}
return false;
};
+/**
+ * Recreate a chart by destroying and creating a new instance
+ * @param {*} type
+ * @param {*} id
+ * @param {*} autoUpdate
+ * @param {*} autoUpdateInterval
+ * @param {*} days
+ */
+EChart.recreate = function(type, id, autoUpdate = true, autoUpdateInterval = 15000, days = 1) {
+ // Check if existing chart was in natural state before destroying
+ let wasInNaturalState = true;
+ let periodChanged = false;
+ let preservedCurrentType = type; // Default to original type
+ let instance = null; // Declare instance variable
+
+ try {
+ instance = EChart.instances[id];
+
+ // Get current chart element for zoom state check
+ const chartElement = document.querySelector("#" + id);
+ if (chartElement && chartElement._chartInstance && instance) {
+ const currentOption = chartElement._chartInstance.getOption();
+ if (currentOption && currentOption.dataZoom) {
+ wasInNaturalState = instance.isInNaturalState(currentOption.dataZoom);
+ }
+
+ // Check if period has changed (using Number for comparison)
+ const oldDays = Number(instance.days);
+ const newDays = Number(days);
+ periodChanged = oldDays !== newDays;
+
+ // Preserve the current chart type (may have been changed by magicType)
+ preservedCurrentType = instance.currentType || instance.type;
+ }
+ } catch (error) {
+ console.warn('EChart.recreate: Error checking zoom state, defaulting to natural state', error);
+ wasInNaturalState = true;
+ periodChanged = false;
+ preservedCurrentType = type;
+ }
+
+ if (EChart.destroyInstance(id)) {
+ // Make spinner visible before creating new chart
+ $('#' + id + '-loading').show();
+
+ // Create new instance and pass the preserved type directly to constructor
+ const newInstance = new EChart(type, id, autoUpdate, autoUpdateInterval, days, wasInNaturalState, periodChanged, preservedCurrentType);
+
+ // Also preserve colors if they existed
+ if (instance && instance._preservedColors) {
+ newInstance._preservedColors = instance._preservedColors;
+ }
+ }
+}
+
/**
* Register available formatters (to avoid eval)
*/
@@ -648,3 +1432,37 @@ EChart.formatters = {
// Example usage: formatterName: "activeState"
activeState: (val) => val === 1 ? "active" : "inactive",
};
+
+/**
+ * Event: when the period (days) selection is changed for any chart with class 'echart-period'
+ */
+$(document).ready(function () {
+ // Initialize charts for all elements with class 'echart'
+ $('.echart').each(function() {
+ const id = $(this).attr('id');
+ const type = $(this).attr('type');
+ const autoUpdate = $(this).attr('autoupdate') || true;
+ const autoUpdateInterval = $(this).attr('interval') || 15000;
+ const days = $(this).attr('days') || 1; // Default days based on screen size
+ const generate = $(this).attr('generate') !== undefined; // Check if 'generate' attribute is present
+
+ // Initialize charts that have the 'generate' attribute to avoid unnecessary instances
+ if (id && type && generate) {
+ new EChart(type, id, autoUpdate, autoUpdateInterval, days);
+ }
+ });
+
+ // Listen for changes on any select element with class 'echart-period'
+ $('select.echart-period').on('change', function() {
+ // Get chart Id
+ const chartId = $(this).attr('chart-id');
+ // Get selected value - convert to number to match internal storage
+ const days = Number($(this).val());
+
+ // Get chart type from 'type' or 'chart-type' attribute, default to 'line' if not specified
+ const type = $('#' + chartId).attr('type') || $('#' + chartId).attr('chart-type') || 'line';
+
+ // Destroy and recreate chart with new days value (pass as number)
+ EChart.recreate(type, chartId, true, 15000, days);
+ });
+});
diff --git a/www/public/resources/js/classes/Layout.js b/www/public/resources/js/classes/Layout.js
index fabcfb60..558f523e 100644
--- a/www/public/resources/js/classes/Layout.js
+++ b/www/public/resources/js/classes/Layout.js
@@ -53,7 +53,7 @@ class Layout {
*/
veilBody()
{
- $('body').append('


');
+ $('body').append('
');
}
/**
diff --git a/www/public/resources/js/classes/Motion/Media.js b/www/public/resources/js/classes/Motion/Media.js
new file mode 100644
index 00000000..7018fcc2
--- /dev/null
+++ b/www/public/resources/js/classes/Motion/Media.js
@@ -0,0 +1,83 @@
+class Media
+{
+ /**
+ * Download selected media(s)
+ */
+ download(checkboxes)
+ {
+ var files = [];
+
+ // Get selected media Id
+ $(checkboxes).each(function () {
+ files.push({ fileId: $(this).attr('file-id'), filename: $(this).attr('file-name') });
+ });
+
+ // Append a temporary
element to download files
+ var temporaryDownloadLink = document.createElement('a');
+ temporaryDownloadLink.style.display = 'none';
+
+ document.body.appendChild(temporaryDownloadLink);
+
+ for (var n = 0; n < files.length; n++) {
+ var download = files[n];
+
+ // Set the href attribute to the file path, also include the filename for the android app to make sure it downloads the file with the correct name
+ temporaryDownloadLink.setAttribute('href', '/media?id=' + download.fileId + '&filename=' + download.filename);
+ // Set the download attribute to force download
+ temporaryDownloadLink.setAttribute('download', download.filename);
+ // Trigger click on the temporary element to start download
+ temporaryDownloadLink.click();
+ }
+
+ // Remove temporary element
+ document.body.removeChild(temporaryDownloadLink);
+ }
+
+ /**
+ * Delete selected media(s)
+ */
+ delete(checkboxes)
+ {
+ var mediaId = [];
+
+ // Get selected media Id
+ $(checkboxes).each(function () {
+ var id = $(this).attr('file-id');
+ mediaId.push(id);
+ });
+
+ // Print confirm box to delete selected media(s)
+ setTimeout(function () {
+ myconfirmbox.print(
+ {
+ 'id': 'delete-media',
+ 'title': 'Delete medias',
+ 'message': 'Are you sure you want to delete ' + mediaId.length + ' media' + (mediaId.length > 1 ? 's' : '') + ' ?',
+ 'buttons': [
+ {
+ 'text': 'Delete',
+ 'color': 'red',
+ 'callback': function () {
+ ajaxRequest(
+ // Controller:
+ 'motion',
+ // Action:
+ 'deleteFile',
+ // Data:
+ {
+ mediaId: mediaId
+ },
+ // Print success alert:
+ true,
+ // Print error alert:
+ true
+ ).then(function () {
+ mycontainer.reload('motion/events/list');
+ });
+ }
+ }]
+ }
+ );
+ }, 10);
+ }
+}
\ No newline at end of file
diff --git a/www/public/resources/js/events/motion/event.js b/www/public/resources/js/events/motion/event.js
index a14f5d69..486b8fb7 100644
--- a/www/public/resources/js/events/motion/event.js
+++ b/www/public/resources/js/events/motion/event.js
@@ -1,7 +1,7 @@
/**
- * Add an Intersection Observer to load videos only when they are visible
+ * Initialize video lazy loading with Intersection Observer
*/
-document.addEventListener("DOMContentLoaded", function () {
+function initializeVideoLazyLoading() {
// Create the IntersectionObserver to observe video visibility
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
@@ -27,7 +27,6 @@ document.addEventListener("DOMContentLoaded", function () {
});
video.setAttribute('poster', poster); // Trigger poster loading
video.load(); // Trigger video loading
-
};
// Function to unload video sources when not visible
@@ -62,4 +61,71 @@ document.addEventListener("DOMContentLoaded", function () {
// Start observing DOM changes
mutationObserver.observe(document.body, config);
-});
\ No newline at end of file
+}
+
+/**
+ * Initialize video lazy loading on DOMContentLoaded and make it available globally
+ */
+document.addEventListener("DOMContentLoaded", function () {
+ initializeVideoLazyLoading();
+});
+
+/**
+ * Event: select events date
+ */
+$(document).on('change','.event-date-input',function () {
+ const date = $(this).val();
+
+ // Delete tables/motion/events/offset cookie to reset pagination to the first page after reloading events list
+ mycookie.delete('tables/motion/events/offset');
+
+ // Save selected date in cookies for 15 minutes
+ mycookie.set('event-date', date, 1/96); // 15 minutes
+
+ // Reload events list
+ mycontainer.reload('motion/events/list');
+});
+
+/**
+ * Event: filter events by camera
+ */
+$(document).on('change','select#events-filter-cameras',function () {
+ const selectedCameras = $(this).val();
+
+ // Delete tables/motion/events/offset cookie to reset pagination to the first page after reloading events list
+ mycookie.delete('tables/motion/events/offset');
+
+ // Save selected cameras in cookies for 7 days
+ mycookie.set('tmp/events-filter-cameras', selectedCameras ? selectedCameras.join(',') : '', 1/96); // 15 minutes
+
+ // Reload events list
+ mycontainer.reload('motion/events/list');
+});
+
+/**
+ * Event: acquit all events
+ */
+$(document).on('click','.acquit-events-btn',function () {
+ myalert.print('Acquitting all events, please wait...');
+
+ ajaxRequest(
+ // Controller:
+ 'motion',
+ // Action:
+ 'acquit-events',
+ // Data:
+ {},
+ // Print success alert:
+ true,
+ // Print error alert:
+ true
+ ).then(function () {
+ mycontainer.reload('buttons/bottom');
+
+ // Remove all 'New' labels from events list
+ $('.new-event-label').remove();
+
+ // Remove all 'unacquitted' classes from events list
+ $('.acquit-events-btn').remove();
+ });
+});
diff --git a/www/public/resources/js/motion.js b/www/public/resources/js/motion.js
index 26673b30..68c47cc8 100644
--- a/www/public/resources/js/motion.js
+++ b/www/public/resources/js/motion.js
@@ -1,126 +1,3 @@
-/**
- * Function: get selected media Id and delete them
- */
-function deleteMedia()
-{
- var mediaId = [];
-
- /**
- * Get all selected checkboxes and their file-id (media) attribute
- */
- $('#events-captures-div').find('input[class=event-media-checkbox]:checked').each(function () {
- id = $(this).attr('file-id');
- mediaId.push(id);
- });
-
- /**
- * Wait for previous confirm box to be removed
- */
- setTimeout(function () {
- myconfirmbox.print(
- {
- 'title': 'Delete medias',
- 'message': 'Are you sure you want to delete the selected media(s)?',
- 'buttons': [
- {
- 'text': 'Delete',
- 'color': 'red',
- 'callback': function () {
- ajaxRequest(
- // Controller:
- 'motion',
- // Action:
- 'deleteFile',
- // Data:
- {
- mediaId: mediaId
- },
- // Print success alert:
- true,
- // Print error alert:
- true,
- // Reload containers:
- ['motion/events/list']
- ).then(function () {
- loadEventDateTotalMediaSize();
- });
- }
- }]
- }
- );
- }, 10);
-}
-
-/**
- * Function: get selected media Id and download them
- */
-function downloadMedia()
-{
- filesForDownload = [];
-
- /**
- * Get all selected checkboxes and their file-id (media) attribute
- */
- $('#events-captures-div').find('input[class=event-media-checkbox]:checked').each(function () {
- filesForDownload.push({ fileId: $(this).attr('file-id'), filename: $(this).attr('file-name') });
- });
-
- /**
- * Append a temporary element to download files
- */
- var temporaryDownloadLink = document.createElement("a");
- temporaryDownloadLink.style.display = 'none';
-
- document.body.appendChild(temporaryDownloadLink);
-
- for (var n = 0; n < filesForDownload.length; n++) {
- var download = filesForDownload[n];
- // Set the href attribute to the file path, also include the filename for the android app to make sure it downloads the file with the correct name
- temporaryDownloadLink.setAttribute('href', '/media?id=' + download.fileId + '&filename=' + download.filename);
- // Set the download attribute to force download
- temporaryDownloadLink.setAttribute('download', download.filename);
-
- /**
- * Click on the element to start download
- */
- temporaryDownloadLink.click();
- }
-
- /**
- * Remove temporary element
- */
- document.body.removeChild(temporaryDownloadLink);
-}
-
-function reloadMotionConfigEditForm(id)
-{
- setTimeout(function () {
- ajaxRequest(
- // Controller:
- 'general',
- // Action:
- 'get-panel',
- // Data:
- {
- name: 'motion/edit',
- params: {
- 'id': id
- }
- },
- // Print success alert:
- false,
- // Print error alert:
- true
- ).then(function () {
- // Get the #camera-edit-motion-config-form-container from jsonValue.message
- content = $(jsonValue.message).find('#camera-edit-motion-config-form-container').html();
-
- // Replace the content
- $('#camera-edit-motion-config-form-container').html(content);
- });
- }, 50);
-}
-
/**
* Start / stop motion service
*/
@@ -225,28 +102,6 @@ $(document).on('click','#send-test-email-btn',function () {
sendTestEmail(mailRecipient);
});
-/**
- * Event: acquit all events
- */
-$(document).on('click','.acquit-events-btn',function () {
- myalert.print('Acquitting all events, please wait...');
-
- ajaxRequest(
- // Controller:
- 'motion',
- // Action:
- 'acquit-events',
- // Data:
- {},
- // Print success alert:
- true,
- // Print error alert:
- true,
- // Reload containers:
- [ 'motion/events/list', 'buttons/bottom' ]
- );
-});
-
/**
* Function: print events between selected dates
* @param {*} dateStart
@@ -266,19 +121,6 @@ function eventDateSelect(dateStart, dateEnd)
mycontainer.reload('motion/events/list');
}
-/**
- * Event: select events dates
- */
-$(document).on('change','.event-date-input',function () {
- date = $(this).val();
-
- document.cookie = "event-date=" + date + ";max-age=900;";
-
- mycontainer.reload('motion/events/list').then(() => {
- loadEventDateTotalMediaSize();
- });
-});
-
/**
* Event: vizualize event image
*/
@@ -319,86 +161,68 @@ $(document).on('click','.event-print-file-close-btn',function () {
$(document).on('click','input[class=event-media-checkbox]',function () {
var eventId = $(this).attr('event-id');
- /**
- * Count checked checkboxes
- */
- var count_checked = $('#events-captures-div').find('input[class=event-media-checkbox]:checked').length;
+ // Get checked checkboxes
+ const checkboxes = $('#events-captures-div').find('input[class=event-media-checkbox]:checked');
- /**
- * If no checkbox is selected
- */
- if (count_checked == 0) {
- /**
- * Hide confirm box, checkboxes and 'Select all' button
- */
+ // If no checkbox is selected
+ if (checkboxes.length == 0) {
+ // Hide confirm box, checkboxes and 'Select all' button
myconfirmbox.close();
$('#events-captures-div').find('input[class=event-media-checkbox]').removeAttr('style');
$('#events-captures-div').find('.select-all-media-checkbox').hide();
return;
}
- /**
- * Print confirm box to delete selected medias
- */
+ // Print confirm box to download or delete selected media(s)
myconfirmbox.print(
{
'id': 'download-delete-media',
'title': 'Download or delete selected media(s)',
- 'message': '',
+ 'message': checkboxes.length + ' media' + (checkboxes.length > 1 ? 's' : '') + ' selected',
'buttons': [
{
'text': 'Download',
'color': 'blue',
'callback': function () {
- downloadMedia();
+ mymotionmedia.download(checkboxes);
}
},
{
'text': 'Delete',
'color': 'red',
'callback': function () {
- deleteMedia();
+ mymotionmedia.delete(checkboxes);
}
}]
}
);
- /**
- * Print related 'Select all' button
- */
+ // Print related 'Select all' button
$('#events-captures-div').find('.select-all-media-checkbox[event-id="' + eventId + '"]').css('display', 'initial');
- /**
- * Print all related checkboxes with opacity 1
- */
- $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').css("visibility", "visible");
- $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').css("opacity", "1");
+ // Print all related checkboxes with opacity 1
+ $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').css('visibility', 'visible');
+ $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').css('opacity', '1');
});
/**
* Event: on 'Select all' button click
*/
$(document).on('click',".select-all-media-checkbox",function () {
- var eventId = $(this).attr('event-id');
-
- /**
- * Count checked checkboxes
- */
- var count_checked = $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]:checked').length;
-
- /**
- * Count total checkbox
- */
- var count_total = $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').length;
+ const eventId = $(this).attr('event-id');
- if (count_checked == count_total) {
- $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').prop('checked', false);
- // Hide 'select all' button
- $(this).hide();
- // Hide confirm box
- myconfirmbox.close();
+ if ($(this).is(':checked')) {
+ // Loop on all unchecked checkboxes and check them
+ $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]:not(:checked)').each(function () {
+ $(this).click();
+ });
} else {
- $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').prop('checked', true);
+ // Uncheck all checkboxes
+ $('#events-captures-div').find('input[class=event-media-checkbox][event-id="' + eventId + '"]').each(function () {
+ if ($(this).is(':checked')) {
+ $(this).click();
+ }
+ });
}
});
diff --git a/www/public/resources/js/pre/motion.js b/www/public/resources/js/pre/motion.js
index 008f3ef6..e1c6d18a 100644
--- a/www/public/resources/js/pre/motion.js
+++ b/www/public/resources/js/pre/motion.js
@@ -8,7 +8,10 @@ function loadEventDateTotalMediaSize()
}
// Get date
- var date = $('#event-date-total-size').attr('event-date');
+ const date = $('#event-date-total-size').attr('event-date');
+
+ // Get selected cameras from select
+ const cameras = $('#events-filter-cameras').val();
ajaxRequest(
// Controller:
@@ -17,7 +20,8 @@ function loadEventDateTotalMediaSize()
'get-event-date-total-media-size',
// Data:
{
- date: date
+ date: date,
+ cameras: cameras
},
// Print success alert:
false,
@@ -26,6 +30,6 @@ function loadEventDateTotalMediaSize()
).then(function () {
$('#event-date-total-size').html('(' + jsonValue.message + ')');
}).catch(function () {
- $('#event-date-total-size').html();
+ $('#event-date-total-size').html('Error loading size');
});
}
\ No newline at end of file
diff --git a/www/public/resources/styles/common.css b/www/public/resources/styles/common.css
index 9a8d706c..3e00763d 100644
--- a/www/public/resources/styles/common.css
+++ b/www/public/resources/styles/common.css
@@ -1,5 +1,5 @@
/**
- * v1.20
+ * v1.21
*/
@font-face{font-family: 'Roboto'; src: url('/assets/fonts/Roboto/Roboto-Regular.ttf') format('truetype');}
@@ -161,8 +161,10 @@ pre.codeblock {
filter: initial;
}
.text-wrap{text-wrap:wrap}
-.wordbreakall {word-break: break-all}
-.flex {display:flex!important;}
+.wordbreakall{word-break:break-all}
+.overflowx-auto{overflow-x:auto}
+.overflowy-hidden{overflow-y:hidden}
+.flex{display:flex!important;}
.flex-div-15 { flex: 0 0 14%; }.flex-div-20 { flex: 0 0 19%; }.flex-div-25 { flex: 0 0 24%; }.flex-div-30 { flex: 0 0 29%; }
.flex-div-33 { flex: 0 0 32%; }.flex-div-40 { flex: 0 0 39%; }.flex-div-50 { flex: 0 0 47%; }.flex-div-60 { flex: 0 0 59%; }
.flex-div-65 { flex: 0 0 64%; }.flex-div-68 { flex: 0 0 67%; }.flex-div-80 { flex: 0 0 79%; }.flex-div-90 { flex: 0 0 90%; }.flex-div-100 { flex: 0 0 98%; }
@@ -187,8 +189,12 @@ pre.codeblock {
.grid-fr-6 {grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr !important}
.grid-40p-45p-10p{grid-template-columns: 40% 45% 10% !important}
/* For responsive */
-.grid-rfr-1-2,.grid-rfr-1-3,.grid-rfr-1-4,.grid-rfr-1-6{grid-template-columns:1fr !important}
+.grid-rfr-1-2,.grid-rfr-1-3,.grid-rfr-1-4,.grid-rfr-1-5,.grid-rfr-1-6{grid-template-columns:1fr !important}
.grid-rfr-2-4{grid-template-columns:repeat(2, 1fr) !important}
+/* For responsive */
+/* column on mobile */
+.flexrc{display:flex;flex-direction: column;}
+.hide-mobile{display:none!important;}
.justify-center {justify-content: center}
.justify-space-between {justify-content: space-between}
.justify-space-around{justify-content: space-around}
@@ -217,14 +223,14 @@ pre.codeblock {
.margin-right-0 {margin-right: 0px !important}.margin-right-5 {margin-right: 5px !important}.margin-right-8{margin-right:8px!important}.margin-right-10 {margin-right: 10px !important}.margin-right-15 {margin-right: 15px !important}.margin-right-20 {margin-right: 20px !important}.margin-right-30 {margin-right: 30px !important}.margin-right-40 {margin-right: 40px !important}
.margin-top-0 {margin-top: 0px !important}.margin-top-5 {margin-top: 5px !important}.margin-top-8{margin-top:8px!important}.margin-top-10 {margin-top: 10px !important}.margin-top-15 {margin-top: 15px !important}.margin-top-20 {margin-top: 20px !important}.margin-top-30 {margin-top: 30px !important}.margin-top-40 {margin-top: 40px !important}.margin-top-50{margin-top:50px !important}
.margin-bottom-0 {margin-bottom: 0px !important}.margin-bottom-5 {margin-bottom: 5px !important}.margin-bottom-8{margin-bottom:8px!important}.margin-bottom-10 {margin-bottom: 10px !important}.margin-bottom-15 {margin-bottom: 15px !important}.margin-bottom-20 {margin-bottom: 20px !important}.margin-bottom-30 {margin-bottom: 30px !important}.margin-bottom-40 {margin-bottom: 40px !important} .margin-bottom-50{margin-bottom:50px!important}
-.min-height-100 {min-height: 100px}.min-height-200 {min-height: 200px}.min-height-300 {min-height: 300px}.min-height-400 {min-height: 400px}.min-height-500 {min-height: 500px}.min-height-600 {min-height: 600px}.min-height-700 {min-height: 700px}.min-height-800 {min-height: 800px}.min-height-900 {min-height: 900px}.min-height-1000 {min-height: 1000px}
+.min-height-100{min-height:100px!important}.min-height-120{min-height:120px!important}.min-height-150{min-height:150px!important}.min-height-200{min-height:200px!important}.min-height-300{min-height:300px!important}.min-height-400{min-height:400px!important}.min-height-500{min-height:500px!important}.min-height-600{min-height:600px!important}.min-height-700{min-height:700px!important}.min-height-800{min-height:800px!important}.min-height-900{min-height:900px!important}.min-height-1000{min-height:1000px!important}
.min-width-100 {min-width: 100px}.min-width-200 {min-width: 200px}.min-width-300 {min-width: 300px}.min-width-400 {min-width: 400px}.min-width-500 {min-width: 500px}.min-width-600 {min-width: 600px}.min-width-700 {min-width: 700px}.min-width-800 {min-width: 800px}.min-width-900 {min-width: 900px}.min-width-1000 {min-width: 1000px}
.min-height-50vh{min-height:50vh}.min-height-90vh{min-height:90vh}.min-height-100vh{min-height:100vh}
.min-width-100vw{min-width:100vw}
.width-100{width:100%}
.height-100{height:100%}
.max-width-fit{max-width:fit-content}
-.max-width-100{max-width:100px}.max-width-200{max-width:200px}.max-width-300{max-width:300px}.max-width-400{max-width:400px}.max-width-500{max-width:500px}.max-width-600{max-width:600px}.max-width-700{max-width:700px}.max-width-800{max-width:800px}.max-width-900{max-width:900px}.max-width-1000{max-width:1000px}
+.max-width-80{max-width:80px}.max-width-100{max-width:100px}.max-width-200{max-width:200px}.max-width-300{max-width:300px}.max-width-400{max-width:400px}.max-width-500{max-width:500px}.max-width-600{max-width:600px}.max-width-700{max-width:700px}.max-width-800{max-width:800px}.max-width-900{max-width:900px}.max-width-1000{max-width:1000px}
.resize-disabled{resize:none;}
code {
@@ -236,59 +242,6 @@ code {
border-radius: 50px;
}
-/**
- * Tables
- */
-table {
- width: 100%;
- font-size: 14px;
- table-layout: auto !important;
-}
-
-td {
- height: 36px;
- vertical-align: middle;
- color: white;
-}
-
-.td-50, .td-100 {
- width: 0px;
- margin: 0px;
- white-space: nowrap;
-}
-.td-50 { padding-right: 50px !important; }
-.td-100 { padding-right: 100px !important; }
-
-/**
- * Generic tables
- */
-
-/* Concerne toutes les classes commencant par table-generic- */
-[class^="table-generic"] {
- border-collapse: separate !important;
- border-spacing: 0 4px;
- table-layout: auto !important;
- word-break: break-word;
-}
-[class^="table-generic"] tr td {
- padding-left: 8px;
- padding-right: 8px;
-}
-[class^="table-generic"] tr td:first-child {
- border-top-left-radius: 2px;
- border-bottom-left-radius: 2px;
-}
-[class^="table-generic"] tr td:last-child {
- border-top-right-radius: 2px;
- border-bottom-right-radius: 2px;
-}
-[class^="table-generic"] thead tr {
- background: none !important;
- font-weight: bold;
-}
-.table-generic-blue tr { background-color: #22384F; }
-.table-generic-red tr { background-color: #F32F63; }
-
/**
* Generic table with pagination
*/
@@ -381,7 +334,7 @@ td {
width: 100%;
padding: 20px;
background-color: #182b3e;
- border-left: 1px solid rgb(60 70 75 / 34%);
+ border-left: 1px solid rgb(117 128 134 / 34%);
box-shadow: rgb(0 0 0) 0px 10px 13px -12px, rgb(0 0 0 / 15%) 0px 0px 10px 2px;
overflow: auto;
}
@@ -438,12 +391,16 @@ td {
.grid-rfr-1-2{grid-template-columns:repeat(2, 1fr)!important}
.grid-rfr-1-3{grid-template-columns:repeat(3, 1fr)!important}
.grid-rfr-1-4{grid-template-columns:repeat(4, 1fr)!important}
+ .grid-rfr-1-5{grid-template-columns:repeat(5, 1fr)!important}
.grid-rfr-1-6{grid-template-columns:repeat(6, 1fr)!important}
.grid-rfr-2-4{grid-template-columns:repeat(4, 1fr)!important}
+ .flexrc{flex-direction:row!important;}
+ .hide-mobile{display:initial!important;}
+ .d-align-item-center{align-items:center!important;}
.table-container-3{grid-template-columns: fit-content(10%) auto 10%!important}
.slide-panel {
- width: 30%;
+ width: 40%;
}
#loading {
diff --git a/www/public/resources/styles/components/button.css b/www/public/resources/styles/components/button.css
index c1b322aa..368443e0 100644
--- a/www/public/resources/styles/components/button.css
+++ b/www/public/resources/styles/components/button.css
@@ -19,7 +19,6 @@
.btn-auto-tr {
background-color: initial;
box-shadow: rgb(0 0 0) 0px 10px 13px -12px, rgb(0 0 0 / 15%) 0px 0px 10px 2px;
- /* border: 1px solid #24405c; */
border: 1px solid #ffffff30;
}
@@ -141,10 +140,10 @@
transition: max-width 0.7s;
margin: 0 3px 0 3px;
color: white;
- background-color: #15bf7f;
+ /* background-color: #15bf7f; */
border-radius: 60px;
cursor: pointer;
- box-shadow: rgba(12, 18, 20, 0.504) 0px 0px 10px 1px;
+ /* box-shadow: rgba(12, 18, 20, 0.504) 0px 0px 10px 1px; */
}
[class^="slide-btn"] img {
width: 20px;
@@ -303,7 +302,7 @@
/* Desktop configuration */
@media (min-width:1025px) {
/* Slide btns */
- [class^="slide-btn"]:hover {
- max-width: 300px;
+ [class^="slide-btn"] {
+ max-width: 100%;
}
}
diff --git a/www/public/resources/styles/components/echart.css b/www/public/resources/styles/components/echart.css
index ae37e860..395d7281 100644
--- a/www/public/resources/styles/components/echart.css
+++ b/www/public/resources/styles/components/echart.css
@@ -1,8 +1,27 @@
.echart-container {
- height: 250px;
+ position: relative;
+ overflow: hidden;
+ box-sizing: border-box;
}
.echart {
+ position: relative;
width: 100%;
+ max-height: 100%;
+ max-width: 100%;
+ min-height: 250px;
+ box-sizing: border-box;
+}
+
+.echart-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
height: 100%;
-}
\ No newline at end of file
+ position: absolute;
+ top: 0;
+ left: 50%;
+ -webkit-transform: translateX(-50%);
+ -moz-transform: translateX(-50%);
+ transform: translateX(-50%);
+}
diff --git a/www/public/resources/styles/components/hide.css b/www/public/resources/styles/components/hide.css
new file mode 100644
index 00000000..b439817e
--- /dev/null
+++ b/www/public/resources/styles/components/hide.css
@@ -0,0 +1,3 @@
+.hide {
+ display: none;
+}
\ No newline at end of file
diff --git a/www/public/resources/styles/motionui.css b/www/public/resources/styles/motionui.css
index dfe26471..ecb58d5d 100644
--- a/www/public/resources/styles/motionui.css
+++ b/www/public/resources/styles/motionui.css
@@ -217,6 +217,7 @@ h3 {
#motion-events-captures-acquit-container .acquit-events-btn {
min-width: 50px;
height: 50px !important;
+ background-color: #15bf7f;
}
#motion-events-captures-acquit-container .acquit-events-btn img {
@@ -476,7 +477,6 @@ h3 {
}
.camera-controls-container, #timelapse-controls-container {
width: 100%;
- display: flex;
column-gap: 10px;
position: absolute;
top: 0;
@@ -484,10 +484,9 @@ h3 {
box-sizing: border-box;
padding: 10px 10px 0px 10px;
}
-.camera-controls-container {
- display: grid;
-}
+
#timelapse-controls-container {
+ display: flex;
justify-content: end;
}
.camera-controls-container .round-btn-tr, #timelapse .round-btn-tr {
@@ -543,7 +542,7 @@ h3 {
}
.video-container {
- display: flex;
+ /* display: flex; */
justify-content: center;
height: 100%;
}
@@ -633,10 +632,6 @@ video, video-stream {
margin: auto;
}
-.hide {
- display: none;
-}
-
footer {
display: flex;
flex-direction: column;
@@ -765,18 +760,6 @@ footer #github img { width: 25px; }
z-index: 90;
}
-.body-veil img:first-child {
- width: 120px;
- height: 120px;
- opacity: 0.1;
-}
-
-.body-veil img:last-child {
- width: 20px;
- height: 20px;
- opacity: 0.5;
-}
-
/* Desktop configuration */
@media (min-width:1025px) {
h3 {
diff --git a/www/version b/www/version
index 11946a9f..ceda194c 100644
--- a/www/version
+++ b/www/version
@@ -1 +1 @@
-6.10.0
\ No newline at end of file
+6.11.0
\ No newline at end of file
diff --git a/www/views/includes/camera/edit/form.inc.php b/www/views/includes/camera/edit/form.inc.php
index 033d3415..de2f400f 100644
--- a/www/views/includes/camera/edit/form.inc.php
+++ b/www/views/includes/camera/edit/form.inc.php
@@ -6,15 +6,15 @@