GeoLite/view_dashboard.php

2527 lines
109 KiB
PHP
Raw Blame History

<?php
// Include required files
require_once 'incl/const.php';
require_once 'incl/Auth.php';
require_once 'incl/Config.php';
require_once 'incl/Database.php';
// Require authentication
//requireAuth();
// Get dashboard ID
$id = isset($_GET['id']) ? intval($_GET['id']) : 0;
if ($id === 0) {
http_response_code(404);
die('Bad request! Dashboard ID is required.');
}
// Check view permission
if (!canView('dashboard', $id)) {
header('Location: index.php?error=access_denied');
exit;
}
try {
$dashboard = getDashboardById($id);
if (!$dashboard) {
http_response_code(404);
die('Dashboard not found!');
}
} catch (Exception $e) {
http_response_code(500);
die('Error loading dashboard.');
}
$dashboardConfig = json_decode($dashboard['config'], true);
$geoServerConfig = getGeoServerConfig();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($dashboard['title']); ?> - GeoLite Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
:root {
--bg: #f6f7fb;
--panel: #fff;
--muted: #6b7280;
--text: #1f2937;
--accent: #2563eb;
--shadow: 0 10px 24px rgba(0,0,0,.08);
--radius: 14px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial;
}
body.dark-mode {
background: #2d2d2d;
color: #e2e8f0;
}
.topbar {
height: 50px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #fff;
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 10;
}
body.dark-mode .topbar {
background: #2d2d2d;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.6);
}
.btn {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 6px 12px;
background: #fff;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
color: #333;
}
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn:hover {
background: #f3f4f6;
}
.btn-primary:hover {
background: #1d4ed8;
color: #fff;
}
body.dark-mode .btn {
background: #1f2937;
border-color: #334155;
color: #e2e8f0;
}
body.dark-mode .btn:hover {
background: #334155;
color: #f8fafc;
}
body.dark-mode .btn-primary {
background: #3b82f6;
border-color: #3b82f6;
color: #f8fafc;
}
body.dark-mode .btn-primary:hover {
background: #60a5fa;
border-color: #60a5fa;
color: #0f172a;
}
body.dark-mode .topbar strong {
color: #f8fafc !important;
}
.wrap {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
padding: 16px;
}
.canvas {
position: relative;
min-height: calc(100vh - 88px);
border-radius: 12px;
}
.item {
position: absolute;
background: #fff;
border-radius: 0px;
box-shadow: 0 1px 2px #00000020, 0 1px 1px #0000001a;
overflow: hidden;
border-top: 2px solid #e5e7eb;
}
.item[data-kind="map"] {
overflow: visible;
}
body.dark-mode .wrap {
background-color: transparent;
}
body.dark-mode .canvas {
background-color: transparent;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid #eef0f4;
}
body.dark-mode .card-header {
background: #2d2d2d;
border-bottom: 1px solid #2d2d2d;
}
.title {
font-weight: 600;
padding: 2px 4px;
border-radius: 4px;
font-size: 20px;
color: #333;
}
body.dark-mode .title {
color: #f8fafc;
}
.tools {
display: flex;
gap: 4px;
align-items: center;
}
.tbtn {
border: none;
background: #f3f4f6;
width: 30px;
height: 30px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #6b7280;
transition: all 0.2s;
}
body.dark-mode .tbtn {
background: #2d2d2d;
color: #e2e8f0;
}
.tbtn:hover {
background: #e5e7eb;
color: #374151;
}
body.dark-mode .tbtn:hover {
background: #2d2d2d;
color: #f8fafc;
}
.tbtn.maximize {
position: relative;
}
.tbtn.maximize::before {
content: '⛶';
font-size: 16px;
}
.tbtn.maximize.maximized::before {
content: '⛷';
font-size: 16px;
}
.item.maximized {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
border-radius: 0;
}
.item.maximized .card-header {
background: #fff;
border-bottom: 1px solid #eef0f4;
}
.body {
height: calc(100% - 40px);
overflow: auto;
}
body.dark-mode .body {
color: #e2e8f0;
}
.item[data-kind="map"] .body {
overflow: visible;
}
.item[data-kind="map"] .pad { padding: 0; height: 100%; }
.pad {
padding: 12px;
}
body.dark-mode .item {
background: #2d2d2d;
border-top-color: #2d2d2d;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.45), 0 1px 0 rgba(148, 163, 184, 0.05);
color: #e2e8f0;
}
body.dark-mode .item.maximized {
background: #0f172a;
}
body.dark-mode .pad {
color: inherit;
}
.dashboard-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: transparent;
color: inherit;
}
.dashboard-table thead tr {
background-color: #f8f9fa;
color: #1f2937;
}
.dashboard-table thead tr.dashboard-table-meta {
background-color: #e9ecef;
color: #6c757d;
font-size: 11px;
}
.dashboard-table th,
.dashboard-table td {
padding: 6px;
border: 1px solid #dee2e6;
text-align: left;
vertical-align: top;
}
.dashboard-table .dashboard-table-icon-cell {
width: 40px;
text-align: center;
}
.dashboard-table tbody tr:nth-child(odd) {
background-color: #ffffff;
}
.dashboard-table tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
.dashboard-table tbody tr:hover {
background-color: rgba(37, 99, 235, 0.08);
}
body.dark-mode .dashboard-table th,
body.dark-mode .dashboard-table td {
border-color: #404040;
}
body.dark-mode .dashboard-table thead tr {
background-color: #2d2d2d !important;
color: #f8fafc !important;
}
body.dark-mode .dashboard-table thead tr.dashboard-table-meta {
background-color: #2d2d2d !important;
color: #94a3b8 !important;
}
body.dark-mode .dashboard-table tbody tr:nth-child(odd) {
background-color: #2d2d2d;
}
body.dark-mode .dashboard-table tbody tr:nth-child(even) {
background-color: #2d2d2d;
}
body.dark-mode .dashboard-table tbody tr:hover {
background-color: #2d2d2d;
}
.counter-widget {
text-align: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
box-sizing: border-box;
overflow: hidden;
}
.counter-value {
font-size: 32px;
font-weight: bold;
color: var(--accent);
text-align: center;
}
.counter-label {
margin-top: 10px;
color: #6b7280;
text-align: center;
}
.counter-description {
font-size: 12px;
margin-top: 5px;
color: #4b5563;
text-align: center;
}
body.dark-mode .counter-widget {
background: #2d2d2d;
border-radius: 12px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
body.dark-mode .counter-value {
color: #ffffff!important;
}
body.dark-mode .counter-label {
color: #cbd5f5;
}
body.dark-mode .counter-description {
color: #a1a1aa;
}
body.dark-mode .counter-label {
color: #94a3b8;
}
body.dark-mode .counter-description {
color: #64748b;
}
.leaflet-container {
height: 100%;
width: 100%;
}
.leaflet-popup-content {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial;
line-height: 1.4;
}
.leaflet-popup-content-wrapper {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.leaflet-popup-tip {
background: #fff;
border: 1px solid #e5e7eb;
}
/* Magnifying glass styles */
.magnifying-glass {
cursor: pointer;
transition: all 0.2s ease;
font-size: 16px;
color: #6b7280;
}
body.dark-mode .magnifying-glass {
color: #cbd5f5;
}
.magnifying-glass:hover {
color: #2563eb;
transform: scale(1.1);
}
body.dark-mode .magnifying-glass:hover {
color: #60a5fa;
}
.feature-highlight-marker {
z-index: 1000;
}
/* Feature popup styles */
.feature-popup, .multi-feature-popup {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial;
min-width: 200px;
max-width: 300px;
}
.popup-header {
background: var(--accent);
color: white;
padding: 8px 12px;
margin: -10px -10px 10px -10px;
border-radius: 8px 8px 0 0;
}
.popup-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.popup-content {
padding: 0;
}
.popup-row {
padding: 4px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.popup-row:last-child {
border-bottom: none;
}
.popup-row strong {
color: #333;
}
.popup-footer {
margin-top: 10px;
text-align: center;
}
.popup-btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.popup-btn:hover {
background: #1d4ed8;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.feature-table-modal {
background: white;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
max-width: 80%;
max-height: 80%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
background: var(--accent);
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.modal-content {
padding: 16px;
overflow: auto;
flex: 1;
}
.feature-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.feature-table th {
background: #f8f9fa;
padding: 8px;
text-align: left;
border: 1px solid #dee2e6;
font-weight: 600;
}
.feature-table td {
padding: 8px;
border: 1px solid #dee2e6;
vertical-align: top;
}
.feature-table tr:nth-child(even) {
background: #f8f9fa;
}
/* allow the toolbar to live outside the plot box */
.js-plotly-plot { position: relative; overflow: visible !important; }
/* move it up/left, keep it above the header, keep it visible */
.js-plotly-plot .modebar,
.js-plotly-plot .modebar-container {
position: absolute !important;
left: 40px !important;
top: 10px !important; /* anchor at the top */
transform: translateY(-26px) !important;/* lift into the header area */
z-index: 99999 !important;
opacity: 1 !important; /* <-- stop hover fade-out */
pointer-events: auto;
}
/* if your header is covering it, keep header below the toolbar */
.card-header { position: relative; z-index: 1; }
/* Ensure the Plotly container can show the toolbar cleanly */
.js-plotly-plot,
.js-plotly-plot .plotly {
position: relative;
overflow: visible !important;
}
/* Move toolbar to top-right, aligned just below header line */
.js-plotly-plot .modebar,
.js-plotly-plot .modebar-container {
position: absolute !important;
top: -18px !important; /* lifts into header space */
right: 10px !important; /* keep near chart edge */
left: auto !important;
transform: none !important;
z-index: 1000 !important;
opacity: 1 !important; /* prevent fade-out */
pointer-events: auto;
}
/* Make sure card body doesn't clip it */
.card-body {
position: relative;
overflow: visible !important;
}
/* Keep header flat, not blocking it */
.card-header {
position: relative;
z-index: 1;
overflow: visible !important;
}
.body {
height: calc(100% - 40px);
overflow: auto;
}
/* Center the title text in card header while keeping tool buttons on right
.card-header {
display: flex;
align-items: center;
justify-content: center; /* centers everything */
position: relative;
text-align: center;
}
.card-header .title {
flex: 1;
text-align: center;
margin: 0;
}
.card-header .tools {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
}
*/
</style>
</head>
<body>
<div class="topbar">
<div>
<strong style="color: #333;"><?php echo htmlspecialchars($dashboard['title']); ?></strong>
<?php if (!empty($dashboard['description'])): ?>
<!-- <br><span style="font-size: 12px; color: #333;"><?php echo htmlspecialchars($dashboard['description']); ?></span> -->
<?php endif; ?>
</div>
<div style="display: flex; gap: 8px;">
<button class="btn btn-outline-secondary theme-toggle" id="dashboardThemeToggle" data-theme-toggle style="display: inline-flex; align-items: center; gap: 0.4rem;">
<i class="bi bi-moon-stars theme-toggle-icon"></i>
<span class="theme-toggle-label">Dark</span>
</button>
<a href="index.php" class="btn">
<i class="bi bi-arrow-left"></i> Back
</a>
<a href="dashboard_builder.php?id=<?php echo $id; ?>" class="btn">
<i class="bi bi-pencil"></i> Edit
</a>
<button class="btn btn-primary" id="exportPdfBtn" title="Export Dashboard to PDF">
<i class="bi bi-file-pdf"></i> Export PDF
</button>
</div>
</div>
<div class="wrap">
<main class="canvas" id="canvas"></main>
</div>
<script src="assets/js/theme-toggle.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<script>
// Configuration from PHP
const DASHBOARD_EDITOR = false;
const DASHBOARD_ID = <?php echo $id; ?>;
const DASHBOARD_CONFIG = <?php echo json_encode($dashboardConfig); ?>;
const canvas = document.getElementById('canvas');
// Build filter map from all map widgets
const layerFilters = {};
if (DASHBOARD_CONFIG && DASHBOARD_CONFIG.items) {
DASHBOARD_CONFIG.items.forEach(item => {
if (item.kind === 'map' && item.config.filters) {
Object.keys(item.config.filters).forEach(layerId => {
layerFilters[layerId] = item.config.filters[layerId];
});
}
});
}
// Function to build CQL filter for data fetching
function buildCqlFilterForData(filters, layerId) {
if (!filters || !filters[layerId]) return '';
const conditions = filters[layerId];
const filterParts = [];
conditions.forEach((filter, idx) => {
if (!filter.attribute || !filter.value) return;
const attribute = filter.attribute;
const operator = filter.operator || '=';
const value = filter.value;
let condition = '';
switch (operator) {
case '=':
condition = attribute + ' = \'' + value.replace(/'/g, "''") + '\'';
break;
case '!=':
condition = attribute + ' != \'' + value.replace(/'/g, "''") + '\'';
break;
case '>':
case '<':
case '>=':
case '<=':
condition = attribute + ' ' + operator + ' ' + value;
break;
case 'LIKE':
condition = attribute + ' LIKE \'%' + value.replace(/'/g, "''") + '%\'';
break;
default:
condition = attribute + ' = \'' + value.replace(/'/g, "''") + '\'';
}
if (idx > 0 && filter.logic) {
filterParts.push(filter.logic + ' ' + condition);
} else {
filterParts.push(condition);
}
});
return filterParts.join(' ');
}
// Function to fetch data from GeoServer WFS via proxy
async function fetchLayerData(layerName, limit = 100) {
try {
const proxyUrl = 'geoserver_proxy.php?dash_id=' + DASHBOARD_ID;
let wfsUrl = `${proxyUrl}&service=WFS&version=1.0.0&request=GetFeature&typeName=${layerName}&outputFormat=application/json&maxFeatures=${limit}`;
// Apply CQL filter if configured for this layer in any map widget
if (layerFilters[layerName]) {
const cqlFilter = buildCqlFilterForData(layerFilters, layerName);
if (cqlFilter) {
wfsUrl += `&CQL_FILTER=${encodeURIComponent(cqlFilter)}`;
console.log('Applying filter when fetching data for', layerName + ':', cqlFilter);
}
}
const response = await fetch(wfsUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching layer data:', error);
return null;
}
}
// Function to format numbers with comma separators
function formatNumber(value) {
if (typeof value === 'string') {
// If it's a string (like "Error"), return as is
return value;
}
if (value == null || isNaN(value)) {
return '0';
}
// Use toLocaleString to add comma separators
return Number(value).toLocaleString();
}
// Function to get color palette based on scheme
function getColorPalette(scheme) {
const palettes = {
default: ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'],
viridis: ['#440154', '#31688e', '#35b779', '#fde724', '#440154', '#31688e', '#35b779', '#fde724', '#440154', '#31688e'],
warm: ['#d62728', '#ff7f0e', '#ffbb78', '#ff9896', '#c49c94', '#f7b6d2', '#d62728', '#ff7f0e', '#ffbb78', '#ff9896'],
cool: ['#1f77b4', '#aec7e8', '#17becf', '#9edae5', '#7fcdbb', '#1f77b4', '#aec7e8', '#17becf', '#9edae5', '#7fcdbb'],
earth: ['#8c564b', '#c49c94', '#bcbd22', '#dbdb8d', '#9467bd', '#8c564b', '#c49c94', '#bcbd22', '#dbdb8d', '#9467bd']
};
return palettes[scheme] || palettes.default;
}
// Function to process layer data for charts
function processDataForChart(data, xField, yField, aggregation = 'count') {
if (!data || !data.features) return null;
const groups = new Map();
data.features.forEach(feature => {
const xValue = feature.properties?.[xField] ?? 'Unknown';
const yRaw = feature.properties?.[yField];
const yNum = parseFloat(yRaw);
const isNum = !isNaN(yNum);
if (!groups.has(xValue)) {
groups.set(xValue, { count: 0, sum: 0, min: isNum ? yNum : Infinity, max: isNum ? yNum : -Infinity });
}
const g = groups.get(xValue);
g.count += 1;
if (isNum) {
g.sum += yNum;
if (yNum < g.min) g.min = yNum;
if (yNum > g.max) g.max = yNum;
}
});
const x = [];
const y = [];
groups.forEach((g, key) => {
x.push(key);
let val = 0;
switch (aggregation) {
case 'sum': val = g.sum; break;
case 'avg': val = g.count ? (g.sum / g.count) : 0; break;
case 'min': val = (g.min === Infinity) ? 0 : g.min; break;
case 'max': val = (g.max === -Infinity) ? 0 : g.max; break;
case 'count':
default: val = g.count; break;
}
y.push(val);
});
return { x, y };
}
// Function to get feature count
function getFeatureCount(data) {
return data && data.features ? data.features.length : 0;
}
// Function to get bounding box of a geometry
function getGeometryBounds(geometry) {
let minLng = Infinity, minLat = Infinity;
let maxLng = -Infinity, maxLat = -Infinity;
function processCoordinate(coord) {
if (typeof coord[0] === 'number' && typeof coord[1] === 'number') {
minLng = Math.min(minLng, coord[0]);
maxLng = Math.max(maxLng, coord[0]);
minLat = Math.min(minLat, coord[1]);
maxLat = Math.max(maxLat, coord[1]);
} else if (Array.isArray(coord)) {
coord.forEach(processCoordinate);
}
}
processCoordinate(geometry.coordinates);
return {
minLng, minLat, maxLng, maxLat
};
}
// Function to check if two bounding boxes intersect
function boundsIntersect(bounds1, bounds2) {
return !(bounds1.maxLng < bounds2.minLng ||
bounds1.minLng > bounds2.maxLng ||
bounds1.maxLat < bounds2.minLat ||
bounds1.minLat > bounds2.maxLat);
}
// Function to filter features by map bounds
function filterFeaturesByBounds(features, mapBounds) {
if (!mapBounds || !features) return features;
const viewBounds = {
minLng: mapBounds._southWest.lng,
minLat: mapBounds._southWest.lat,
maxLng: mapBounds._northEast.lng,
maxLat: mapBounds._northEast.lat
};
return features.filter(feature => {
if (!feature.geometry || !feature.geometry.coordinates) return false;
try {
const featureBounds = getGeometryBounds(feature.geometry);
return boundsIntersect(featureBounds, viewBounds);
} catch (error) {
console.warn('Error filtering feature:', error);
return true; // Include feature if we can't determine bounds
}
});
}
// Function to update widgets based on map bounds
function updateWidgetsForMapBounds(mapBounds) {
// Find all widgets that have layer configurations
if (DASHBOARD_CONFIG && DASHBOARD_CONFIG.items) {
DASHBOARD_CONFIG.items.forEach(item => {
if (item.config.layer && (item.kind === 'chart' || item.kind === 'table' || item.kind === 'counter')) {
updateWidgetForBounds(item, mapBounds);
}
});
}
}
// Function to update a single widget based on map bounds
async function updateWidgetForBounds(item, mapBounds) {
try {
const data = await fetchLayerData(item.config.layer, 1000);
if (!data || !data.features) return;
// Filter features by current map bounds
const filteredFeatures = filterFeaturesByBounds(data.features, mapBounds);
// Update the widget with filtered data
updateWidgetContent(item, filteredFeatures);
} catch (error) {
console.error('Error updating widget for bounds:', error);
}
}
// Function to update widget content with filtered data
function updateWidgetContent(item, features) {
const itemDiv = document.querySelector(`[data-id="${item.id}"]`);
if (!itemDiv) return;
const container = itemDiv.querySelector('.pad');
if (!container) return;
switch (item.kind) {
case 'chart':
updateChartContent(item, features, container);
break;
case 'table':
updateTableContent(item, features, container);
break;
case 'counter':
updateCounterContent(item, features, container);
break;
}
}
function getChartThemeOptions() {
const isDark = document.body.classList.contains('dark-mode');
return {
isDark,
textColor: isDark ? '#e2e8f0' : '#2d2d2d',
subtleText: isDark ? '#94a3b8' : '#6b7280',
gridColor: isDark ? 'rgba(148, 163, 184, 0.25)' : '#e5e5e5',
axisColor: isDark ? '#475569' : '#6b7280',
plotBackground: 'rgba(0,0,0,0)'
};
}
// Function to update chart content with filtered features
function updateChartContent(item, features, container) {
// Always ensure chart div exists
let chartDiv = container.querySelector('[id^="chart-"]');
if (!chartDiv) {
// Recreate the chart div if it was removed
container.innerHTML = '';
chartDiv = document.createElement('div');
chartDiv.id = 'chart-' + item.id;
chartDiv.style.width = '100%';
chartDiv.style.height = '100%';
chartDiv.style.minHeight = '200px';
container.appendChild(chartDiv);
}
if (!features || features.length === 0) {
chartDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">No features in current view</div>';
return;
}
try {
const properties = features[0].properties;
const propNames = Object.keys(properties).filter(prop =>
typeof properties[prop] === 'string' || typeof properties[prop] === 'number'
);
const xField = item.config.xField || propNames[0] || 'id';
const yField = item.config.yField || propNames[1] || propNames[0] || 'id';
const chartData = processDataForChart({features: features}, xField, yField, item.config.aggregation || 'count');
if (chartData && chartData.x.length > 0) {
// Clear any "no data" message
chartDiv.innerHTML = '';
const colorPalette = getColorPalette(item.config.colorScheme || 'default');
const chartType = item.config.type || 'bar';
// Handle pie charts differently - they use 'labels' and 'values' instead of 'x' and 'y'
let plotData;
if (chartType === 'pie') {
plotData = [{
labels: chartData.x.slice(0, 10),
values: chartData.y.slice(0, 10),
type: 'pie',
marker: {
colors: colorPalette
}
}];
} else {
const primaryLabel = item.config.label || `Data from ${item.config.layer}`;
plotData = [{
x: chartData.x.slice(0, 10),
y: chartData.y.slice(0, 10),
type: chartType,
name: primaryLabel,
marker: {
color: colorPalette
},
fill: (chartType === 'area') ? 'tozeroy' : undefined
}];
// Add second series as a line if enabled and chart type supports it
if (item.config.enableSecondSeries && (chartType === 'bar' || chartType === 'area')) {
// Get second series data
let secondFeatures = features;
if (item.config.secondLayer && item.config.secondLayer !== item.config.layer) {
// Need to fetch data from different layer - we'll handle this in the initial load
secondFeatures = features; // Will be handled in initial render
}
const secondYField = item.config.secondYField;
if (secondYField && secondFeatures && secondFeatures.length > 0) {
const secondChartData = processDataForChart(
{features: secondFeatures},
xField,
secondYField,
item.config.aggregation || 'count'
);
if (secondChartData && secondChartData.y.length > 0) {
// Match x values to primary series for proper alignment
const alignedY = chartData.x.map(xVal => {
const idx = secondChartData.x.indexOf(xVal);
return idx >= 0 ? secondChartData.y[idx] : null;
});
const secondSeriesLabel = item.config.secondLabel || `Second Series`;
plotData.push({
x: chartData.x.slice(0, 10),
y: alignedY.slice(0, 10),
type: 'scatter',
mode: 'lines+markers',
name: secondSeriesLabel,
line: {
color: '#ff7f0e',
width: 2
},
marker: {
color: '#ff7f0e',
size: 6
},
yaxis: 'y2'
});
}
}
}
}
const theme = getChartThemeOptions();
const showGrid = item.config.showGrid !== false;
const layout = {
margin: {t: 20, r: 20, b: 40, l: 40},
font: {size: 12, color: theme.textColor},
paper_bgcolor: theme.plotBackground,
plot_bgcolor: theme.plotBackground,
hovermode: 'closest',
showlegend: true,
legend: {
font: { color: theme.textColor }
}
};
// Only add axis config for non-pie charts
if (chartType !== 'pie') {
const baseAxis = {
showgrid: showGrid,
gridcolor: theme.gridColor,
linecolor: theme.axisColor,
zerolinecolor: theme.gridColor,
tickfont: { color: theme.textColor },
color: theme.textColor,
title: { font: { color: theme.textColor } }
};
layout.xaxis = { ...baseAxis };
layout.yaxis = { ...baseAxis };
// Add second y-axis if second series is enabled
if (item.config.enableSecondSeries && plotData.length > 1) {
layout.yaxis2 = {
title: item.config.secondLabel || 'Second Series',
overlaying: 'y',
side: 'right',
showgrid: false,
tickfont: { color: theme.textColor },
color: theme.textColor,
linecolor: theme.axisColor
};
}
} else {
plotData.forEach(trace => {
trace.textfont = { ...(trace.textfont || {}), color: theme.textColor };
trace.insidetextfont = { ...(trace.insidetextfont || {}), color: theme.isDark ? '#0f172a' : '#ffffff' };
trace.outsidetextfont = { ...(trace.outsidetextfont || {}), color: theme.textColor };
});
}
const config = {
responsive: true,
displayModeBar: true,
displaylogo: false,
modeBarButtonsToAdd: ['hoverclosest', 'hovercompare'],
modeBarButtonsToRemove: [],
toImageButtonOptions: {
format: 'png',
filename: 'chart_export',
height: 800,
width: 1200,
scale: 2
}
};
Plotly.newPlot(chartDiv.id, plotData, layout, config);
} else {
chartDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">No data available for chart</div>';
}
} catch (error) {
console.error('Error updating chart:', error);
}
}
// Function to update table content with filtered features
function updateTableContent(item, features, container) {
if (!features || features.length === 0) {
container.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">No features in current view</div>';
return;
}
try {
const properties = features[0].properties;
const propNames = Object.keys(properties);
// Use configured columns if available, otherwise use first 5 columns
const displayProps = item.config.columns && item.config.columns.length > 0
? item.config.columns.filter(col => propNames.includes(col))
: propNames.slice(0, 5);
let tableHTML = `
<table class="dashboard-table">
<thead>
<tr>
<th class="dashboard-table-icon-cell">
<span class="magnifying-glass" aria-label="Zoom to row">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
</th>
`;
displayProps.forEach(prop => {
tableHTML += `<th>${prop}</th>`;
});
tableHTML += `
</tr>
<tr class="dashboard-table-meta">
<td colspan="${displayProps.length + 1}">Features in view: ${features.length}</td>
</tr>
</thead>
<tbody>
`;
features.forEach((feature, index) => {
const rowClass = index % 2 === 0 ? 'dashboard-table-row' : 'dashboard-table-row dashboard-table-row-alt';
const featureData = JSON.stringify(feature).replace(/"/g, '&quot;');
tableHTML += `<tr class="${rowClass}">`;
tableHTML += `
<td class="dashboard-table-icon-cell" onclick="zoomToFeature(${featureData}, '${item.config.layer}')" title="Zoom to feature on map">
<span class="magnifying-glass" aria-label="Zoom to row">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
</td>
`;
displayProps.forEach(prop => {
const value = feature.properties[prop] || '';
const displayValue = typeof value === 'string' && value.length > 20
? value.substring(0, 20) + '...'
: value;
tableHTML += `<td>${displayValue}</td>`;
});
tableHTML += '</tr>';
});
tableHTML += `
</tbody>
</table>
`;
container.innerHTML = tableHTML;
} catch (error) {
console.error('Error updating table:', error);
}
}
// Function to update counter content with filtered features
function updateCounterContent(item, features, container) {
// Find the counter div inside the pad container
const counter = container.querySelector('.counter-widget') || container.firstElementChild;
if (!counter) {
container.innerHTML = '<div style="color: #999; text-align: center;">No features in current view</div>';
return;
}
const valueDiv = counter.querySelector('.counter-value') || counter.querySelector('div:nth-child(1)');
const labelDiv = counter.querySelector('.counter-label') || counter.querySelector('div:nth-child(2)');
const descDiv = counter.querySelector('.counter-description') || counter.querySelector('div:nth-child(3)');
if (!features || features.length === 0) {
if (valueDiv && labelDiv && descDiv) {
valueDiv.textContent = '0';
labelDiv.textContent = 'No features';
descDiv.textContent = '';
} else {
container.innerHTML = '<div style="color: #999; text-align: center;">No features in current view</div>';
}
return;
}
try {
let value = 0;
const count = features.length;
if (item.config.operation === 'count') {
value = count;
} else if (item.config.operation === 'sum' && item.config.field) {
value = features.reduce((sum, feature) => {
const val = parseFloat(feature.properties[item.config.field]) || 0;
return sum + val;
}, 0);
} else if (item.config.operation === 'avg' && item.config.field) {
const sum = features.reduce((sum, feature) => {
const val = parseFloat(feature.properties[item.config.field]) || 0;
return sum + val;
}, 0);
value = count > 0 ? (sum / count).toFixed(1) : 0;
} else if (item.config.operation === 'min' && item.config.field) {
const values = features.map(feature => parseFloat(feature.properties[item.config.field]) || 0);
value = values.length > 0 ? Math.min(...values) : 0;
} else if (item.config.operation === 'max' && item.config.field) {
const values = features.map(feature => parseFloat(feature.properties[item.config.field]) || 0);
value = values.length > 0 ? Math.max(...values) : 0;
} else {
value = count;
}
const label = item.config.operation === 'count' ? 'Features' :
// item.config.operation === 'sum' ? `Sum of ${item.config.field}` :
item.config.operation === 'avg' ? `Avg of ${item.config.field}` :
item.config.operation === 'min' ? `Min of ${item.config.field}` :
item.config.operation === 'max' ? `Max of ${item.config.field}` : '';
if (valueDiv && labelDiv && descDiv) {
valueDiv.textContent = formatNumber(value);
labelDiv.textContent = label;
// descDiv.textContent = `in current view (${count} features)`;
} else {
container.innerHTML = `
<div class="counter-widget">
<div class="counter-value">${formatNumber(value)}</div>
<div class="counter-label">${label}</div>
<div class="counter-description"></div>
</div>
`;
}
} catch (error) {
console.error('Error updating counter:', error);
}
}
// Function to handle map clicks and show popups
async function handleMapClick(e, map, layers) {
if (!layers || layers.length === 0) {
console.log('No layers configured for popups');
return;
}
const lat = e.latlng.lat;
const lng = e.latlng.lng;
try {
// Query each layer for features at the clicked point
const allFeatures = [];
for (const layerId of layers) {
try {
const features = await queryFeaturesAtPoint(layerId, lat, lng);
if (features && features.length > 0) {
allFeatures.push(...features.map(f => ({
...f,
layerName: layerId
})));
}
} catch (error) {
console.warn(`Error querying layer ${layerId}:`, error);
}
}
if (allFeatures.length > 0) {
showFeaturePopup(allFeatures, lat, lng, map);
} else {
// Show a simple popup indicating no features found
L.popup()
.setLatLng([lat, lng])
.setContent('<div style="padding: 10px; text-align: center; color: #666;">No features found at this location</div>')
.openOn(map);
}
} catch (error) {
console.error('Error handling map click:', error);
}
}
// Function to query features at a specific point using WFS
async function queryFeaturesAtPoint(layerId, lat, lng) {
try {
const proxyUrl = 'geoserver_proxy.php?dash_id=' + DASHBOARD_ID;
const bbox = `${lng-0.001},${lat-0.001},${lng+0.001},${lat+0.001}`;
const wfsUrl = `${proxyUrl}&service=WFS&version=1.0.0&request=GetFeature&typeName=${layerId}&outputFormat=application/json&bbox=${bbox}&srsName=EPSG:4326`;
const response = await fetch(wfsUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data.features || [];
} catch (error) {
console.error('Error querying features at point:', error);
return [];
}
}
// Function to show feature popup
function showFeaturePopup(features, lat, lng, map) {
if (features.length === 1) {
// Single feature - show simple popup
const feature = features[0];
const popupContent = generatePopupContent(feature);
L.popup()
.setLatLng([lat, lng])
.setContent(popupContent)
.openOn(map);
} else {
// Multiple features - show navigation popup
const popupContent = generateMultiFeaturePopupContent(features, lat, lng);
L.popup()
.setLatLng([lat, lng])
.setContent(popupContent)
.openOn(map);
}
}
// Function to generate popup content for a single feature
function generatePopupContent(feature) {
const props = feature.properties || {};
const layerName = feature.layerName || 'Unknown Layer';
let html = `<div class="feature-popup">`;
html += `<div class="popup-header">`;
html += `<h4>${layerName}</h4>`;
html += `</div>`;
html += `<div class="popup-content">`;
// Show first 5 properties
const propKeys = Object.keys(props).slice(0, 5);
propKeys.forEach(key => {
const value = props[key];
const displayValue = typeof value === 'string' && value.length > 50
? value.substring(0, 50) + '...'
: value;
html += `<div class="popup-row">`;
html += `<strong>${key}:</strong> ${displayValue}`;
html += `</div>`;
});
if (Object.keys(props).length > 5) {
html += `<div class="popup-row">`;
html += `<em>... and ${Object.keys(props).length - 5} more properties</em>`;
html += `</div>`;
}
html += `</div>`;
html += `<div class="popup-footer">`;
html += `<button class="popup-btn" onclick="showFeatureTable(${JSON.stringify(feature).replace(/"/g, '&quot;')})">View Details</button>`;
html += `</div>`;
html += `</div>`;
return html;
}
// Function to generate popup content for multiple features
function generateMultiFeaturePopupContent(features, lat, lng) {
const layerCounts = {};
features.forEach(f => {
const layerName = f.layerName || 'Unknown';
layerCounts[layerName] = (layerCounts[layerName] || 0) + 1;
});
let html = `<div class="multi-feature-popup">`;
html += `<div class="popup-header">`;
html += `<h4>Multiple Features Found</h4>`;
html += `</div>`;
html += `<div class="popup-content">`;
Object.entries(layerCounts).forEach(([layerName, count]) => {
html += `<div class="popup-row">`;
html += `<strong>${layerName}:</strong> ${count} feature${count > 1 ? 's' : ''}`;
html += `</div>`;
});
html += `</div>`;
html += `<div class="popup-footer">`;
html += `<button class="popup-btn" onclick="showAllFeaturesTable(${JSON.stringify(features).replace(/"/g, '&quot;')})">View All Details</button>`;
html += `</div>`;
html += `</div>`;
return html;
}
// Function to show feature details in a modal table
function showFeatureTable(feature) {
const props = feature.properties || {};
const layerName = feature.layerName || 'Unknown Layer';
let tableHTML = `
<div class="feature-table-modal">
<div class="modal-header">
<h3>Feature Details - ${layerName}</h3>
<button class="modal-close" onclick="closeFeatureTable()">&times;</button>
</div>
<div class="modal-content">
<table class="feature-table">
<thead>
<tr>
<th>Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
`;
Object.entries(props).forEach(([key, value]) => {
tableHTML += `
<tr>
<td><strong>${key}</strong></td>
<td>${value}</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
</div>
</div>
`;
// Create modal overlay
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = tableHTML;
document.body.appendChild(modal);
}
// Function to show all features in a modal table
function showAllFeaturesTable(features) {
let tableHTML = `
<div class="feature-table-modal">
<div class="modal-header">
<h3>All Features (${features.length})</h3>
<button class="modal-close" onclick="closeFeatureTable()">&times;</button>
</div>
<div class="modal-content">
<table class="feature-table">
<thead>
<tr>
<th>Layer</th>
<th>Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
`;
features.forEach(feature => {
const props = feature.properties || {};
const layerName = feature.layerName || 'Unknown';
Object.entries(props).forEach(([key, value]) => {
tableHTML += `
<tr>
<td>${layerName}</td>
<td><strong>${key}</strong></td>
<td>${value}</td>
</tr>
`;
});
});
tableHTML += `
</tbody>
</table>
</div>
</div>
`;
// Create modal overlay
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = tableHTML;
document.body.appendChild(modal);
}
// Function to close feature table modal
function closeFeatureTable() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
// Function to zoom to a specific feature on the map
function zoomToFeature(feature, layerName) {
try {
// Find all map widgets in the dashboard
const mapWidgets = DASHBOARD_CONFIG.items.filter(item => item.kind === 'map');
if (mapWidgets.length === 0) {
console.warn('No map widgets found in dashboard');
return;
}
// Use the first map widget (you could enhance this to find the specific map)
const mapWidget = mapWidgets[0];
const mapElement = document.getElementById('map-' + mapWidget.id);
if (!mapElement || !mapElement._leaflet_map) {
console.warn('Map element not found or not initialized');
return;
}
const map = mapElement._leaflet_map;
// Check if the feature has geometry
if (!feature.geometry || !feature.geometry.coordinates) {
console.warn('Feature has no geometry to zoom to');
return;
}
// Handle different geometry types
let bounds;
switch (feature.geometry.type) {
case 'Point':
// For points, zoom to the point with a reasonable zoom level
const coords = feature.geometry.coordinates;
map.setView([coords[1], coords[0]], Math.max(map.getZoom(), 15), {
animate: true,
duration: 1.0
});
break;
case 'Polygon':
case 'MultiPolygon':
case 'LineString':
case 'MultiLineString':
// For complex geometries, fit bounds
try {
const geoJsonLayer = L.geoJSON(feature);
bounds = geoJsonLayer.getBounds();
map.fitBounds(bounds, {
animate: true,
duration: 1.0,
padding: [20, 20]
});
} catch (error) {
console.error('Error creating bounds for feature:', error);
// Fallback to point zoom if bounds creation fails
const coords = feature.geometry.coordinates;
if (Array.isArray(coords) && coords.length >= 2) {
map.setView([coords[1], coords[0]], Math.max(map.getZoom(), 15), {
animate: true,
duration: 1.0
});
}
}
break;
default:
console.warn('Unsupported geometry type:', feature.geometry.type);
return;
}
// Add a temporary marker to highlight the feature
let marker;
if (feature.geometry.type === 'Point') {
const coords = feature.geometry.coordinates;
marker = L.marker([coords[1], coords[0]], {
icon: L.divIcon({
className: 'feature-highlight-marker',
html: '<div style="background-color: #ff0000; border: 2px solid #fff; border-radius: 50%; width: 20px; height: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
})
}).addTo(map);
} else {
// For non-point geometries, add a temporary highlight layer
const highlightLayer = L.geoJSON(feature, {
style: {
color: '#ff0000',
weight: 3,
opacity: 0.8,
fillOpacity: 0.2
}
}).addTo(map);
marker = highlightLayer; // Use the layer as our "marker"
}
// Remove the highlight after 3 seconds
setTimeout(() => {
if (marker) {
map.removeLayer(marker);
}
}, 3000);
} catch (error) {
console.error('Error zooming to feature:', error);
}
}
// Load and render dashboard
if (DASHBOARD_CONFIG && DASHBOARD_CONFIG.items) {
DASHBOARD_CONFIG.items.forEach(item => {
createItemElement(item);
});
}
function createItemElement(item) {
const div = document.createElement('div');
div.className = 'item';
div.dataset.id = item.id;
div.dataset.kind = item.kind;
div.style.left = item.x + 'px';
div.style.top = item.y + 'px';
div.style.width = item.w + 'px';
div.style.height = item.h + 'px';
const header = document.createElement('div');
header.className = 'card-header';
const titleSpan = document.createElement('span');
titleSpan.className = 'title';
titleSpan.textContent = item.title;
const tools = document.createElement('div');
tools.className = 'tools';
const maxBtn = document.createElement('button');
maxBtn.className = 'tbtn maximize';
maxBtn.title = 'Maximize';
maxBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleMaximize(div);
});
tools.appendChild(maxBtn);
header.appendChild(titleSpan);
header.appendChild(tools);
const body = document.createElement('div');
body.className = 'body';
const pad = document.createElement('div');
pad.className = 'pad';
// Render widget content
renderWidgetContent(item, pad);
body.appendChild(pad);
div.appendChild(header);
div.appendChild(body);
canvas.appendChild(div);
}
function renderWidgetContent(item, container) {
container.innerHTML = '';
switch (item.kind) {
case 'map':
const mapDiv = document.createElement('div');
mapDiv.id = 'map-' + item.id;
mapDiv.style.width = '100%';
mapDiv.style.height = '100%';
mapDiv.style.minHeight = '200px';
container.appendChild(mapDiv);
// Ensure the wrapper fills the item
container.style.height = '100%';
container.style.padding = '0';
// Initialize map after DOM is ready
setTimeout(() => {
try {
if (typeof L === 'undefined') {
throw new Error('Leaflet not loaded');
}
// Check if the element exists in DOM
const mapElement = document.getElementById('map-' + item.id);
if (!mapElement) {
throw new Error('Map container not found in DOM');
}
// Ensure map container is fluid and fills the widget
mapElement.style.position = 'relative';
mapElement.style.width = '100%';
mapElement.style.height = '100%';
mapElement.style.minHeight = '200px';
mapElement.style.maxWidth = 'none';
mapElement.style.maxHeight = 'none';
mapElement.style.overflow = 'hidden';
const map = L.map('map-' + item.id, {
center: item.config.center || [0, 0],
zoom: item.config.zoom || 2,
zoomControl: true,
preferCanvas: false
});
// Restore saved extent if available
const savedBounds = item.config && item.config.bounds;
const savedCenter = item.config && item.config.center;
const savedZoom = item.config && item.config.zoom;
// Force map to invalidate size multiple times
setTimeout(() => {
map.invalidateSize();
// Also trigger resize event
window.dispatchEvent(new Event('resize'));
}, 100);
setTimeout(() => {
map.invalidateSize();
}, 300);
setTimeout(() => {
map.invalidateSize();
}, 500);
// --- Base maps ---
const baseMaps = {
'OpenStreetMap': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}),
'Carto Light': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; CartoDB',
subdomains: 'abcd'
}),
'Carto Dark': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; CartoDB',
subdomains: 'abcd'
}),
'Carto Voyager': L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', {
attribution: '&copy; CartoDB',
subdomains: 'abcd'
}),
'Esri Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '&copy; Esri'
}),
'Esri Topo': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', {
attribution: '&copy; Esri'
})
};
// Use selected basemap or default to OpenStreetMap
const selectedBasemap = item.config && item.config.basemap ? item.config.basemap : 'OpenStreetMap';
if (baseMaps[selectedBasemap]) {
baseMaps[selectedBasemap].addTo(map);
}
// --- Helper function to build CQL filter ---
function buildCqlFilter(filters, layerId) {
if (!filters || !filters[layerId]) return '';
const conditions = filters[layerId];
const filterParts = [];
conditions.forEach((filter, idx) => {
if (!filter.attribute || !filter.value) return;
const attribute = filter.attribute;
const operator = filter.operator || '=';
const value = filter.value;
let condition = '';
switch (operator) {
case '=':
condition = attribute + ' = \'' + value.replace(/'/g, "''") + '\'';
break;
case '!=':
condition = attribute + ' != \'' + value.replace(/'/g, "''") + '\'';
break;
case '>':
case '<':
case '>=':
case '<=':
condition = attribute + ' ' + operator + ' ' + value;
break;
case 'LIKE':
condition = attribute + ' LIKE \'%' + value.replace(/'/g, "''") + '%\'';
break;
default:
condition = attribute + ' = \'' + value.replace(/'/g, "''") + '\'';
}
if (idx > 0 && filter.logic) {
filterParts.push(filter.logic + ' ' + condition);
} else {
filterParts.push(condition);
}
});
return filterParts.join(' ');
}
// --- Overlays (WMS layers) ---
const overlays = {};
if (item.config.layers && item.config.layers.length > 0) {
item.config.layers.forEach(layerId => {
try {
// Use proxy to avoid CORS issues with authentication
const proxyUrl = 'geoserver_proxy.php?dash_id=' + DASHBOARD_ID;
// Build WMS options
const wmsOptions = {
layers: layerId,
format: 'image/png',
transparent: true,
version: '1.1.1'
};
// Apply CQL filter if configured
const cqlFilter = buildCqlFilter(item.config.filters, layerId);
if (cqlFilter) {
wmsOptions.cql_filter = cqlFilter;
console.log('Applying filter to layer', layerId + ':', cqlFilter);
}
const wmsLayer = L.tileLayer.wms(proxyUrl, wmsOptions);
overlays[layerId] = wmsLayer;
wmsLayer.addTo(map);
} catch (error) {
console.warn('Failed to add WMS layer:', layerId, error);
}
});
}
// --- Layer selector control (only show overlays if there are any) ---
if (Object.keys(overlays).length > 0) {
L.control.layers(null, overlays, { collapsed: false, position: 'topright' }).addTo(map);
}
if (savedBounds && savedBounds.length === 2) {
try {
const b = L.latLngBounds(savedBounds[0], savedBounds[1]);
map.fitBounds(b, {animate: false});
} catch (e) { console.warn('Invalid saved bounds', savedBounds, e); }
} else if (savedCenter && typeof savedCenter[0] === 'number' && typeof savedCenter[1] === 'number' && typeof savedZoom === 'number') {
map.setView(savedCenter, savedZoom, {animate: false});
}
// Store map instance
mapDiv._leaflet_map = map;
// Add resize observer to detect container size changes
const resizeObserver = new ResizeObserver(() => {
mapElement.style.width = '100%';
mapElement.style.height = '100%';
setTimeout(() => map.invalidateSize(), 100);
});resizeObserver.observe(mapElement.parentElement);
// Add event listeners to update widgets when map moves
let updateTimeout;
map.on('moveend zoomend', function() {
// Debounce updates to avoid too many API calls
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
const bounds = map.getBounds();
updateWidgetsForMapBounds(bounds);
}, 500);
});
// Add click event handler for popups
map.on('click', function(e) {
handleMapClick(e, map, item.config.layers || []);
});
// Force initial resize after everything is loaded
setTimeout(() => {
map.invalidateSize();
// Also try to trigger a redraw
map._resetView(map.getCenter(), map.getZoom(), true);
}, 1000);
} catch (error) {
console.error('Failed to initialize map:', error);
mapDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">Failed to load map: ' + error.message + '</div>';
}
}, 100);
break;
case 'chart':
// Create a chart
const chartDiv = document.createElement('div');
chartDiv.id = 'chart-' + item.id;
chartDiv.style.width = '100%';
chartDiv.style.height = '100%';
chartDiv.style.minHeight = '200px';
container.appendChild(chartDiv);
// Initialize chart after DOM is ready
setTimeout(() => {
try {
if (typeof Plotly === 'undefined') {
throw new Error('Plotly not loaded');
}
// Check if the element exists in DOM
const chartElement = document.getElementById('chart-' + item.id);
if (!chartElement) {
throw new Error('Chart container not found in DOM');
}
// Try to fetch real data if layer is configured
if (item.config.layer) {
chartDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">Loading chart data...</div>';
fetchLayerData(item.config.layer, 100).then(data => {
// Clear loading text
chartDiv.innerHTML = '';
if (data && data.features && data.features.length > 0) {
// Get available properties from first feature
const properties = data.features[0].properties;
const propNames = Object.keys(properties).filter(prop =>
typeof properties[prop] === 'string' || typeof properties[prop] === 'number'
);
// Use first two properties as x and y if not configured
const xField = item.config.xField || propNames[0] || 'id';
const yField = item.config.yField || propNames[1] || propNames[0] || 'id';
const chartData = processDataForChart(data, xField, yField, item.config.aggregation || 'count');
if (chartData && chartData.x.length > 0) {
const colorPalette = getColorPalette(item.config.colorScheme || 'default');
const chartType = item.config.type || 'bar';
// Handle pie charts differently - they use 'labels' and 'values' instead of 'x' and 'y'
let plotData;
if (chartType === 'pie') {
plotData = [{
labels: chartData.x.slice(0, 10),
values: chartData.y.slice(0, 10),
type: 'pie',
marker: {
colors: colorPalette
}
}];
} else {
const primaryLabel = item.config.label || `Data from ${item.config.layer}`;
plotData = [{
x: chartData.x.slice(0, 10),
y: chartData.y.slice(0, 10),
type: chartType,
name: primaryLabel,
marker: {
color: colorPalette
},
fill: (chartType === 'area') ? 'tozeroy' : undefined
}];
// Add second series as a line if enabled and chart type supports it
if (item.config.enableSecondSeries && (chartType === 'bar' || chartType === 'area')) {
const secondYField = item.config.secondYField;
if (secondYField) {
const secondChartData = processDataForChart(
data,
xField,
secondYField,
item.config.aggregation || 'count'
);
if (secondChartData && secondChartData.y.length > 0) {
// Match x values to primary series for proper alignment
const alignedY = chartData.x.map(xVal => {
const idx = secondChartData.x.indexOf(xVal);
return idx >= 0 ? secondChartData.y[idx] : null;
});
const secondSeriesLabel = item.config.secondLabel || `Second Series`;
plotData.push({
x: chartData.x.slice(0, 10),
y: alignedY.slice(0, 10),
type: 'scatter',
mode: 'lines+markers',
name: secondSeriesLabel,
line: {
color: '#ff7f0e',
width: 2
},
marker: {
color: '#ff7f0e',
size: 6
},
yaxis: 'y2'
});
}
}
}
}
const showGrid = item.config.showGrid !== false;
const layout = {
margin: {t: 20, r: 20, b: 40, l: 40},
font: {size: 12},
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
hovermode: 'closest',
showlegend: true
};
// Only add axis config for non-pie charts
if (chartType !== 'pie') {
layout.xaxis = {
showgrid: showGrid,
gridcolor: '#e5e5e5'
};
layout.yaxis = {
showgrid: showGrid,
gridcolor: '#e5e5e5'
};
// Add second y-axis if second series is enabled
if (item.config.enableSecondSeries && plotData.length > 1) {
layout.yaxis2 = {
title: item.config.secondLabel || 'Second Series',
overlaying: 'y',
side: 'right',
showgrid: false
};
}
}
const config = {
responsive: true,
displayModeBar: true,
displaylogo: false,
modeBarButtonsToAdd: ['hoverclosest', 'hovercompare'],
modeBarButtonsToRemove: [],
toImageButtonOptions: {
format: 'png',
filename: 'chart_export',
height: 800,
width: 1200,
scale: 2
}
};
Plotly.newPlot(chartDiv.id, plotData, layout, config);
} else {
chartDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">No data available for chart</div>';
}
} else {
chartDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">No features found in layer</div>';
}
}).catch(error => {
console.error('Error loading chart data:', error);
chartDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">Error loading data</div>';
});
} else {
// Show sample data if no layer configured
const chartType = item.config.type || 'bar';
let chartData;
// Handle pie charts differently
if (chartType === 'pie') {
chartData = [{
labels: ['North', 'South', 'East', 'West'],
values: [20, 14, 23, 25],
type: 'pie',
marker: {
colors: ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728']
}
}];
} else {
chartData = [{
x: ['North', 'South', 'East', 'West'],
y: [20, 14, 23, 25],
type: chartType,
name: 'Sample Data',
marker: {
color: ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728']
}
}];
}
const theme = getChartThemeOptions();
const layout = {
margin: {t: 20, r: 20, b: 40, l: 40},
font: {size: 12, color: theme.textColor},
paper_bgcolor: theme.plotBackground,
plot_bgcolor: theme.plotBackground,
hovermode: 'closest',
showlegend: true,
legend: {
font: { color: theme.textColor }
}
};
const showGrid = item.config.showGrid !== false;
if (chartType !== 'pie') {
const baseAxis = {
showgrid: showGrid,
gridcolor: theme.gridColor,
linecolor: theme.axisColor,
zerolinecolor: theme.gridColor,
tickfont: { color: theme.textColor },
color: theme.textColor,
title: { font: { color: theme.textColor } }
};
layout.xaxis = { ...baseAxis };
layout.yaxis = { ...baseAxis };
} else {
chartData.forEach(trace => {
trace.textfont = { ...(trace.textfont || {}), color: theme.textColor };
trace.insidetextfont = { ...(trace.insidetextfont || {}), color: theme.isDark ? '#0f172a' : '#ffffff' };
trace.outsidetextfont = { ...(trace.outsidetextfont || {}), color: theme.textColor };
});
}
const config = {
responsive: true,
displayModeBar: true,
displaylogo: false,
modeBarButtonsToAdd: ['hoverclosest', 'hovercompare'],
modeBarButtonsToRemove: [],
toImageButtonOptions: {
format: 'png',
filename: 'chart_export',
height: 800,
width: 1200,
scale: 2
}
};
Plotly.newPlot(chartDiv.id, chartData, layout, config);
}
} catch (error) {
console.error('Failed to create chart:', error);
chartDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999; border: 2px dashed #ddd; border-radius: 8px; margin: 20px;">Chart Preview<br><small>Error: ' + error.message + '</small></div>';
}
}, 100);
break;
case 'table':
// Create table container
const tableDiv = document.createElement('div');
tableDiv.style.width = '100%';
tableDiv.style.height = '100%';
tableDiv.style.overflow = 'auto';
tableDiv.style.padding = '10px';
container.appendChild(tableDiv);
// Try to fetch real data if layer is configured
if (item.config.layer) {
tableDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">Loading table data...</div>';
fetchLayerData(item.config.layer, item.config.limit || 50).then(data => {
// Clear loading text
tableDiv.innerHTML = '';
if (data && data.features && data.features.length > 0) {
// Get properties from first feature
const properties = data.features[0].properties;
const propNames = Object.keys(properties);
// Use configured columns if available, otherwise use first 5 columns
const displayProps = item.config.columns && item.config.columns.length > 0
? item.config.columns.filter(col => propNames.includes(col))
: propNames.slice(0, 5);
// Create table header
let tableHTML = `
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
<thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #dee2e6;">
`;
// Add magnifying glass column header
tableHTML += `<th style="padding: 6px; text-align: center; border: 1px solid #dee2e6; width: 40px;"><span class="magnifying-glass" aria-label="Zoom to row">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
</th>`;
// Add headers for properties
displayProps.forEach(prop => {
tableHTML += `<th style="padding: 6px; text-align: left; border: 1px solid #dee2e6;">${prop}</th>`;
});
tableHTML += `
</tr>
</thead>
<tbody>
`;
// Add data rows
data.features.forEach((feature, index) => {
const bgColor = index % 2 === 0 ? '' : 'background-color: #f8f9fa;';
tableHTML += `<tr style="border-bottom: 1px solid #dee2e6; ${bgColor}">`;
// Add magnifying glass cell with click handler
tableHTML += `<td style="padding: 6px; border: 1px solid #dee2e6; text-align: center;" onclick="zoomToFeature(${JSON.stringify(feature).replace(/"/g, '&quot;')}, '${item.config.layer}')" title="Zoom to feature on map"><span class="magnifying-glass" aria-label="Zoom to row">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
</td>`;
displayProps.forEach(prop => {
const value = feature.properties[prop] || '';
const displayValue = typeof value === 'string' && value.length > 20
? value.substring(0, 20) + '...'
: value;
tableHTML += `<td style="padding: 6px; border: 1px solid #dee2e6;">${displayValue}</td>`;
});
tableHTML += '</tr>';
});
tableHTML += `
</tbody>
</table>
`;
tableDiv.innerHTML = tableHTML;
} else {
tableDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">No features found in layer</div>';
}
}).catch(error => {
console.error('Error loading table data:', error);
tableDiv.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">Error loading data</div>';
});
} else {
// Show sample data if no layer configured
const table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
table.style.fontSize = '14px';
table.innerHTML = `
<thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #dee2e6;">
<th style="padding: 8px; text-align: left; border: 1px solid #dee2e6;">ID</th>
<th style="padding: 8px; text-align: left; border: 1px solid #dee2e6;">Name</th>
<th style="padding: 8px; text-align: left; border: 1px solid #dee2e6;">Value</th>
<th style="padding: 8px; text-align: left; border: 1px solid #dee2e6;">Status</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 8px; border: 1px solid #dee2e6;">1</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">Sample Location 1</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">100</td>
<td style="padding: 8px; border: 1px solid #dee2e6; color: green;">Active</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6; background-color: #f8f9fa;">
<td style="padding: 8px; border: 1px solid #dee2e6;">2</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">Sample Location 2</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">200</td>
<td style="padding: 8px; border: 1px solid #dee2e6; color: orange;">Pending</td>
</tr>
</tbody>
`;
tableDiv.appendChild(table);
}
break;
case 'counter':
const counter = document.createElement('div');
counter.classList.add('counter-widget');
container.appendChild(counter);
// Create stable DOM structure that won't cause layout shifts
const valueDiv = document.createElement('div');
valueDiv.classList.add('counter-value');
const labelDiv = document.createElement('div');
labelDiv.classList.add('counter-label');
const descDiv = document.createElement('div');
descDiv.classList.add('counter-description');
counter.appendChild(valueDiv);
counter.appendChild(labelDiv);
counter.appendChild(descDiv);
// Try to fetch real data if layer is configured
if (item.config.layer) {
valueDiv.textContent = '...';
fetchLayerData(item.config.layer, 1000).then(data => {
if (data && data.features) {
let value = 0;
const count = data.features.length;
if (item.config.operation === 'count') {
value = count;
} else if (item.config.operation === 'sum' && item.config.field) {
value = data.features.reduce((sum, feature) => {
const val = parseFloat(feature.properties[item.config.field]) || 0;
return sum + val;
}, 0);
} else if (item.config.operation === 'avg' && item.config.field) {
const sum = data.features.reduce((sum, feature) => {
const val = parseFloat(feature.properties[item.config.field]) || 0;
return sum + val;
}, 0);
value = count > 0 ? (sum / count).toFixed(1) : 0;
} else if (item.config.operation === 'min' && item.config.field) {
const values = data.features.map(feature => parseFloat(feature.properties[item.config.field]) || 0);
value = values.length > 0 ? Math.min(...values) : 0;
} else if (item.config.operation === 'max' && item.config.field) {
const values = data.features.map(feature => parseFloat(feature.properties[item.config.field]) || 0);
value = values.length > 0 ? Math.max(...values) : 0;
} else {
value = count;
}
const label = item.config.operation === 'count' ? 'Features' :
// item.config.operation === 'sum' ? `Sum of ${item.config.field}` :
item.config.operation === 'avg' ? `Avg of ${item.config.field}` :
item.config.operation === 'min' ? `Min of ${item.config.field}` :
item.config.operation === 'max' ? `Max of ${item.config.field}` : '';
valueDiv.textContent = formatNumber(value);
labelDiv.textContent = label;
// descDiv.textContent = `from ${item.config.layer}`;
} else {
valueDiv.textContent = '0';
labelDiv.textContent = 'No data';
descDiv.textContent = '';
}
}).catch(error => {
console.error('Error loading counter data:', error);
valueDiv.textContent = 'Error';
labelDiv.textContent = 'Error loading data';
descDiv.textContent = '';
});
} else {
// Show sample data if no layer configured
valueDiv.textContent = '123';
labelDiv.textContent = 'Sample Count';
descDiv.textContent = '';
}
break;
case 'text':
const textContent = document.createElement('div');
textContent.style.minHeight = '100px';
textContent.style.width = '100%';
textContent.style.padding = '10px';
textContent.style.overflow = 'auto';
textContent.style.boxSizing = 'border-box';
// Render HTML content (from Quill)
textContent.innerHTML = item.config.content || '<p>Text content</p>';
container.appendChild(textContent);
break;
}
}
function toggleMaximize(div) {
const isMaximized = div.classList.contains('maximized');
const maxBtn = div.querySelector('.maximize');
if (isMaximized) {
div.classList.remove('maximized');
if (maxBtn) {
maxBtn.classList.remove('maximized');
}
div.style.position = div.dataset.originalPosition || '';
div.style.top = div.dataset.originalTop || '';
div.style.left = div.dataset.originalLeft || '';
div.style.width = div.dataset.originalWidth || '';
div.style.height = div.dataset.originalHeight || '';
div.style.zIndex = div.dataset.originalZIndex || '';
div.style.right = '';
div.style.bottom = '';
} else {
const computed = window.getComputedStyle(div);
div.dataset.originalPosition = computed.position;
div.dataset.originalTop = computed.top;
div.dataset.originalLeft = computed.left;
div.dataset.originalWidth = computed.width;
div.dataset.originalHeight = computed.height;
div.dataset.originalZIndex = computed.zIndex;
div.classList.add('maximized');
if (maxBtn) {
maxBtn.classList.add('maximized');
}
div.style.position = 'fixed';
div.style.top = '0';
div.style.left = '0';
div.style.width = '100vw';
div.style.height = '100vh';
div.style.zIndex = '2000';
div.style.right = 'auto';
div.style.bottom = 'auto';
}
// If there's a map, invalidate its size after a short delay
setTimeout(() => {
const mapDiv = div.querySelector('.body .pad > div');
if (mapDiv && mapDiv._leaflet_map) {
mapDiv._leaflet_map.invalidateSize();
}
}, 100);
}
// PDF Export Function
function exportToPDF() {
const exportBtn = document.getElementById('exportPdfBtn');
const originalText = exportBtn.innerHTML;
exportBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Generating...';
exportBtn.disabled = true;
exportBtn.style.display = 'none';
setTimeout(() => {
const dashboardArea = document.querySelector('.wrap');
const opt = {
margin: [0, 0, 0, 0],
filename: `dashboard-<?php echo preg_replace('/[^a-zA-Z0-9-_]/', '', $dashboard['title']); ?>-${new Date().toISOString().split('T')[0]}.pdf`,
image: { type: 'jpeg', quality: 0.95 },
html2canvas: {
scale: 0.8,
useCORS: true,
letterRendering: true,
allowTaint: true,
backgroundColor: '#f6f7fb',
scrollX: 0,
scrollY: 0,
width: dashboardArea.scrollWidth,
height: dashboardArea.scrollHeight
},
jsPDF: {
unit: 'mm',
format: 'a4',
orientation: 'landscape'
}
};
html2pdf().set(opt).from(dashboardArea).save().then(() => {
exportBtn.style.display = 'inline-flex';
exportBtn.innerHTML = originalText;
exportBtn.disabled = false;
}).catch((error) => {
console.error('PDF generation failed:', error);
alert('Failed to generate PDF. Please try again.');
exportBtn.style.display = 'inline-flex';
exportBtn.innerHTML = originalText;
exportBtn.disabled = false;
});
}, 500);
}
document.getElementById('exportPdfBtn').addEventListener('click', exportToPDF);
</script>
<style>
/* reduce the padding only on top */
.wrap { padding-top: 4px !important; }
/* also reduce the grid<69>s first-row spacing */
.wrap { gap: 4px 16px !important; } /* row-gap = 4px, column-gap = 16px */
/* if a framework gives the first child a margin-top, kill it */
.wrap > *:first-child { margin-top: -20px !important; }
</style>
</body>
</html>