2527 lines
109 KiB
PHP
<?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, '"');
|
||
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, '"')})">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, '"')})">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()">×</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()">×</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: '© CartoDB',
|
||
subdomains: 'abcd'
|
||
}),
|
||
'Carto Dark': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||
attribution: '© CartoDB',
|
||
subdomains: 'abcd'
|
||
}),
|
||
'Carto Voyager': L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', {
|
||
attribution: '© CartoDB',
|
||
subdomains: 'abcd'
|
||
}),
|
||
'Esri Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||
attribution: '© Esri'
|
||
}),
|
||
'Esri Topo': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', {
|
||
attribution: '© 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, '"')}, '${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>
|
PostGIS
Mobile
QGIS
MapBender
GeoServer
GeoNode
GeoNetwork
Novella
Solutions