From 62a697ce453e2dbcd54f7c629b4d3e76a059e346 Mon Sep 17 00:00:00 2001 From: jhartworks Date: Mon, 22 Jun 2026 15:52:25 +0200 Subject: [PATCH] init --- api/cli/run_csv_exports.php | 720 ++ api/visu_api.php | 4218 ++++++++++++ sql/visu_csv_exports.sql | 51 + sql/visu_virtual_points.sql | 18 + visu-src/.gitignore | 24 + visu-src/README.md | 16 + visu-src/eslint.config.js | 21 + visu-src/index.html | 13 + visu-src/package-lock.json | 2410 +++++++ visu-src/package.json | 27 + visu-src/public/favicon.svg | 1 + visu-src/public/icons.svg | 24 + visu-src/src/App.css | 4404 ++++++++++++ visu-src/src/App.jsx | 36 + visu-src/src/assets/hero.png | Bin 0 -> 13057 bytes visu-src/src/assets/react.svg | 1 + visu-src/src/assets/vite.svg | 1 + visu-src/src/components/SankeyEditor.jsx | 547 ++ visu-src/src/components/SankeyGraphic.jsx | 354 + visu-src/src/components/ShapeGraphic.jsx | 284 + visu-src/src/components/TextGraphic.jsx | 185 + visu-src/src/components/TrendChartGraphic.jsx | 816 +++ .../src/components/ValueInstrumentGraphic.jsx | 303 + .../src/components/ViewerShortcutButton.jsx | 46 + visu-src/src/index.css | 111 + visu-src/src/lib/templateInstances.js | 224 + visu-src/src/lib/visuApi.js | 87 + visu-src/src/lib/visuUtils.js | 674 ++ visu-src/src/main.jsx | 10 + visu-src/src/pages/CsvExports.jsx | 483 ++ visu-src/src/pages/Designer.jsx | 5939 +++++++++++++++++ visu-src/src/pages/MonitoringData.jsx | 426 ++ visu-src/src/pages/Overview.jsx | 1309 ++++ visu-src/src/pages/TemplateDesigner.jsx | 172 + visu-src/src/pages/Viewer.jsx | 1261 ++++ visu-src/src/pages/VirtualPoints.jsx | 645 ++ visu-src/src/style.css | 572 ++ visu-src/vite.config.js | 12 + visu/dist/.vite/manifest.json | 11 + visu/dist/assets/index-5aj60a4v.css | 1 + visu/dist/assets/index-uV2H22jy.js | 23 + visu/dist/favicon.svg | 1 + visu/dist/icons.svg | 24 + visu/dist/index.html | 14 + visu/favicon.svg | 1 + visu/icons.svg | 24 + visu/index.php | 111 + 47 files changed, 26655 insertions(+) create mode 100644 api/cli/run_csv_exports.php create mode 100644 api/visu_api.php create mode 100644 sql/visu_csv_exports.sql create mode 100644 sql/visu_virtual_points.sql create mode 100644 visu-src/.gitignore create mode 100644 visu-src/README.md create mode 100644 visu-src/eslint.config.js create mode 100644 visu-src/index.html create mode 100644 visu-src/package-lock.json create mode 100644 visu-src/package.json create mode 100644 visu-src/public/favicon.svg create mode 100644 visu-src/public/icons.svg create mode 100644 visu-src/src/App.css create mode 100644 visu-src/src/App.jsx create mode 100644 visu-src/src/assets/hero.png create mode 100644 visu-src/src/assets/react.svg create mode 100644 visu-src/src/assets/vite.svg create mode 100644 visu-src/src/components/SankeyEditor.jsx create mode 100644 visu-src/src/components/SankeyGraphic.jsx create mode 100644 visu-src/src/components/ShapeGraphic.jsx create mode 100644 visu-src/src/components/TextGraphic.jsx create mode 100644 visu-src/src/components/TrendChartGraphic.jsx create mode 100644 visu-src/src/components/ValueInstrumentGraphic.jsx create mode 100644 visu-src/src/components/ViewerShortcutButton.jsx create mode 100644 visu-src/src/index.css create mode 100644 visu-src/src/lib/templateInstances.js create mode 100644 visu-src/src/lib/visuApi.js create mode 100644 visu-src/src/lib/visuUtils.js create mode 100644 visu-src/src/main.jsx create mode 100644 visu-src/src/pages/CsvExports.jsx create mode 100644 visu-src/src/pages/Designer.jsx create mode 100644 visu-src/src/pages/MonitoringData.jsx create mode 100644 visu-src/src/pages/Overview.jsx create mode 100644 visu-src/src/pages/TemplateDesigner.jsx create mode 100644 visu-src/src/pages/Viewer.jsx create mode 100644 visu-src/src/pages/VirtualPoints.jsx create mode 100644 visu-src/src/style.css create mode 100644 visu-src/vite.config.js create mode 100644 visu/dist/.vite/manifest.json create mode 100644 visu/dist/assets/index-5aj60a4v.css create mode 100644 visu/dist/assets/index-uV2H22jy.js create mode 100644 visu/dist/favicon.svg create mode 100644 visu/dist/icons.svg create mode 100644 visu/dist/index.html create mode 100644 visu/favicon.svg create mode 100644 visu/icons.svg create mode 100644 visu/index.php diff --git a/api/cli/run_csv_exports.php b/api/cli/run_csv_exports.php new file mode 100644 index 0000000..10fc1d4 --- /dev/null +++ b/api/cli/run_csv_exports.php @@ -0,0 +1,720 @@ +#!/usr/bin/env php + ['sql' => '15m', 'seconds' => 900], + '1h' => ['sql' => '1h', 'seconds' => 3600], + '6h' => ['sql' => '6h', 'seconds' => 21600], + '12h' => ['sql' => '12h', 'seconds' => 43200], + '24h' => ['sql' => '24h', 'seconds' => 86400], + '7d' => ['sql' => '7d', 'seconds' => 604800], + '30d' => ['sql' => '30d', 'seconds' => 2592000], + '90d' => ['sql' => '90d', 'seconds' => 7776000], + '180d' => ['sql' => '180d', 'seconds' => 15552000], + ]; + + return $ranges[$range] ?? $ranges['24h']; +} + +function runnerTrendInterval(string $interval, int $rangeSeconds): string +{ + $allowed = ['10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '1d']; + + if ($interval !== 'auto' && in_array($interval, $allowed, true)) { + return $interval; + } + + if ($rangeSeconds <= 900) return '10s'; + if ($rangeSeconds <= 3600) return '30s'; + if ($rangeSeconds <= 21600) return '5m'; + if ($rangeSeconds <= 86400) return '10m'; + if ($rangeSeconds <= 604800) return '1h'; + return '6h'; +} + +function runnerInfluxIdentifier(string $value): string +{ + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; +} + +function runnerInfluxString(string $value): string +{ + return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; +} + +function runnerInfluxQuery(string $database, string $query): array +{ + $url = rtrim(VISU_INFLUX_URL, '/') . '/query?' . http_build_query([ + 'db' => $database, + 'q' => $query, + ]); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $resp = curl_exec($ch); + $err = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($err) { + return ['ok' => false, 'error' => $err]; + } + + $decoded = json_decode((string)$resp, true); + if (!is_array($decoded)) { + return ['ok' => false, 'error' => 'Influx returned no JSON', 'raw' => (string)$resp]; + } + + if ($status >= 400) { + return ['ok' => false, 'error' => $decoded['error'] ?? ('Influx HTTP ' . $status)]; + } + + $result = $decoded['results'][0] ?? []; + if (!empty($result['error'])) { + return ['ok' => false, 'error' => (string)$result['error']]; + } + + return ['ok' => true, 'result' => $result]; +} + +function runnerInfluxSeriesValues(array $response): array +{ + $series = $response['result']['series'][0] ?? null; + if (!$series || empty($series['values']) || !is_array($series['values'])) { + return []; + } + + return $series['values']; +} + +function runnerSelectionPointKey(array $point): string +{ + return implode('|', [ + 'influx', + (string)($point['database'] ?? ''), + (string)($point['measurement'] ?? ''), + (string)($point['tag_key'] ?? ''), + (string)($point['tag_value'] ?? ''), + ]); +} + +function runnerNormalizePoints(array $points): array +{ + $out = []; + + foreach ($points as $point) { + if (!is_array($point)) { + continue; + } + + $database = (string)($point['database'] ?? ''); + $measurement = (string)($point['measurement'] ?? ''); + $tagKey = (string)($point['tag_key'] ?? ''); + $tagValue = (string)($point['tag_value'] ?? ''); + + if ($database === '' || $measurement === '' || $tagKey === '' || $tagValue === '') { + continue; + } + + $point['label'] = (string)($point['tag_value'] ?? $point['label'] ?? ''); + $out[] = $point; + } + + return array_values($out); +} + +function runnerLoadTrendSeries(array $selectionPoints, string $range, string $interval, string $aggregate): array +{ + $rangeInfo = runnerTrendRange($range); + $bucket = runnerTrendInterval($interval, (int)$rangeInfo['seconds']); + $agg = runnerTrendAggregate($aggregate); + $result = []; + + foreach (array_chunk($selectionPoints, 8) as $group) { + foreach ($group as $index => $point) { + $query = sprintf( + 'SELECT %s("value") FROM %s WHERE %s = %s AND time >= now() - %s GROUP BY time(%s) fill(null)', + $agg, + runnerInfluxIdentifier((string)$point['measurement']), + runnerInfluxIdentifier((string)$point['tag_key']), + runnerInfluxString((string)$point['tag_value']), + $rangeInfo['sql'], + $bucket + ); + + $response = runnerInfluxQuery((string)$point['database'], $query); + $values = []; + + if (!empty($response['ok'])) { + foreach (runnerInfluxSeriesValues($response) as $row) { + $values[] = [ + 'time' => (string)($row[0] ?? ''), + 'value' => $row[1] ?? '', + ]; + } + } + + $result[] = [ + 'id' => runnerSelectionPointKey($point) ?: ('series-' . $index), + 'label' => (string)($point['tag_value'] ?? $point['label'] ?? ''), + 'values' => $values, + 'error' => empty($response['ok']) ? (string)($response['error'] ?? 'Influx error') : '', + ]; + } + } + + return $result; +} + +function runnerLoadLastValues(array $selectionPoints): array +{ + $result = []; + + foreach (array_chunk($selectionPoints, 64) as $group) { + foreach ($group as $index => $point) { + $query = sprintf( + 'SELECT last("value") FROM %s WHERE %s = %s', + runnerInfluxIdentifier((string)$point['measurement']), + runnerInfluxIdentifier((string)$point['tag_key']), + runnerInfluxString((string)$point['tag_value']) + ); + + $response = runnerInfluxQuery((string)$point['database'], $query); + $rows = !empty($response['ok']) ? runnerInfluxSeriesValues($response) : []; + $time = (string)($rows[0][0] ?? ''); + $value = $rows[0][1] ?? ''; + + $result[runnerSelectionPointKey($point) ?: ('series-' . $index)] = [ + 'time' => $time, + 'value' => $value, + 'error' => empty($response['ok']) ? (string)($response['error'] ?? 'Influx error') : '', + ]; + } + } + + return $result; +} + +function runnerBuildTrendCsv(array $selectionPoints, array $series, string $delimiter): array +{ + $rows = []; + $headers = ['Zeit']; + + foreach ($selectionPoints as $point) { + $headers[] = (string)($point['tag_value'] ?? $point['label'] ?? ''); + } + + foreach ($series as $serie) { + foreach (($serie['values'] ?? []) as $row) { + $time = (string)($row['time'] ?? ''); + if ($time === '') { + continue; + } + + if (!isset($rows[$time])) { + $rows[$time] = ['Zeit' => $time]; + } + + $rows[$time][(string)($serie['label'] ?? '')] = $row['value'] ?? ''; + } + } + + ksort($rows, SORT_STRING); + + $lines = [ + implode($delimiter, array_map(static fn($cell) => runnerCsvEscape($cell, $delimiter), $headers)), + ]; + + foreach ($rows as $row) { + $lines[] = implode($delimiter, array_map( + static fn($cell) => runnerCsvEscape($row[$cell] ?? '', $delimiter), + $headers + )); + } + + return [ + 'content' => "\xEF\xBB\xBF" . implode("\r\n", $lines), + 'row_count' => count($rows), + ]; +} + +function runnerBuildLastValuesCsv(array $selectionPoints, array $lastValues, string $delimiter): array +{ + $header = ['Datenpunkt', 'Measurement', 'Tag', 'Wert', 'Zeit', 'Datenbank']; + $lines = [ + implode($delimiter, array_map(static fn($cell) => runnerCsvEscape($cell, $delimiter), $header)), + ]; + + foreach ($selectionPoints as $point) { + $last = $lastValues[runnerSelectionPointKey($point)] ?? []; + $lines[] = implode($delimiter, array_map( + static fn($cell) => runnerCsvEscape($cell, $delimiter), + [ + (string)($point['tag_value'] ?? ''), + (string)($point['measurement'] ?? ''), + (string)($point['tag_key'] ?? ''), + $last['value'] ?? '', + $last['time'] ?? '', + (string)($point['database'] ?? ''), + ] + )); + } + + return [ + 'content' => "\xEF\xBB\xBF" . implode("\r\n", $lines), + 'row_count' => count($selectionPoints), + ]; +} + +function runnerScheduleKind(string $value): string +{ + return in_array($value, ['interval', 'daily', 'weekly', 'monthly'], true) ? $value : 'daily'; +} + +function runnerTimeOfDay(string $value): string +{ + $value = trim($value); + if (!preg_match('/^(\d{2}):(\d{2})$/', $value, $m)) { + return '00:00'; + } + + return sprintf('%02d:%02d', max(0, min(23, (int)$m[1])), max(0, min(59, (int)$m[2]))); +} + +function runnerNextRunAt(string $scheduleKind, int $intervalMinutes, string $timeOfDay, string $weekdays, int $monthDay, ?DateTimeImmutable $now = null): string +{ + $now = $now ?: new DateTimeImmutable('now'); + [$hour, $minute] = array_map('intval', explode(':', runnerTimeOfDay($timeOfDay))); + $scheduleKind = runnerScheduleKind($scheduleKind); + + if ($scheduleKind === 'interval') { + return $now->modify('+' . max(1, $intervalMinutes) . ' minutes')->format('Y-m-d H:i:s'); + } + + if ($scheduleKind === 'daily') { + $candidate = $now->setTime($hour, $minute, 0); + if ($candidate <= $now) { + $candidate = $candidate->modify('+1 day'); + } + return $candidate->format('Y-m-d H:i:s'); + } + + if ($scheduleKind === 'weekly') { + $days = array_filter(array_map('intval', explode(',', (string)$weekdays)), static fn($day) => $day >= 1 && $day <= 7); + if (empty($days)) { + $days = [(int)$now->format('N')]; + } + + for ($offset = 0; $offset <= 14; $offset++) { + $candidateDay = $now->modify('+' . $offset . ' days'); + if (!in_array((int)$candidateDay->format('N'), $days, true)) { + continue; + } + + $candidate = $candidateDay->setTime($hour, $minute, 0); + if ($candidate > $now) { + return $candidate->format('Y-m-d H:i:s'); + } + } + } + + if ($scheduleKind === 'monthly') { + $monthDay = max(1, min(31, $monthDay)); + $base = $now; + + for ($i = 0; $i < 14; $i++) { + $monthStart = $base->modify('first day of this month')->setTime($hour, $minute, 0); + $daysInMonth = (int)$monthStart->format('t'); + $day = min($monthDay, $daysInMonth); + $candidate = $monthStart->setDate( + (int)$monthStart->format('Y'), + (int)$monthStart->format('m'), + $day + ); + + if ($candidate > $now) { + return $candidate->format('Y-m-d H:i:s'); + } + + $base = $base->modify('first day of next month'); + } + } + + return $now->modify('+1 day')->setTime($hour, $minute, 0)->format('Y-m-d H:i:s'); +} + +function runnerEnsureDir(string $dir): void +{ + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + throw new RuntimeException('Could not create export directory: ' . $dir); + } + + if (!is_writable($dir)) { + throw new RuntimeException('Export directory is not writable: ' . $dir); + } +} + +function runnerSanitizeFilePart(string $value): string +{ + $value = trim($value); + $value = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $value); + $value = trim((string)$value, '._-'); + return $value !== '' ? $value : 'export'; +} + +function runnerSanitizeTargetDir(string $value): string +{ + $value = str_replace('\\', '/', trim($value)); + $parts = array_filter(explode('/', $value), static fn($part) => $part !== '' && $part !== '.' && $part !== '..'); + $clean = []; + + foreach ($parts as $part) { + $part = runnerSanitizeFilePart($part); + if ($part !== '') { + $clean[] = $part; + } + } + + return !empty($clean) ? implode('/', $clean) : 'csv_exports'; +} + +function runnerBuildFilename(string $pattern, string $selectionName, string $jobName, int $jobId, ?DateTimeImmutable $now = null): string +{ + $now = $now ?: new DateTimeImmutable('now'); + $selection = runnerSanitizeFilePart($selectionName); + $job = runnerSanitizeFilePart($jobName); + + $replacements = [ + '{selection}' => $selection, + '{job}' => $job, + '{job_id}' => (string)$jobId, + '{yyyy}' => $now->format('Y'), + '{mm}' => $now->format('m'), + '{dd}' => $now->format('d'), + '{hh}' => $now->format('H'), + '{ii}' => $now->format('i'), + '{ss}' => $now->format('s'), + ]; + + $name = strtr($pattern !== '' ? $pattern : '{selection}_{yyyy}-{mm}-{dd}_{hh}-{ii}-{ss}.csv', $replacements); + $name = runnerSanitizeFilePart($name); + + if (!str_ends_with(strtolower($name), '.csv')) { + $name .= '.csv'; + } + + return $name; +} + +function runnerCreateFileRecord(PDO $pdo, array $job, string $fileName, string $relativePath): int +{ + $stmt = $pdo->prepare(" + INSERT INTO visu_csv_export_files ( + job_id, + project_id, + selection_id, + status, + file_name, + file_path, + export_started_at + ) VALUES (?, ?, ?, 'running', ?, ?, NOW()) + "); + $stmt->execute([ + (int)$job['id'], + (int)$job['project_id'], + (int)$job['selection_id'], + $fileName, + $relativePath, + ]); + + return (int)$pdo->lastInsertId(); +} + +function runnerMarkFileSuccess(PDO $pdo, int $fileId, int $fileSize, int $rowCount): void +{ + $stmt = $pdo->prepare(" + UPDATE visu_csv_export_files + SET + status = 'success', + file_size = ?, + row_count = ?, + export_finished_at = NOW(), + error_message = NULL + WHERE id = ? + "); + $stmt->execute([$fileSize, $rowCount, $fileId]); +} + +function runnerMarkFileError(PDO $pdo, int $fileId, string $error): void +{ + $stmt = $pdo->prepare(" + UPDATE visu_csv_export_files + SET + status = 'error', + export_finished_at = NOW(), + error_message = ? + WHERE id = ? + "); + $stmt->execute([mb_substr($error, 0, 65535), $fileId]); +} + +function runnerMarkJobSuccess(PDO $pdo, array $job, string $nextRunAt): void +{ + $stmt = $pdo->prepare(" + UPDATE visu_csv_export_jobs + SET + last_run_at = NOW(), + next_run_at = ?, + last_status = 'success', + last_error = NULL + WHERE id = ? + "); + $stmt->execute([$nextRunAt, (int)$job['id']]); +} + +function runnerMarkJobError(PDO $pdo, array $job, string $error, string $nextRunAt): void +{ + $stmt = $pdo->prepare(" + UPDATE visu_csv_export_jobs + SET + last_run_at = NOW(), + next_run_at = ?, + last_status = 'error', + last_error = ? + WHERE id = ? + "); + $stmt->execute([$nextRunAt, mb_substr($error, 0, 65535), (int)$job['id']]); +} + +function runnerClaimJob(PDO $pdo, int $jobId): bool +{ + $stmt = $pdo->prepare(" + UPDATE visu_csv_export_jobs + SET last_status = 'running', last_error = NULL + WHERE id = ? + AND enabled = 1 + AND (last_status <> 'running' OR last_status IS NULL) + "); + $stmt->execute([$jobId]); + return $stmt->rowCount() > 0; +} + +function runnerCleanupOldFiles(PDO $pdo, array $job): void +{ + $retentionDays = max(1, (int)($job['retention_days'] ?? 30)); + + $stmt = $pdo->prepare(" + SELECT id, file_path + FROM visu_csv_export_files + WHERE job_id = ? + AND status = 'success' + AND created_at < DATE_SUB(NOW(), INTERVAL ? DAY) + "); + $stmt->execute([(int)$job['id'], $retentionDays]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as $row) { + $relativePath = (string)($row['file_path'] ?? ''); + $absolutePath = rtrim(VISU_EXPORT_STORAGE_ROOT, '/') . '/' . ltrim($relativePath, '/'); + if (is_file($absolutePath)) { + @unlink($absolutePath); + } + + $upd = $pdo->prepare(" + UPDATE visu_csv_export_files + SET status = 'deleted' + WHERE id = ? + "); + $upd->execute([(int)$row['id']]); + } +} + +function runnerLoadDueJobs(PDO $pdo): array +{ + $stmt = $pdo->query(" + SELECT * + FROM visu_csv_export_jobs + WHERE enabled = 1 + AND next_run_at IS NOT NULL + AND next_run_at <= NOW() + AND (last_status <> 'running' OR last_status IS NULL) + ORDER BY next_run_at ASC, id ASC + LIMIT 25 + "); + + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; +} + +function runnerLoadSelection(PDO $pdo, int $selectionId): ?array +{ + $stmt = $pdo->prepare(" + SELECT id, name, database_name, range_value, interval_value, aggregate_value, delimiter_value, points_json + FROM visu_monitoring_selections + WHERE id = ? + AND is_active = 1 + LIMIT 1 + "); + $stmt->execute([$selectionId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; +} + +try { + $pdo->query('SELECT 1 FROM visu_csv_export_jobs LIMIT 1'); + $pdo->query('SELECT 1 FROM visu_csv_export_files LIMIT 1'); +} catch (Throwable $e) { + runnerLog('CSV export tables missing, skipping run.'); + exit(0); +} + +$jobs = runnerLoadDueJobs($pdo); +if (empty($jobs)) { + runnerLog('No due CSV export jobs.'); + exit(0); +} + +runnerLog('Found ' . count($jobs) . ' due CSV export job(s).'); + +foreach ($jobs as $job) { + $jobId = (int)($job['id'] ?? 0); + if ($jobId <= 0) { + continue; + } + + if (!runnerClaimJob($pdo, $jobId)) { + continue; + } + + $selection = runnerLoadSelection($pdo, (int)($job['selection_id'] ?? 0)); + $nextRunAt = runnerNextRunAt( + (string)($job['schedule_kind'] ?? 'daily'), + (int)($job['interval_minutes'] ?? 60), + (string)($job['time_of_day'] ?? '00:00'), + (string)($job['weekdays'] ?? ''), + (int)($job['month_day'] ?? 1) + ); + + try { + if (!$selection) { + throw new RuntimeException('Selection not found or inactive.'); + } + + $points = json_decode((string)($selection['points_json'] ?? '[]'), true); + if (!is_array($points)) { + throw new RuntimeException('Selection points JSON is invalid.'); + } + + $points = runnerNormalizePoints($points); + if (empty($points)) { + throw new RuntimeException('Selection contains no valid Influx points.'); + } + + $delimiter = runnerCsvDelimiter($job['csv_delimiter'] ?? ';'); + $exportKind = (string)($job['export_kind'] ?? 'trend_csv'); + $selectionName = (string)($selection['name'] ?? ('selection_' . (int)$job['selection_id'])); + $jobName = (string)($job['name'] ?? ('job_' . $jobId)); + $targetDir = runnerSanitizeTargetDir((string)($job['target_dir'] ?? 'csv_exports')); + $fileName = runnerBuildFilename((string)($job['filename_pattern'] ?? ''), $selectionName, $jobName, $jobId); + $relativePath = trim($targetDir . '/' . $fileName, '/'); + $absoluteDir = rtrim(VISU_EXPORT_STORAGE_ROOT, '/') . '/' . $targetDir; + $absolutePath = rtrim(VISU_EXPORT_STORAGE_ROOT, '/') . '/' . $relativePath; + + runnerEnsureDir($absoluteDir); + $fileId = runnerCreateFileRecord($pdo, $job, $fileName, $relativePath); + + if ($exportKind === 'lastvalues_csv') { + $lastValues = runnerLoadLastValues($points); + $csv = runnerBuildLastValuesCsv($points, $lastValues, $delimiter); + } else { + $series = runnerLoadTrendSeries( + $points, + (string)($selection['range_value'] ?? '24h'), + (string)($selection['interval_value'] ?? 'auto'), + (string)($selection['aggregate_value'] ?? 'last') + ); + $csv = runnerBuildTrendCsv($points, $series, $delimiter); + } + + if (file_put_contents($absolutePath, $csv['content']) === false) { + throw new RuntimeException('CSV file could not be written: ' . $absolutePath); + } + + @chmod($absolutePath, 0664); + runnerMarkFileSuccess($pdo, $fileId, (int)filesize($absolutePath), (int)$csv['row_count']); + runnerMarkJobSuccess($pdo, $job, $nextRunAt); + runnerCleanupOldFiles($pdo, $job); + + runnerLog("Job {$jobId} finished successfully: {$relativePath}"); + } catch (Throwable $e) { + $error = $e->getMessage(); + + if (!empty($fileId ?? 0)) { + runnerMarkFileError($pdo, (int)$fileId, $error); + } + + runnerMarkJobError($pdo, $job, $error, $nextRunAt); + runnerLog("Job {$jobId} failed: {$error}"); + } +} + +runnerLog('CSV export runner finished.'); diff --git a/api/visu_api.php b/api/visu_api.php new file mode 100644 index 0000000..44622b6 --- /dev/null +++ b/api/visu_api.php @@ -0,0 +1,4218 @@ + false, 'error' => 'Nicht eingeloggt.']); + exit; +} + +header('Content-Type: application/json; charset=utf-8'); + +define('VISU_ASSET_UPLOAD_DIR', dirname(__DIR__) . '/uploads/visu_assets'); +define('VISU_ASSET_UPLOAD_URL', '/uploads/visu_assets'); +define('VISU_BG_UPLOAD_DIR', dirname(__DIR__) . '/uploads/visu_backgrounds'); +define('VISU_BG_UPLOAD_URL', '/uploads/visu_backgrounds'); +define('VISU_INFLUX_URL', 'http://10.99.99.6:8086'); +define('VISU_EXPORT_STORAGE_ROOT', rtrim((string)(getenv('VISU_EXPORT_ROOT') ?: '/var/portal_storage'), '/')); + +function visuJson(array $data, int $status = 200): void +{ + http_response_code($status); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function visuCleanString($value): ?string +{ + $value = trim((string)$value); + return $value === '' ? null : $value; +} + +function visuSlug(string $value): string +{ + $value = trim(mb_strtolower($value)); + $value = preg_replace('/[^a-z0-9äöüß]+/iu', '-', $value); + $value = trim((string)$value, '-'); + return $value !== '' ? mb_substr($value, 0, 180) : 'visu'; +} + +function visuUserGroups(PDO $pdo, string $username, array $sessionGroups = []): array +{ + $groups = is_array($sessionGroups) ? $sessionGroups : []; + + if (!empty($groups)) { + return array_values(array_unique(array_filter(array_map('trim', $groups), fn($x) => $x !== ''))); + } + + if ($username === '') { + return []; + } + + $stmt = $pdo->prepare("SELECT `groups` FROM users WHERE username = ? LIMIT 1"); + $stmt->execute([$username]); + $raw = (string)($stmt->fetchColumn() ?? ''); + + if ($raw === '') { + return []; + } + + $parts = array_map('trim', explode('|', $raw)); + return array_values(array_unique(array_filter($parts, fn($x) => $x !== ''))); +} + +function visuVisibilitySql(int $userId, bool $canViewAll, string $projectExpr, array &$params, string $prefix): string +{ + if ($canViewAll) { + return '1=1'; + } + + $params[":{$prefix}_user_id"] = $userId; + $params[":{$prefix}_role_user_id"] = $userId; + + return "( + EXISTS ( + SELECT 1 + FROM project_assignments pa + WHERE pa.project_id = {$projectExpr} + AND pa.user_id = :{$prefix}_user_id + ) + OR EXISTS ( + SELECT 1 + FROM project_groups pg + JOIN roles r ON r.key_name = pg.group_name + JOIN user_roles ur ON ur.role_id = r.id + WHERE pg.project_id = {$projectExpr} + AND ur.user_id = :{$prefix}_role_user_id + ) + )"; +} + +function visuProjectVisible(PDO $pdo, int $projectId, int $userId, bool $canViewAll): bool +{ + if ($projectId <= 0) { + return false; + } + + $params = [':project_id' => $projectId]; + $visSql = visuVisibilitySql($userId, $canViewAll, 'p.id', $params, 'project_visible'); + + $stmt = $pdo->prepare(" + SELECT 1 + FROM projects p + WHERE p.id = :project_id + AND {$visSql} + LIMIT 1 + "); + $stmt->execute($params); + + return (bool)$stmt->fetchColumn(); +} + +function visuPageVisible(PDO $pdo, int $pageId, int $userId, bool $canViewAll): ?array +{ + if ($pageId <= 0) { + return null; + } + + $params = [':page_id' => $pageId]; + $visSql = visuVisibilitySql($userId, $canViewAll, 'vp.project_id', $params, 'page_visible'); + + $stmt = $pdo->prepare(" + SELECT + vp.*, + p.project_number, + p.year, + p.name AS project_name, + pc.company_name AS customer_name + FROM visu_pages vp + INNER JOIN projects p ON p.id = vp.project_id + LEFT JOIN project_customers pc ON pc.id = p.customer_id + WHERE vp.id = :page_id + AND vp.is_active = 1 + AND {$visSql} + LIMIT 1 + "); + $stmt->execute($params); + + $page = $stmt->fetch(PDO::FETCH_ASSOC); + return $page ?: null; +} + +function visuEnsureDir(string $dir): void +{ + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + if (!is_dir($dir) || !is_writable($dir)) { + visuJson([ + 'ok' => false, + 'error' => 'Upload-Ordner ist nicht beschreibbar: ' . $dir, + ], 500); + } +} + +function visuSaveUploadedImage(string $fieldName, string $dir, string $urlBase): ?string +{ + if (empty($_FILES[$fieldName]) || !is_array($_FILES[$fieldName])) { + return null; + } + + $file = $_FILES[$fieldName]; + + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { + return null; + } + + if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + visuJson(['ok' => false, 'error' => 'Uploadfehler bei ' . $fieldName], 400); + } + + $tmp = (string)($file['tmp_name'] ?? ''); + $name = (string)($file['name'] ?? ''); + + if (!is_uploaded_file($tmp)) { + visuJson(['ok' => false, 'error' => 'Ungültiger Upload.'], 400); + } + + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $allowed = ['png', 'jpg', 'jpeg', 'webp', 'svg']; + + if (!in_array($ext, $allowed, true)) { + visuJson(['ok' => false, 'error' => 'Nur PNG, JPG, JPEG, WEBP oder SVG erlaubt.'], 400); + } + + visuEnsureDir($dir); + + $stored = date('Ymd_His') . '_' . bin2hex(random_bytes(10)) . '.' . $ext; + $target = rtrim($dir, '/') . '/' . $stored; + + if (!move_uploaded_file($tmp, $target)) { + visuJson(['ok' => false, 'error' => 'Datei konnte nicht gespeichert werden.'], 500); + } + + @chmod($target, 0664); + + return rtrim($urlBase, '/') . '/' . $stored; +} + +function visuSaveUploadedImageFile(array $file, string $dir, string $urlBase): ?array +{ + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { + return null; + } + + if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + visuJson(['ok' => false, 'error' => 'Uploadfehler bei Bild.'], 400); + } + + $tmp = (string)($file['tmp_name'] ?? ''); + $name = (string)($file['name'] ?? ''); + + if (!is_uploaded_file($tmp)) { + visuJson(['ok' => false, 'error' => 'Ungueltiger Upload.'], 400); + } + + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $allowed = ['png', 'jpg', 'jpeg', 'webp', 'svg']; + + if (!in_array($ext, $allowed, true)) { + visuJson(['ok' => false, 'error' => 'Nur PNG, JPG, JPEG, WEBP oder SVG erlaubt.'], 400); + } + + visuEnsureDir($dir); + + $stored = date('Ymd_His') . '_' . bin2hex(random_bytes(10)) . '.' . $ext; + $target = rtrim($dir, '/') . '/' . $stored; + + if (!move_uploaded_file($tmp, $target)) { + visuJson(['ok' => false, 'error' => 'Datei konnte nicht gespeichert werden.'], 500); + } + + @chmod($target, 0664); + + return [ + 'url' => rtrim($urlBase, '/') . '/' . $stored, + 'original_name' => $name, + 'mime_type' => (string)($file['type'] ?? ''), + 'file_size' => (int)($file['size'] ?? 0), + ]; +} + +function visuImageLibraryRegister(PDO $pdo, string $url, ?string $originalName, ?string $mimeType, ?int $fileSize, ?int $projectId, int $userId): void +{ + $url = trim($url); + if ($url === '') { + return; + } + + try { + $stmt = $pdo->prepare(" + INSERT INTO visu_image_library + (project_id, file_url, original_name, mime_type, file_size, created_by, is_active) + VALUES + (?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE + project_id = COALESCE(VALUES(project_id), project_id), + original_name = COALESCE(VALUES(original_name), original_name), + mime_type = COALESCE(VALUES(mime_type), mime_type), + file_size = COALESCE(VALUES(file_size), file_size), + is_active = 1 + "); + $stmt->execute([$projectId, $url, $originalName, $mimeType, $fileSize, $userId]); + } catch (Throwable $e) { + // Die Bilderbibliothek ist optional bis die SQL-Tabelle angelegt wurde. + } +} + +function visuImageLibraryAllowed(PDO $pdo, string $url, int $projectId, int $userId, bool $canViewAll): bool +{ + $url = trim($url); + if ($url === '' || !str_starts_with($url, rtrim(VISU_ASSET_UPLOAD_URL, '/') . '/')) { + return false; + } + + try { + $stmt = $pdo->prepare(" + SELECT project_id + FROM visu_image_library + WHERE file_url = ? + AND is_active = 1 + LIMIT 1 + "); + $stmt->execute([$url]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return false; + } + + $imageProjectId = (int)($row['project_id'] ?? 0); + + if ($imageProjectId <= 0) { + return true; + } + + if ($projectId > 0 && $imageProjectId === $projectId) { + return true; + } + + return visuProjectVisible($pdo, $imageProjectId, $userId, $canViewAll); + } catch (Throwable $e) { + return false; + } +} + +function visuDeleteAssetImageIfUnused(PDO $pdo, ?string $url, int $ignoreStateId = 0): void +{ + $url = trim((string)$url); + if ($url === '') { + return; + } + + try { + $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_image_library WHERE file_url = ? AND is_active = 1"); + $stmt->execute([$url]); + if ((int)$stmt->fetchColumn() > 0) { + return; + } + } catch (Throwable $e) { + // Ohne Bibliothekstabelle weiter wie bisher. + } + + try { + if ($ignoreStateId > 0) { + $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_asset_states WHERE image_file = ? AND id <> ?"); + $stmt->execute([$url, $ignoreStateId]); + } else { + $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_asset_states WHERE image_file = ?"); + $stmt->execute([$url]); + } + + if ((int)$stmt->fetchColumn() > 0) { + return; + } + } catch (Throwable $e) { + } + + visuDeleteUploadedFile($url, VISU_ASSET_UPLOAD_URL, VISU_ASSET_UPLOAD_DIR); +} + +function visuDeleteUploadedFile(?string $url, string $urlBase, string $dir): void +{ + $url = trim((string)$url); + if ($url === '') { + return; + } + + $prefix = rtrim($urlBase, '/') . '/'; + + if (!str_starts_with($url, $prefix)) { + return; + } + + $file = basename($url); + $path = rtrim($dir, '/') . '/' . $file; + + $base = realpath($dir); + $real = realpath($path); + + if ($base && $real && strncmp($real, $base, strlen($base)) === 0 && is_file($real)) { + @unlink($real); + } +} + +function visuCallMainApi(string $fn, string $externalId, array $extra = []): array +{ + global $mainApiKey; + + $apiKey = (string)($mainApiKey ?? ''); + + if ($apiKey === '') { + return ['ok' => false, 'error' => 'mainApiKey fehlt in config.php']; + } + + $data = array_merge([ + 'api_key' => $apiKey, + 'function' => $fn, + 'id' => $externalId, + ], $extra); + + $ch = curl_init('https://main.api.se-inno.de'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $data); + curl_setopt($ch, CURLOPT_TIMEOUT, 12); + + $resp = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($err) { + return ['ok' => false, 'error' => $err]; + } + + $decoded = json_decode((string)$resp, true); + return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'upstream bad json', 'raw' => (string)$resp]; +} + +function visuPointLabel(array $p, int $ispNumber, string $type): string +{ + $group = trim((string)($p['group'] ?? '')); + $label = trim((string)($p['label'] ?? '')); + $key = trim((string)($p['key'] ?? '')); + $id = (int)($p['id'] ?? 0); + + if ($label === '' || in_array(mb_strtolower($label), ['value', 'wert'], true)) { + $label = $key !== '' ? $key : ('Variable ' . $id); + } + + return trim(implode(' · ', array_filter([ + 'ISP ' . $ispNumber, + strtoupper($type), + $group, + $label, + ]))); +} + +function visuPointSourceKey(array $point): string +{ + return implode('|', [ + (string)($point['type'] ?? ''), + (string)($point['external_id'] ?? ''), + (string)($point['variable_id'] ?? ''), + (string)($point['point_key'] ?? ''), + ]); +} + +function visuPointHasDisplayValue(array $point): bool +{ + foreach (['value', 'value_formatted'] as $key) { + if (array_key_exists($key, $point) && trim((string)$point[$key]) !== '') { + return true; + } + } + + return false; +} + +function visuNormalizeScalarValue($value): ?string +{ + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_int($value) || is_float($value) || is_string($value)) { + return trim((string)$value); + } + + return null; +} + +function visuPickFirstScalar(array $payload, array $keys): ?string +{ + foreach ($keys as $key) { + if (array_key_exists($key, $payload)) { + $value = visuNormalizeScalarValue($payload[$key]); + + if ($value !== null && $value !== '') { + return $value; + } + } + } + + foreach ($payload as $value) { + if (is_array($value)) { + $nested = visuPickFirstScalar($value, $keys); + + if ($nested !== null && $nested !== '') { + return $nested; + } + } + } + + return null; +} + +function visuObjectIdFromValue($value): int +{ + $scalar = visuNormalizeScalarValue($value); + + if ($scalar === null || $scalar === '') { + return 0; + } + + $clean = str_replace(['.', ',', ' '], '', $scalar); + + if (!preg_match('/^\d+$/', $clean)) { + return 0; + } + + $id = (int)$clean; + return $id > 1000 ? $id : 0; +} + +function visuNormalizeAlarmValue(?string $value, bool $allowObjectIds = false): ?string +{ + if ($value === null) { + return null; + } + + $clean = trim($value); + + if ($clean === '') { + return null; + } + + $lower = mb_strtolower($clean); + + if (in_array($lower, ['ok', 'aus', 'off', 'false', 'nein', 'no', 'normal'], true)) { + return '0'; + } + + if (in_array($lower, ['störung', 'stoerung', 'alarm', 'ein', 'on', 'true', 'ja', 'yes', 'active', 'aktiv'], true)) { + return '1'; + } + + if (preg_match('/-?\d+(?:[.,]\d+)?/', $clean, $match)) { + $numeric = str_replace(',', '.', $match[0]); + + if (!$allowObjectIds && abs((float)$numeric) > 1) { + return null; + } + + return $numeric; + } + + return $clean; +} + +function visuAlarmValueFromResponse(array $response): ?string +{ + $value = visuPickFirstScalar($response, ['value_raw', 'raw_value', 'raw', 'value', 'Value', 'val', 'result', 'formatted', 'state']); + return visuNormalizeAlarmValue($value); +} + +function visuAlarmValueFromPointPayload(array $point): ?string +{ + $value = visuPickFirstScalar($point, ['value_raw', 'raw_value', 'raw', 'value', 'Value', 'val', 'result', 'value_formatted', 'formatted', 'state']); + return visuNormalizeAlarmValue($value); +} + +function visuMatchingAlarmPoint(array $points, array $needle): ?array +{ + $needleKey = (string)($needle['point_key'] ?? ''); + $needleGroup = (string)($needle['group'] ?? ''); + $needleLabel = (string)($needle['raw_label'] ?? ''); + + foreach ($points as $point) { + if (!is_array($point)) { + continue; + } + + if ($needleKey !== '' && (string)($point['key'] ?? '') === $needleKey) { + return $point; + } + + if ( + $needleGroup !== '' + && $needleLabel !== '' + && (string)($point['group'] ?? '') === $needleGroup + && (string)($point['label'] ?? '') === $needleLabel + ) { + return $point; + } + } + + return null; +} + +function visuHydrateAlarmPointValue(array $point): array +{ + $variableId = (int)($point['variable_id'] ?? 0); + $alarmMasterId = + visuObjectIdFromValue($point['value'] ?? null) + ?: visuObjectIdFromValue($point['value_formatted'] ?? null) + ?: visuObjectIdFromValue($point['alarm_master_id'] ?? null); + + if ($variableId <= 0 && $alarmMasterId <= 0) { + return $point; + } + + $rawValue = null; + $sourceResponse = null; + + if ($alarmMasterId > 0) { + $alarmList = visuCallMainApi('getalarms', (string)$alarmMasterId); + $alarmPoints = $alarmList['points'] ?? $alarmList['alarms'] ?? []; + + if (is_array($alarmPoints)) { + $match = visuMatchingAlarmPoint($alarmPoints, $point); + + if (is_array($match)) { + $candidate = visuAlarmValueFromPointPayload($match); + + if ($candidate !== null && $candidate !== '') { + $rawValue = $candidate; + $sourceResponse = $match; + $point['alarm_value_id'] = (int)($match['id'] ?? 0) ?: null; + $point['alarm_master_id'] = $alarmMasterId; + } + } + } + + if ($rawValue === null || $rawValue === '') { + $valueResponse = visuCallMainApi('getvalue', (string)$alarmMasterId); + $candidate = is_array($valueResponse) ? visuAlarmValueFromResponse($valueResponse) : null; + + if ($candidate !== null && $candidate !== '') { + $rawValue = $candidate; + $sourceResponse = $valueResponse; + $point['alarm_value_id'] = $alarmMasterId; + } + } + } + + if ($rawValue === null || $rawValue === '') { + $fallback = visuNormalizeAlarmValue(visuNormalizeScalarValue($point['value'] ?? $point['value_formatted'] ?? null)); + + if ($fallback === null || $fallback === '') { + if ($alarmMasterId > 0) { + $point['value'] = null; + $point['value_formatted'] = null; + $point['quality'] = 'unresolved'; + $point['alarm_master_id'] = $alarmMasterId; + } + + return $point; + } + + $rawValue = $fallback; + } + + $point['value'] = $rawValue; + $point['value_formatted'] = $rawValue; + + $quality = is_array($sourceResponse) ? visuPickFirstScalar($sourceResponse, ['quality', 'Quality']) : null; + if ($quality !== null && $quality !== '') { + $point['quality'] = $quality; + } + + $age = is_array($sourceResponse) ? visuPickFirstScalar($sourceResponse, ['age_s', 'age', 'Age']) : null; + if ($age !== null && $age !== '') { + $point['age_s'] = $age; + } + + return $point; +} + +function visuSourceKeyFromRef(array $ref): string +{ + return implode('|', [ + (string)($ref['type'] ?? $ref['variable_type'] ?? ''), + (string)($ref['external_id'] ?? ''), + (string)($ref['variable_id'] ?? ''), + (string)($ref['point_key'] ?? ''), + ]); +} + +function visuCollectAlarmRefsFromValue($value, array &$sourceKeys, array &$variableIds): void +{ + if (!is_array($value)) { + return; + } + + $type = (string)($value['type'] ?? $value['variable_type'] ?? ''); + + if ($type === 'alarm') { + $sourceKey = visuSourceKeyFromRef($value); + + if ($sourceKey !== '|||') { + $sourceKeys[$sourceKey] = true; + } + + $variableId = (int)($value['variable_id'] ?? 0); + + if ($variableId > 0) { + $variableIds[(string)$variableId] = true; + } + } + + foreach ($value as $item) { + visuCollectAlarmRefsFromValue($item, $sourceKeys, $variableIds); + } +} + +function visuCollectPageAlarmRefs(PDO $pdo, int $pageId): array +{ + $sourceKeys = []; + $variableIds = []; + + $stmt = $pdo->prepare(" + SELECT style_json, state_rules + FROM visu_elements + WHERE page_id = ? + "); + $stmt->execute([$pageId]); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + foreach (['style_json', 'state_rules'] as $column) { + $decoded = json_decode((string)($row[$column] ?? ''), true); + + if (is_array($decoded)) { + visuCollectAlarmRefsFromValue($decoded, $sourceKeys, $variableIds); + } + } + } + + $stmt = $pdo->prepare(" + SELECT r.variable_type, r.external_id, r.variable_id, r.point_key + FROM visu_element_state_rules r + INNER JOIN visu_elements e ON e.id = r.element_id + WHERE e.page_id = ? + AND r.variable_type = 'alarm' + "); + $stmt->execute([$pageId]); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $sourceKey = visuSourceKeyFromRef($row); + + if ($sourceKey !== '|||') { + $sourceKeys[$sourceKey] = true; + } + + $variableId = (int)($row['variable_id'] ?? 0); + + if ($variableId > 0) { + $variableIds[(string)$variableId] = true; + } + } + + return [ + 'source_keys' => $sourceKeys, + 'variable_ids' => $variableIds, + ]; +} + +function visuShouldHydrateAlarmPoint(array $point, array $alarmRefs): bool +{ + if (($point['type'] ?? '') !== 'alarm') { + return false; + } + + $sourceKey = (string)($point['source_key'] ?? visuPointSourceKey($point)); + $variableId = (string)($point['variable_id'] ?? ''); + + return isset($alarmRefs['source_keys'][$sourceKey]) + || ($variableId !== '' && isset($alarmRefs['variable_ids'][$variableId])); +} + +function visuLoadAssetStatesForAssets(PDO $pdo, array $assetIds): array +{ + $assetIds = array_values(array_unique(array_filter(array_map('intval', $assetIds), fn($id) => $id > 0))); + + if (empty($assetIds)) { + return []; + } + + $ph = implode(',', array_fill(0, count($assetIds), '?')); + + $stmt = $pdo->prepare(" + SELECT * + FROM visu_asset_states + WHERE asset_id IN ($ph) + ORDER BY asset_id ASC, is_default DESC, sort_order ASC, id ASC + "); + $stmt->execute($assetIds); + + $statesByAsset = []; + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $statesByAsset[(int)$row['asset_id']][] = $row; + } + + return $statesByAsset; +} + +function visuGetProjectPoints(PDO $pdo, int $projectId, bool $hydrateAlarmValues = false): array +{ + $stmt = $pdo->prepare(" + SELECT isp_number, external_id, notes + FROM project_isps + WHERE project_id = ? + ORDER BY isp_number ASC + "); + $stmt->execute([$projectId]); + $isps = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $allPoints = []; + + foreach ($isps as $isp) { + $externalId = trim((string)($isp['external_id'] ?? '')); + + if (!preg_match('/^\d{5}$/', $externalId)) { + continue; + } + + foreach ([ + 'getanalog' => ['type' => 'analog', 'writable' => false], + 'getdigital' => ['type' => 'digital', 'writable' => false], + 'getalarms' => ['type' => 'alarm', 'writable' => false], + 'getsetpoints' => ['type' => 'setpoint', 'writable' => true], + ] as $fn => $meta) { + $upstream = visuCallMainApi($fn, $externalId); + $points = $upstream['points'] ?? $upstream['alarms'] ?? $upstream['setpoints'] ?? []; + + if (!is_array($points)) { + continue; + } + + foreach ($points as $p) { + if (!is_array($p)) { + continue; + } + + $key = trim((string)($p['key'] ?? '')); + $variableId = (int)($p['id'] ?? $p['variable_id'] ?? 0); + $alarmMasterId = $meta['type'] === 'alarm' + ? ( + visuObjectIdFromValue($p['value_raw'] ?? null) + ?: visuObjectIdFromValue($p['value'] ?? null) + ?: visuObjectIdFromValue($p['value_formatted'] ?? $p['formatted'] ?? null) + ) + : 0; + + if ($key === '' && $variableId <= 0) { + continue; + } + + $point = [ + 'type' => $meta['type'], + 'writable' => (bool)$meta['writable'], + 'external_id' => $externalId, + 'isp_number' => (int)$isp['isp_number'], + 'variable_id' => $variableId > 0 ? $variableId : null, + 'point_key' => $key, + 'label' => visuPointLabel($p, (int)$isp['isp_number'], $meta['type']), + 'raw_label' => (string)($p['label'] ?? ''), + 'group' => (string)($p['group'] ?? ''), + 'unit' => (string)($p['unit'] ?? ''), + 'value' => $p['value'] ?? $p['value_raw'] ?? null, + 'value_formatted' => $p['value_formatted'] ?? $p['formatted'] ?? $p['value_raw'] ?? $p['value'] ?? null, + 'quality' => $p['quality'] ?? null, + 'age_s' => $p['age_s'] ?? null, + 'alarm_master_id' => $alarmMasterId > 0 ? $alarmMasterId : null, + ]; + + if ($hydrateAlarmValues && $meta['type'] === 'alarm') { + $point = visuHydrateAlarmPointValue($point); + } + + $point['source_key'] = visuPointSourceKey($point); + $allPoints[] = $point; + } + } + } + + return $allPoints; +} + +function visuBuildLiveValueMap(array $points): array +{ + $values = []; + + foreach ($points as $point) { + if (!is_array($point)) { + continue; + } + + $sourceKey = (string)($point['source_key'] ?? ''); + + if ($sourceKey !== '') { + $values[$sourceKey] = $point; + } + + if (!empty($point['variable_id'])) { + $variableKey = (string)$point['variable_id']; + + if ( + !isset($values[$variableKey]) + || !visuPointHasDisplayValue($values[$variableKey]) + || visuPointHasDisplayValue($point) + ) { + $values[$variableKey] = $point; + } + } + } + + return $values; +} + +function visuVirtualPointsTableReady(PDO $pdo): bool +{ + static $ready = null; + + if ($ready !== null) { + return $ready; + } + + try { + $pdo->query("SELECT 1 FROM visu_virtual_points LIMIT 1"); + $ready = true; + } catch (Throwable $e) { + $ready = false; + } + + return $ready; +} + +function visuVirtualPointSourceKey(int $projectId, string $pointKey): string +{ + return implode('|', ['virtual', 'vdp:' . $projectId, '', $pointKey]); +} + +function visuVirtualPointNumericValue($value): ?float +{ + if ($value === null || $value === '') { + return null; + } + + if (is_bool($value)) { + return $value ? 1.0 : 0.0; + } + + if (is_int($value) || is_float($value)) { + return (float)$value; + } + + $text = trim((string)$value); + + if ($text === '') { + return null; + } + + $text = str_replace(',', '.', $text); + + if (preg_match('/-?\d+(?:\.\d+)?/', $text, $match)) { + return (float)$match[0]; + } + + return null; +} + +function visuVirtualPointFormat(?float $value, int $decimals, string $unit = ''): ?string +{ + if ($value === null) { + return null; + } + + $precision = max(0, min(8, $decimals)); + $formatted = number_format($value, $precision, ',', '.'); + $unit = trim($unit); + + return $unit !== '' ? ($formatted . ' ' . $unit) : $formatted; +} + +function visuVirtualPointRows(PDO $pdo, int $projectId): array +{ + if (!visuVirtualPointsTableReady($pdo)) { + return []; + } + + try { + $stmt = $pdo->prepare(" + SELECT * + FROM visu_virtual_points + WHERE project_id = ? + AND is_active = 1 + ORDER BY sort_order ASC, name ASC, id ASC + "); + $stmt->execute([$projectId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + return []; + } +} + +function visuVirtualPointRefFromToken(array $token): ?array +{ + $type = (string)($token['type'] ?? ''); + + if ($type !== 'point' && !is_array($token['ref'] ?? null)) { + return null; + } + + $ref = is_array($token['ref'] ?? null) ? $token['ref'] : []; + + foreach (['type', 'variable_type', 'external_id', 'variable_id', 'point_key', 'point_label', 'label', 'source_key'] as $key) { + if (array_key_exists($key, $token) && !array_key_exists($key, $ref)) { + $ref[$key] = $token[$key]; + } + } + + return !empty($ref) ? $ref : null; +} + +function visuVirtualPointTokensToRpn(array $tokens): array +{ + $output = []; + $stack = []; + $precedence = ['+' => 1, '-' => 1, '*' => 2, '/' => 2]; + + foreach ($tokens as $token) { + if (!is_array($token)) { + continue; + } + + $type = (string)($token['type'] ?? ''); + + if ($type === 'number') { + $value = visuVirtualPointNumericValue($token['value'] ?? null); + + if ($value === null) { + continue; + } + + $output[] = ['kind' => 'number', 'value' => $value]; + continue; + } + + if ($type === 'point') { + $ref = visuVirtualPointRefFromToken($token); + + if ($ref) { + $output[] = ['kind' => 'point', 'ref' => $ref]; + } + + continue; + } + + if ($type !== 'operator') { + continue; + } + + $value = trim((string)($token['value'] ?? '')); + + if ($value === '') { + continue; + } + + if ($value === '(') { + $stack[] = $value; + continue; + } + + if ($value === ')') { + while (!empty($stack) && end($stack) !== '(') { + $output[] = ['kind' => 'operator', 'value' => array_pop($stack)]; + } + + if (!empty($stack) && end($stack) === '(') { + array_pop($stack); + } + + continue; + } + + if (!isset($precedence[$value])) { + continue; + } + + while (!empty($stack)) { + $top = end($stack); + + if ($top === '(' || !isset($precedence[$top]) || $precedence[$top] < $precedence[$value]) { + break; + } + + $output[] = ['kind' => 'operator', 'value' => array_pop($stack)]; + } + + $stack[] = $value; + } + + while (!empty($stack)) { + $operator = array_pop($stack); + + if ($operator === '(' || $operator === ')') { + continue; + } + + $output[] = ['kind' => 'operator', 'value' => $operator]; + } + + return $output; +} + +function visuEvaluateVirtualPointRow(array $row, array &$context): array +{ + $pointKey = trim((string)($row['point_key'] ?? '')); + + if ($pointKey === '') { + return ['ok' => false, 'value' => null, 'error' => 'VDP ohne Punkt-Key.']; + } + + if (isset($context['cache'][$pointKey])) { + return $context['cache'][$pointKey]; + } + + if (!empty($context['visiting'][$pointKey])) { + return ['ok' => false, 'value' => null, 'error' => 'Zyklische VDP-Referenz: ' . $pointKey]; + } + + $context['visiting'][$pointKey] = true; + + $tokens = json_decode((string)($row['formula_json'] ?? '[]'), true); + + if (!is_array($tokens)) { + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => 'Ungültige Formel.']; + } + + $rpn = visuVirtualPointTokensToRpn($tokens); + + if (empty($rpn)) { + unset($context['visiting'][$pointKey]); + $result = ['ok' => false, 'value' => null, 'error' => 'Leere Formel.']; + $context['cache'][$pointKey] = $result; + return $result; + } + + $stack = []; + + foreach ($rpn as $token) { + if (($token['kind'] ?? '') === 'number') { + $stack[] = (float)$token['value']; + continue; + } + + if (($token['kind'] ?? '') === 'point') { + $ref = is_array($token['ref'] ?? null) ? $token['ref'] : null; + + if (!$ref) { + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => 'Ungültige VDP-Quelle.']; + } + + $refType = (string)($ref['type'] ?? $ref['variable_type'] ?? ''); + $numericValue = null; + + if ($refType === 'virtual' || str_starts_with((string)($ref['external_id'] ?? ''), 'vdp:')) { + $virtualPointKey = trim((string)($ref['point_key'] ?? '')); + $virtualRow = $virtualPointKey !== '' ? ($context['rows'][$virtualPointKey] ?? null) : null; + + if (!$virtualRow) { + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => 'VDP-Quelle nicht gefunden: ' . $virtualPointKey]; + } + + $virtualResult = visuEvaluateVirtualPointRow($virtualRow, $context); + + if (empty($virtualResult['ok'])) { + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => (string)($virtualResult['error'] ?? 'VDP-Fehler')]; + } + + $numericValue = $virtualResult['value']; + } else { + $sourceKey = !empty($ref['source_key']) ? (string)$ref['source_key'] : visuSourceKeyFromRef($ref); + $point = $context['points'][$sourceKey] ?? null; + + if (!$point && !empty($ref['variable_id'])) { + $point = $context['points'][(string)$ref['variable_id']] ?? null; + } + + $numericValue = $point ? visuVirtualPointNumericValue($point['value'] ?? $point['value_formatted'] ?? null) : null; + } + + if ($numericValue === null) { + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => 'Quelle ohne numerischen Wert.']; + } + + $stack[] = $numericValue; + continue; + } + + if (($token['kind'] ?? '') === 'operator') { + if (count($stack) < 2) { + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => 'Unvollständige Formel.']; + } + + $right = array_pop($stack); + $left = array_pop($stack); + + switch ((string)($token['value'] ?? '')) { + case '+': + $stack[] = $left + $right; + break; + case '-': + $stack[] = $left - $right; + break; + case '*': + $stack[] = $left * $right; + break; + case '/': + if ((float)$right === 0.0) { + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => 'Division durch 0.']; + } + $stack[] = $left / $right; + break; + default: + unset($context['visiting'][$pointKey]); + return ['ok' => false, 'value' => null, 'error' => 'Unbekannter Operator.']; + } + } + } + + unset($context['visiting'][$pointKey]); + + if (count($stack) !== 1) { + $result = ['ok' => false, 'value' => null, 'error' => 'Formel konnte nicht ausgewertet werden.']; + $context['cache'][$pointKey] = $result; + return $result; + } + + $result = ['ok' => true, 'value' => (float)$stack[0], 'error' => '']; + $context['cache'][$pointKey] = $result; + return $result; +} + +function visuGetProjectVirtualPoints(PDO $pdo, int $projectId, array $basePoints): array +{ + $rows = visuVirtualPointRows($pdo, $projectId); + + if (empty($rows)) { + return []; + } + + $rowsByPointKey = []; + + foreach ($rows as $row) { + $pointKey = trim((string)($row['point_key'] ?? '')); + + if ($pointKey !== '') { + $rowsByPointKey[$pointKey] = $row; + } + } + + $context = [ + 'rows' => $rowsByPointKey, + 'points' => visuBuildLiveValueMap($basePoints), + 'cache' => [], + 'visiting' => [], + ]; + + $virtualPoints = []; + + foreach ($rows as $row) { + $pointKey = trim((string)($row['point_key'] ?? '')); + + if ($pointKey === '') { + continue; + } + + $result = visuEvaluateVirtualPointRow($row, $context); + $value = !empty($result['ok']) ? $result['value'] : null; + $unit = trim((string)($row['unit'] ?? '')); + $decimals = (int)($row['decimals'] ?? 2); + + $virtualPoints[] = [ + 'type' => 'virtual', + 'writable' => false, + 'external_id' => 'vdp:' . $projectId, + 'isp_number' => 0, + 'variable_id' => null, + 'point_key' => $pointKey, + 'label' => trim((string)($row['name'] ?? $pointKey)), + 'raw_label' => trim((string)($row['name'] ?? $pointKey)), + 'group' => 'Virtuelle Datenpunkte', + 'unit' => $unit, + 'value' => $value, + 'value_formatted' => visuVirtualPointFormat($value, $decimals, $unit), + 'quality' => !empty($result['ok']) ? 'virtual' : 'error', + 'age_s' => 0, + 'source_key' => visuVirtualPointSourceKey($projectId, $pointKey), + 'description' => trim((string)($row['description'] ?? '')), + 'virtual_error' => empty($result['ok']) ? (string)($result['error'] ?? 'VDP Fehler') : '', + ]; + } + + return $virtualPoints; +} + +function visuGetProjectPointsWithVirtual(PDO $pdo, int $projectId, bool $hydrateAlarmValues = false): array +{ + $points = visuGetProjectPoints($pdo, $projectId, $hydrateAlarmValues); + $virtualPoints = visuGetProjectVirtualPoints($pdo, $projectId, $points); + + return array_merge($points, $virtualPoints); +} + +function visuAllowedInfluxDatabases(PDO $pdo, int $userId, bool $canViewAll): array +{ + if ($canViewAll) { + $stmt = $pdo->query(" + SELECT DISTINCT database_name + FROM role_influx_databases + WHERE database_name <> '' + ORDER BY database_name ASC + "); + + return array_values(array_filter(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)))); + } + + $stmt = $pdo->prepare(" + SELECT DISTINCT rid.database_name + FROM role_influx_databases rid + INNER JOIN user_roles ur ON ur.role_id = rid.role_id + WHERE ur.user_id = ? + AND rid.database_name <> '' + ORDER BY rid.database_name ASC + "); + $stmt->execute([$userId]); + + return array_values(array_filter(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)))); +} + +function visuInfluxIdentifier(string $value): string +{ + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; +} + +function visuInfluxString(string $value): string +{ + return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; +} + +function visuInfluxQuery(string $database, string $query): array +{ + $url = rtrim(VISU_INFLUX_URL, '/') . '/query?' . http_build_query([ + 'db' => $database, + 'q' => $query, + ]); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 12); + + $resp = curl_exec($ch); + $err = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($err) { + return ['ok' => false, 'error' => $err]; + } + + $decoded = json_decode((string)$resp, true); + + if (!is_array($decoded)) { + return ['ok' => false, 'error' => 'Influx liefert kein JSON.', 'raw' => (string)$resp]; + } + + if ($status >= 400) { + return ['ok' => false, 'error' => $decoded['error'] ?? ('Influx HTTP ' . $status)]; + } + + $result = $decoded['results'][0] ?? []; + + if (!empty($result['error'])) { + return ['ok' => false, 'error' => (string)$result['error']]; + } + + return ['ok' => true, 'result' => $result]; +} + +function visuInfluxSeriesValues(array $response): array +{ + $series = $response['result']['series'][0] ?? null; + + if (!$series || empty($series['values']) || !is_array($series['values'])) { + return []; + } + + return $series['values']; +} + +function visuInfluxListMeasurements(string $database): array +{ + $response = visuInfluxQuery($database, 'SHOW MEASUREMENTS'); + + if (empty($response['ok'])) { + return []; + } + + return array_values(array_filter(array_map( + fn($row) => (string)($row[0] ?? ''), + visuInfluxSeriesValues($response) + ))); +} + +function visuInfluxListTrendPoints(string $database): array +{ + $points = []; + $measurements = visuInfluxListMeasurements($database); + + foreach ($measurements as $measurement) { + $tagKeyResponse = visuInfluxQuery($database, 'SHOW TAG KEYS FROM ' . visuInfluxIdentifier($measurement)); + + if (empty($tagKeyResponse['ok'])) { + continue; + } + + foreach (visuInfluxSeriesValues($tagKeyResponse) as $tagKeyRow) { + $tagKey = (string)($tagKeyRow[0] ?? ''); + + if ($tagKey === '') { + continue; + } + + $tagValueResponse = visuInfluxQuery( + $database, + 'SHOW TAG VALUES FROM ' . visuInfluxIdentifier($measurement) . ' WITH KEY = ' . visuInfluxIdentifier($tagKey) + ); + + if (empty($tagValueResponse['ok'])) { + continue; + } + + foreach (visuInfluxSeriesValues($tagValueResponse) as $tagValueRow) { + $value = (string)($tagValueRow[1] ?? $tagValueRow[0] ?? ''); + + if ($value === '') { + continue; + } + + $points[] = [ + 'database' => $database, + 'measurement' => $measurement, + 'tag_key' => $tagKey, + 'tag_value' => $value, + 'label' => $measurement . ' · ' . $value, + 'source_key' => implode('|', ['influx', $database, $measurement, $tagKey, $value]), + ]; + } + } + } + + usort($points, fn($a, $b) => strcmp($a['label'], $b['label'])); + return $points; +} + +function visuTrendRange(string $range): array +{ + $ranges = [ + '15m' => ['sql' => '15m', 'seconds' => 900], + '1h' => ['sql' => '1h', 'seconds' => 3600], + '6h' => ['sql' => '6h', 'seconds' => 21600], + '12h' => ['sql' => '12h', 'seconds' => 43200], + '24h' => ['sql' => '24h', 'seconds' => 86400], + '7d' => ['sql' => '7d', 'seconds' => 604800], + '30d' => ['sql' => '30d', 'seconds' => 2592000], + '90d' => ['sql' => '90d', 'seconds' => 7776000], + '180d' => ['sql' => '180d', 'seconds' => 15552000], + ]; + + return $ranges[$range] ?? $ranges['24h']; +} + +function visuTrendCustomRange($start, $end): ?array +{ + if ($start === null || $end === null || $start === '' || $end === '') { + return null; + } + + $startTs = is_numeric($start) ? ((float)$start / 1000) : strtotime((string)$start); + $endTs = is_numeric($end) ? ((float)$end / 1000) : strtotime((string)$end); + + if (!$startTs || !$endTs) { + return null; + } + + if ($endTs < $startTs) { + [$startTs, $endTs] = [$endTs, $startTs]; + } + + if (($endTs - $startTs) < 1) { + return null; + } + + return [ + 'sql' => sprintf( + "time >= '%s' AND time <= '%s'", + gmdate('Y-m-d\TH:i:s\Z', (int)$startTs), + gmdate('Y-m-d\TH:i:s\Z', (int)$endTs) + ), + 'seconds' => max(1, (int)round($endTs - $startTs)), + 'start' => gmdate('c', (int)$startTs), + 'end' => gmdate('c', (int)$endTs), + ]; +} + +function visuTrendInterval(string $interval, int $rangeSeconds): string +{ + $allowed = ['10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '1d']; + + if ($interval !== 'auto' && in_array($interval, $allowed, true)) { + return $interval; + } + + if ($rangeSeconds <= 900) return '10s'; + if ($rangeSeconds <= 3600) return '30s'; + if ($rangeSeconds <= 21600) return '5m'; + if ($rangeSeconds <= 86400) return '10m'; + if ($rangeSeconds <= 604800) return '1h'; + return '6h'; +} + +function visuTrendAggregate(string $aggregate): string +{ + return in_array($aggregate, ['mean', 'min', 'max', 'last'], true) ? $aggregate : 'mean'; +} + +function visuInfluxReadTrendSeries(array $series, array $allowedDatabases, string $range, string $interval, string $aggregate, $start = null, $end = null): array +{ + $customRange = visuTrendCustomRange($start, $end); + $rangeInfo = $customRange ?: visuTrendRange($range); + $bucket = visuTrendInterval($interval, (int)$rangeInfo['seconds']); + $agg = visuTrendAggregate($aggregate); + $out = []; + + foreach (array_slice($series, 0, 8) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $database = (string)($item['database'] ?? ''); + $measurement = (string)($item['measurement'] ?? ''); + $tagKey = (string)($item['tag_key'] ?? ''); + $tagValue = (string)($item['tag_value'] ?? ''); + + if ($database === '' || $measurement === '' || $tagKey === '' || $tagValue === '') { + continue; + } + + if (!in_array($database, $allowedDatabases, true)) { + continue; + } + + $query = sprintf( + 'SELECT %s("value") FROM %s WHERE %s = %s AND %s GROUP BY time(%s) fill(null)', + $agg, + visuInfluxIdentifier($measurement), + visuInfluxIdentifier($tagKey), + visuInfluxString($tagValue), + $customRange ? $rangeInfo['sql'] : 'time >= now() - ' . $rangeInfo['sql'], + $bucket + ); + + $response = visuInfluxQuery($database, $query); + $values = []; + + if (!empty($response['ok'])) { + foreach (visuInfluxSeriesValues($response) as $row) { + $values[] = [ + 'time' => (string)($row[0] ?? ''), + 'value' => $row[1] ?? null, + ]; + } + } + + $out[] = [ + 'id' => (string)($item['id'] ?? ('series-' . $index)), + 'label' => (string)($item['label'] ?? $tagValue), + 'color' => (string)($item['color'] ?? '#38bdf8'), + 'database' => $database, + 'measurement' => $measurement, + 'tag_key' => $tagKey, + 'tag_value' => $tagValue, + 'values' => $values, + 'error' => empty($response['ok']) ? (string)($response['error'] ?? 'Influx Fehler') : '', + ]; + } + + return [ + 'range' => $customRange ? 'custom' : $range, + 'interval' => $bucket, + 'aggregate' => $agg, + 'start' => $customRange['start'] ?? null, + 'end' => $customRange['end'] ?? null, + 'series' => $out, + ]; +} + +function visuInfluxReadLatestValues(array $series, array $allowedDatabases): array +{ + $out = []; + + foreach (array_slice($series, 0, 64) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $database = (string)($item['database'] ?? ''); + $measurement = (string)($item['measurement'] ?? ''); + $tagKey = (string)($item['tag_key'] ?? ''); + $tagValue = (string)($item['tag_value'] ?? ''); + + if ($database === '' || $measurement === '' || $tagKey === '' || $tagValue === '') { + continue; + } + + if (!in_array($database, $allowedDatabases, true)) { + continue; + } + + $query = sprintf( + 'SELECT last("value") FROM %s WHERE %s = %s', + visuInfluxIdentifier($measurement), + visuInfluxIdentifier($tagKey), + visuInfluxString($tagValue) + ); + + $response = visuInfluxQuery($database, $query); + $value = null; + + if (!empty($response['ok'])) { + $rows = visuInfluxSeriesValues($response); + $value = $rows[0][1] ?? null; + } + + $out[] = [ + 'id' => (string)($item['id'] ?? ('series-' . $index)), + 'database' => $database, + 'measurement' => $measurement, + 'tag_key' => $tagKey, + 'tag_value' => $tagValue, + 'value' => $value, + 'error' => empty($response['ok']) ? (string)($response['error'] ?? 'Influx Fehler') : '', + ]; + } + + return $out; +} + +function visuCsvDelimiter($value): string +{ + $value = (string)$value; + return in_array($value, [',', ';', "\t"], true) ? $value : ';'; +} + +function visuCsvExportKind(string $value): string +{ + return in_array($value, ['trend_csv', 'lastvalues_csv'], true) ? $value : 'trend_csv'; +} + +function visuCsvScheduleKind(string $value): string +{ + return in_array($value, ['interval', 'daily', 'weekly', 'monthly'], true) ? $value : 'daily'; +} + +function visuCsvTimeOfDay(string $value): string +{ + $value = trim($value); + if (!preg_match('/^(\d{2}):(\d{2})$/', $value, $m)) { + return '00:00'; + } + + $hour = max(0, min(23, (int)$m[1])); + $minute = max(0, min(59, (int)$m[2])); + return sprintf('%02d:%02d', $hour, $minute); +} + +function visuCsvWeekdays(string $value): string +{ + $items = array_filter(array_map('trim', explode(',', $value)), static fn($item) => $item !== ''); + $days = []; + + foreach ($items as $item) { + $day = (int)$item; + if ($day >= 1 && $day <= 7) { + $days[] = $day; + } + } + + $days = array_values(array_unique($days)); + sort($days, SORT_NUMERIC); + + return implode(',', $days); +} + +function visuCsvIntervalMinutes($value): int +{ + return max(1, min(525600, (int)$value)); +} + +function visuCsvMonthDay($value): int +{ + return max(1, min(31, (int)$value)); +} + +function visuCsvRetentionDays($value): int +{ + return max(1, min(3650, (int)$value)); +} + +function visuCsvTargetDir(string $value): string +{ + $value = str_replace('\\', '/', trim($value)); + $parts = array_filter(explode('/', $value), static function ($part) { + return $part !== '' && $part !== '.' && $part !== '..'; + }); + + $clean = []; + foreach ($parts as $part) { + $cleanPart = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $part); + $cleanPart = trim((string)$cleanPart, '._-'); + if ($cleanPart !== '') { + $clean[] = $cleanPart; + } + } + + return !empty($clean) ? implode('/', $clean) : 'csv_exports'; +} + +function visuCsvFilenamePattern(string $value): string +{ + $value = trim($value); + return $value !== '' ? mb_substr($value, 0, 255) : '{selection}_{yyyy}-{mm}-{dd}_{hh}-{ii}-{ss}.csv'; +} + +function visuCsvNextRunAt( + string $scheduleKind, + int $intervalMinutes, + string $timeOfDay, + string $weekdays, + int $monthDay, + ?DateTimeImmutable $now = null +): string { + $now = $now ?: new DateTimeImmutable('now'); + [$hour, $minute] = array_map('intval', explode(':', visuCsvTimeOfDay($timeOfDay))); + $scheduleKind = visuCsvScheduleKind($scheduleKind); + + if ($scheduleKind === 'interval') { + return $now->modify('+' . visuCsvIntervalMinutes($intervalMinutes) . ' minutes')->format('Y-m-d H:i:s'); + } + + if ($scheduleKind === 'daily') { + $candidate = $now->setTime($hour, $minute, 0); + if ($candidate <= $now) { + $candidate = $candidate->modify('+1 day'); + } + return $candidate->format('Y-m-d H:i:s'); + } + + if ($scheduleKind === 'weekly') { + $days = array_filter(array_map('intval', explode(',', visuCsvWeekdays($weekdays))), static fn($day) => $day >= 1 && $day <= 7); + if (empty($days)) { + $days = [(int)$now->format('N')]; + } + + for ($offset = 0; $offset <= 14; $offset++) { + $candidateDay = $now->modify('+' . $offset . ' days'); + if (!in_array((int)$candidateDay->format('N'), $days, true)) { + continue; + } + + $candidate = $candidateDay->setTime($hour, $minute, 0); + if ($candidate > $now) { + return $candidate->format('Y-m-d H:i:s'); + } + } + } + + if ($scheduleKind === 'monthly') { + $monthDay = visuCsvMonthDay($monthDay); + $base = $now; + + for ($i = 0; $i < 14; $i++) { + $monthStart = $base->modify('first day of this month')->setTime($hour, $minute, 0); + $daysInMonth = (int)$monthStart->format('t'); + $day = min($monthDay, $daysInMonth); + $candidate = $monthStart->setDate( + (int)$monthStart->format('Y'), + (int)$monthStart->format('m'), + $day + ); + + if ($candidate > $now) { + return $candidate->format('Y-m-d H:i:s'); + } + + $base = $base->modify('first day of next month'); + } + } + + $fallback = $now->modify('+1 day')->setTime($hour, $minute, 0); + return $fallback->format('Y-m-d H:i:s'); +} + +function visuCsvExportJobsTableReady(PDO $pdo): bool +{ + try { + $pdo->query('SELECT 1 FROM visu_csv_export_jobs LIMIT 1'); + return true; + } catch (Throwable $e) { + return false; + } +} + +function visuCsvExportFilesTableReady(PDO $pdo): bool +{ + try { + $pdo->query('SELECT 1 FROM visu_csv_export_files LIMIT 1'); + return true; + } catch (Throwable $e) { + return false; + } +} + +function visuCsvExportAbsolutePath(string $relativePath): string +{ + $relativePath = str_replace('\\', '/', trim($relativePath)); + $relativePath = preg_replace('#/+#', '/', $relativePath); + $relativePath = ltrim((string)$relativePath, '/'); + return VISU_EXPORT_STORAGE_ROOT . '/' . $relativePath; +} + +$username = (string)($_SESSION['username'] ?? ''); +$userId = resolveUserId($pdo, (int)($_SESSION['user_id'] ?? 0), $username); +$groups = visuUserGroups($pdo, $username, $_SESSION['groups'] ?? []); + +$action = (string)($_GET['action'] ?? $_POST['action'] ?? ''); + +$canViewViewer = hasPermission($pdo, $userId, 'page.visu.viewer.view'); +$canViewAll = hasPermission($pdo, $userId, 'page.visu.viewer.viewall'); +$canViewDesigner = hasPermission($pdo, $userId, 'page.visu.designer.view'); +$canEditDesigner = hasPermission($pdo, $userId, 'page.visu.designer.edit'); +$canManageAssets = hasPermission($pdo, $userId, 'page.visu.assets.manage'); +$canEditAssets = $canManageAssets || hasPermission($pdo, $userId, 'page.visu.assets.edit'); +$canDeleteAssets = $canManageAssets || hasPermission($pdo, $userId, 'page.visu.assets.delete'); +$canWriteSetpoints = hasPermission($pdo, $userId, 'page.visu.setpoints.write'); + +if (!$canViewViewer && !$canViewDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Zugriff auf SE-Cloud Vis.'], 403); +} + +if ($action === 'projects') { + $params = []; + $visSql = visuVisibilitySql($userId, $canViewAll, 'p.id', $params, 'projects'); + + $stmt = $pdo->prepare(" + SELECT + p.id, + p.year, + p.project_number, + p.name, + pc.company_name AS customer_name + FROM projects p + LEFT JOIN project_customers pc ON pc.id = p.customer_id + WHERE {$visSql} + AND EXISTS ( + SELECT 1 + FROM project_isps pi + WHERE pi.project_id = p.id + ) + ORDER BY p.year DESC, p.project_number ASC, p.name ASC + "); + $stmt->execute($params); + + visuJson([ + 'ok' => true, + 'projects' => $stmt->fetchAll(PDO::FETCH_ASSOC), + ]); +} + +if ($action === 'virtual_points') { + $projectId = (int)($_GET['project_id'] ?? 0); + + if ($projectId <= 0) { + visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); + } + + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + if (!visuVirtualPointsTableReady($pdo)) { + visuJson([ + 'ok' => false, + 'error' => 'Tabelle visu_virtual_points fehlt. Bitte SQL-Migration ausfuehren.', + ], 500); + } + + $rows = visuVirtualPointRows($pdo, $projectId); + + foreach ($rows as &$row) { + $formula = json_decode((string)($row['formula_json'] ?? '[]'), true); + $row['formula'] = is_array($formula) ? $formula : []; + unset($row['formula_json']); + } + unset($row); + + visuJson([ + 'ok' => true, + 'virtual_points' => $rows, + 'can_edit' => $canEditDesigner, + ]); +} + +if ($action === 'virtual_point_save') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); + } + + if (!visuVirtualPointsTableReady($pdo)) { + visuJson([ + 'ok' => false, + 'error' => 'Tabelle visu_virtual_points fehlt. Bitte SQL-Migration ausfuehren.', + ], 500); + } + + $pointId = (int)($_POST['point_id'] ?? 0); + $projectId = (int)($_POST['project_id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $pointKey = trim((string)($_POST['point_key'] ?? '')); + $description = visuCleanString($_POST['description'] ?? ''); + $unit = trim((string)($_POST['unit'] ?? '')); + $decimals = max(0, min(8, (int)($_POST['decimals'] ?? 2))); + $sortOrder = (int)($_POST['sort_order'] ?? 100); + $formulaRaw = (string)($_POST['formula_json'] ?? '[]'); + $formula = json_decode($formulaRaw, true); + + if ($projectId <= 0 || $name === '') { + visuJson(['ok' => false, 'error' => 'Projekt oder Name fehlt.'], 400); + } + + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + if ($pointKey === '') { + $pointKey = visuSlug($name); + } else { + $pointKey = visuSlug($pointKey); + } + + if ($pointKey === '') { + visuJson(['ok' => false, 'error' => 'Punkt-Key fehlt.'], 400); + } + + if (!is_array($formula)) { + visuJson(['ok' => false, 'error' => 'Ungültige Formel.'], 400); + } + + $formulaJson = json_encode($formula, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + try { + $check = $pdo->prepare(" + SELECT id + FROM visu_virtual_points + WHERE project_id = ? + AND point_key = ? + AND id <> ? + LIMIT 1 + "); + $check->execute([$projectId, $pointKey, $pointId]); + + if ($check->fetchColumn()) { + visuJson(['ok' => false, 'error' => 'Punkt-Key ist bereits vergeben.'], 400); + } + + if ($pointId > 0) { + $stmt = $pdo->prepare(" + UPDATE visu_virtual_points + SET + project_id = ?, + point_key = ?, + name = ?, + description = ?, + unit = ?, + decimals = ?, + sort_order = ?, + formula_json = ?, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + $stmt->execute([ + $projectId, + $pointKey, + $name, + $description, + $unit, + $decimals, + $sortOrder, + $formulaJson, + $pointId, + ]); + + visuJson(['ok' => true, 'point_id' => $pointId]); + } + + $stmt = $pdo->prepare(" + INSERT INTO visu_virtual_points + ( + project_id, + point_key, + name, + description, + unit, + decimals, + sort_order, + formula_json, + created_by, + is_active + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + "); + $stmt->execute([ + $projectId, + $pointKey, + $name, + $description, + $unit, + $decimals, + $sortOrder, + $formulaJson, + $userId, + ]); + + visuJson(['ok' => true, 'point_id' => (int)$pdo->lastInsertId()]); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => $e->getMessage()], 500); + } +} + +if ($action === 'virtual_point_delete') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen.'], 403); + } + + if (!visuVirtualPointsTableReady($pdo)) { + visuJson([ + 'ok' => false, + 'error' => 'Tabelle visu_virtual_points fehlt. Bitte SQL-Migration ausfuehren.', + ], 500); + } + + $pointId = (int)($_POST['point_id'] ?? 0); + + if ($pointId <= 0) { + visuJson(['ok' => false, 'error' => 'VDP fehlt.'], 400); + } + + $stmt = $pdo->prepare(" + UPDATE visu_virtual_points + SET + is_active = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + $stmt->execute([$pointId]); + + visuJson(['ok' => true]); +} + +if ($action === 'project_values') { + $projectId = (int)($_GET['project_id'] ?? 0); + + if ($projectId <= 0) { + visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); + } + + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + visuJson([ + 'ok' => true, + 'values' => visuGetProjectPointsWithVirtual($pdo, $projectId, true), + 'can_write_setpoints' => $canWriteSetpoints, + ]); +} + +if ($action === 'trend_sources') { + $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); + $database = visuCleanString($_GET['database'] ?? '') ?: ($allowedDatabases[0] ?? ''); + + if (empty($allowedDatabases)) { + visuJson([ + 'ok' => true, + 'databases' => [], + 'selected_database' => '', + 'points' => [], + ]); + } + + if (!in_array($database, $allowedDatabases, true)) { + visuJson(['ok' => false, 'error' => 'Keine Berechtigung fuer diese Influx-Datenbank.'], 403); + } + + visuJson([ + 'ok' => true, + 'databases' => $allowedDatabases, + 'selected_database' => $database, + 'points' => visuInfluxListTrendPoints($database), + ]); +} + +if ($action === 'trend_data') { + $seriesRaw = (string)($_POST['series_json'] ?? $_GET['series_json'] ?? '[]'); + $range = (string)($_POST['range'] ?? $_GET['range'] ?? '24h'); + $interval = (string)($_POST['interval'] ?? $_GET['interval'] ?? 'auto'); + $aggregate = (string)($_POST['aggregate'] ?? $_GET['aggregate'] ?? 'mean'); + $start = $_POST['start'] ?? $_GET['start'] ?? null; + $end = $_POST['end'] ?? $_GET['end'] ?? null; + + $series = json_decode($seriesRaw, true); + + if (!is_array($series)) { + visuJson(['ok' => false, 'error' => 'Ungueltige Trend-Serien.'], 400); + } + + $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); + $data = visuInfluxReadTrendSeries($series, $allowedDatabases, $range, $interval, $aggregate, $start, $end); + + visuJson([ + 'ok' => true, + 'data' => $data, + ]); +} + +if ($action === 'influx_latest_values') { + $seriesRaw = (string)($_POST['series_json'] ?? $_GET['series_json'] ?? '[]'); + $series = json_decode($seriesRaw, true); + + if (!is_array($series)) { + visuJson(['ok' => false, 'error' => 'Ungueltige Influx-Serien.'], 400); + } + + $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); + $data = visuInfluxReadLatestValues($series, $allowedDatabases); + + visuJson([ + 'ok' => true, + 'series' => $data, + ]); +} + +if ($action === 'monitoring_selections') { + try { + $stmt = $pdo->prepare(" + SELECT + id, + name, + database_name, + range_value, + interval_value, + aggregate_value, + delimiter_value, + points_json, + updated_at + FROM visu_monitoring_selections + WHERE user_id = ? + AND is_active = 1 + ORDER BY updated_at DESC, name ASC + "); + $stmt->execute([$userId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_monitoring_selections fehlt oder ist nicht erreichbar.'], 500); + } + + foreach ($rows as &$row) { + $decoded = json_decode((string)($row['points_json'] ?? '[]'), true); + $row['points'] = is_array($decoded) ? $decoded : []; + $row['csv_delimiter'] = (string)($row['delimiter_value'] ?? ';'); + unset($row['points_json']); + } + unset($row); + + visuJson([ + 'ok' => true, + 'selections' => $rows, + ]); +} + +if ($action === 'monitoring_selection_save') { + $selectionId = (int)($_POST['selection_id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $database = visuCleanString($_POST['database_name'] ?? $_POST['database'] ?? ''); + $range = (string)($_POST['range_value'] ?? $_POST['range'] ?? '24h'); + $interval = (string)($_POST['interval_value'] ?? $_POST['interval'] ?? 'auto'); + $aggregate = visuTrendAggregate((string)($_POST['aggregate_value'] ?? $_POST['aggregate'] ?? 'last')); + $delimiter = (string)($_POST['csv_delimiter'] ?? $_POST['delimiter_value'] ?? $_POST['delimiter'] ?? ';'); + $pointsRaw = (string)($_POST['points_json'] ?? '[]'); + $points = json_decode($pointsRaw, true); + + if ($name === '') { + visuJson(['ok' => false, 'error' => 'Name fehlt.'], 400); + } + + if (!is_array($points)) { + visuJson(['ok' => false, 'error' => 'Ungueltige Datenpunktliste.'], 400); + } + + $allowedDatabases = visuAllowedInfluxDatabases($pdo, $userId, $canViewAll); + if ($database && !in_array($database, $allowedDatabases, true)) { + visuJson(['ok' => false, 'error' => 'Keine Berechtigung fuer diese Influx-Datenbank.'], 403); + } + + $range = in_array($range, ['15m', '1h', '6h', '12h', '24h', '7d', '30d', '90d', '180d'], true) ? $range : '24h'; + $interval = in_array($interval, ['auto', '10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '1d'], true) ? $interval : 'auto'; + $delimiter = visuCsvDelimiter($delimiter); + $pointsJson = json_encode(array_values($points), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + try { + if ($selectionId > 0) { + $stmt = $pdo->prepare(" + UPDATE visu_monitoring_selections + SET + name = ?, + database_name = ?, + range_value = ?, + interval_value = ?, + aggregate_value = ?, + delimiter_value = ?, + points_json = ?, + is_active = 1 + WHERE id = ? + AND user_id = ? + "); + $stmt->execute([$name, $database, $range, $interval, $aggregate, $delimiter, $pointsJson, $selectionId, $userId]); + } else { + $stmt = $pdo->prepare(" + INSERT INTO visu_monitoring_selections + (user_id, name, database_name, range_value, interval_value, aggregate_value, delimiter_value, points_json, is_active) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE + database_name = VALUES(database_name), + range_value = VALUES(range_value), + interval_value = VALUES(interval_value), + aggregate_value = VALUES(aggregate_value), + delimiter_value = VALUES(delimiter_value), + points_json = VALUES(points_json), + is_active = 1 + "); + $stmt->execute([$userId, $name, $database, $range, $interval, $aggregate, $delimiter, $pointsJson]); + $selectionId = (int)$pdo->lastInsertId(); + } + + if ($selectionId <= 0) { + $stmt = $pdo->prepare(" + SELECT id + FROM visu_monitoring_selections + WHERE user_id = ? + AND name = ? + LIMIT 1 + "); + $stmt->execute([$userId, $name]); + $selectionId = (int)($stmt->fetchColumn() ?: 0); + } + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Selection konnte nicht gespeichert werden. Ist die Tabelle angelegt?'], 500); + } + + visuJson([ + 'ok' => true, + 'selection_id' => $selectionId, + ]); +} + +if ($action === 'monitoring_selection_delete') { + $selectionId = (int)($_POST['selection_id'] ?? 0); + + if ($selectionId <= 0) { + visuJson(['ok' => false, 'error' => 'Selection fehlt.'], 400); + } + + try { + $stmt = $pdo->prepare(" + UPDATE visu_monitoring_selections + SET is_active = 0 + WHERE id = ? + AND user_id = ? + "); + $stmt->execute([$selectionId, $userId]); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Selection konnte nicht geloescht werden.'], 500); + } + + visuJson(['ok' => true]); +} + +if ($action === 'csv_export_jobs') { + $projectId = (int)($_GET['project_id'] ?? 0); + + if ($projectId <= 0) { + visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); + } + + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + if (!visuCsvExportJobsTableReady($pdo)) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_jobs fehlt. Bitte SQL-Migration ausfuehren.'], 500); + } + + try { + $stmt = $pdo->prepare(" + SELECT + j.*, + s.name AS selection_name + FROM visu_csv_export_jobs j + LEFT JOIN visu_monitoring_selections s + ON s.id = j.selection_id + AND s.user_id = ? + AND s.is_active = 1 + WHERE j.project_id = ? + ORDER BY j.updated_at DESC, j.name ASC + "); + $stmt->execute([$userId, $projectId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Exportjobs konnten nicht geladen werden.'], 500); + } + + foreach ($rows as &$row) { + $row['enabled'] = (int)($row['enabled'] ?? 0); + $row['interval_minutes'] = (int)($row['interval_minutes'] ?? 60); + $row['month_day'] = (int)($row['month_day'] ?? 1); + $row['retention_days'] = (int)($row['retention_days'] ?? 30); + $row['project_id'] = (int)($row['project_id'] ?? 0); + $row['selection_id'] = (int)($row['selection_id'] ?? 0); + } + unset($row); + + visuJson([ + 'ok' => true, + 'jobs' => $rows, + 'can_edit' => $canEditDesigner, + ]); +} + +if ($action === 'csv_export_job_save') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); + } + + if (!visuCsvExportJobsTableReady($pdo)) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_jobs fehlt. Bitte SQL-Migration ausfuehren.'], 500); + } + + $jobId = (int)($_POST['job_id'] ?? 0); + $projectId = (int)($_POST['project_id'] ?? 0); + $selectionId = (int)($_POST['selection_id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $exportKind = visuCsvExportKind((string)($_POST['export_kind'] ?? 'trend_csv')); + $csvDelimiter = visuCsvDelimiter($_POST['csv_delimiter'] ?? ';'); + $enabled = !empty($_POST['enabled']) ? 1 : 0; + $scheduleKind = visuCsvScheduleKind((string)($_POST['schedule_kind'] ?? 'daily')); + $intervalMinutes = visuCsvIntervalMinutes($_POST['interval_minutes'] ?? 60); + $timeOfDay = visuCsvTimeOfDay((string)($_POST['time_of_day'] ?? '00:00')); + $weekdays = visuCsvWeekdays((string)($_POST['weekdays'] ?? '')); + $monthDay = visuCsvMonthDay($_POST['month_day'] ?? 1); + $targetDir = visuCsvTargetDir((string)($_POST['target_dir'] ?? 'csv_exports')); + $filenamePattern = visuCsvFilenamePattern((string)($_POST['filename_pattern'] ?? '')); + $retentionDays = visuCsvRetentionDays($_POST['retention_days'] ?? 30); + + if ($projectId <= 0 || $selectionId <= 0 || $name === '') { + visuJson(['ok' => false, 'error' => 'Projekt, Selection oder Name fehlt.'], 400); + } + + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + $selectionStmt = $pdo->prepare(" + SELECT id, name + FROM visu_monitoring_selections + WHERE id = ? + AND user_id = ? + AND is_active = 1 + LIMIT 1 + "); + $selectionStmt->execute([$selectionId, $userId]); + $selectionRow = $selectionStmt->fetch(PDO::FETCH_ASSOC); + + if (!$selectionRow) { + visuJson(['ok' => false, 'error' => 'Selection nicht gefunden oder nicht erlaubt.'], 404); + } + + if ($scheduleKind === 'weekly' && $weekdays === '') { + $weekdays = (string)(new DateTimeImmutable('now'))->format('N'); + } + + $nextRunAt = $enabled + ? visuCsvNextRunAt($scheduleKind, $intervalMinutes, $timeOfDay, $weekdays, $monthDay) + : null; + + try { + if ($jobId > 0) { + $stmt = $pdo->prepare(" + UPDATE visu_csv_export_jobs + SET + project_id = ?, + selection_id = ?, + name = ?, + export_kind = ?, + csv_delimiter = ?, + enabled = ?, + schedule_kind = ?, + interval_minutes = ?, + time_of_day = ?, + weekdays = ?, + month_day = ?, + target_dir = ?, + filename_pattern = ?, + retention_days = ?, + next_run_at = ? + WHERE id = ? + "); + $stmt->execute([ + $projectId, + $selectionId, + $name, + $exportKind, + $csvDelimiter, + $enabled, + $scheduleKind, + $intervalMinutes, + $timeOfDay, + $weekdays, + $monthDay, + $targetDir, + $filenamePattern, + $retentionDays, + $nextRunAt, + $jobId, + ]); + } else { + $stmt = $pdo->prepare(" + INSERT INTO visu_csv_export_jobs ( + project_id, + selection_id, + name, + export_kind, + csv_delimiter, + enabled, + schedule_kind, + interval_minutes, + time_of_day, + weekdays, + month_day, + target_dir, + filename_pattern, + retention_days, + next_run_at, + created_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $projectId, + $selectionId, + $name, + $exportKind, + $csvDelimiter, + $enabled, + $scheduleKind, + $intervalMinutes, + $timeOfDay, + $weekdays, + $monthDay, + $targetDir, + $filenamePattern, + $retentionDays, + $nextRunAt, + $userId, + ]); + $jobId = (int)$pdo->lastInsertId(); + } + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Exportjob konnte nicht gespeichert werden.'], 500); + } + + visuJson([ + 'ok' => true, + 'job_id' => $jobId, + 'next_run_at' => $nextRunAt, + ]); +} + +if ($action === 'csv_export_job_delete') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen.'], 403); + } + + if (!visuCsvExportJobsTableReady($pdo)) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_jobs fehlt. Bitte SQL-Migration ausfuehren.'], 500); + } + + $jobId = (int)($_POST['job_id'] ?? 0); + if ($jobId <= 0) { + visuJson(['ok' => false, 'error' => 'Job fehlt.'], 400); + } + + try { + $stmt = $pdo->prepare("DELETE FROM visu_csv_export_jobs WHERE id = ?"); + $stmt->execute([$jobId]); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Exportjob konnte nicht geloescht werden.'], 500); + } + + visuJson(['ok' => true]); +} + +if ($action === 'csv_export_files') { + $projectId = (int)($_GET['project_id'] ?? 0); + $jobId = (int)($_GET['job_id'] ?? 0); + + if ($projectId <= 0) { + visuJson(['ok' => false, 'error' => 'Projekt fehlt.'], 400); + } + + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + if (!visuCsvExportFilesTableReady($pdo)) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_files fehlt. Bitte SQL-Migration ausfuehren.'], 500); + } + + try { + $sql = " + SELECT * + FROM visu_csv_export_files + WHERE project_id = ? + "; + $params = [$projectId]; + + if ($jobId > 0) { + $sql .= " AND job_id = ?"; + $params[] = $jobId; + } + + $sql .= " ORDER BY created_at DESC, id DESC LIMIT 500"; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Exportdateien konnten nicht geladen werden.'], 500); + } + + foreach ($rows as &$row) { + $row['job_id'] = (int)($row['job_id'] ?? 0); + $row['project_id'] = (int)($row['project_id'] ?? 0); + $row['selection_id'] = (int)($row['selection_id'] ?? 0); + $row['file_size'] = (int)($row['file_size'] ?? 0); + $row['row_count'] = (int)($row['row_count'] ?? 0); + } + unset($row); + + visuJson([ + 'ok' => true, + 'files' => $rows, + 'can_edit' => $canEditDesigner, + ]); +} + +if ($action === 'csv_export_file_delete') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen.'], 403); + } + + if (!visuCsvExportFilesTableReady($pdo)) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_files fehlt. Bitte SQL-Migration ausfuehren.'], 500); + } + + $fileId = (int)($_POST['file_id'] ?? 0); + if ($fileId <= 0) { + visuJson(['ok' => false, 'error' => 'Datei fehlt.'], 400); + } + + try { + $stmt = $pdo->prepare("SELECT file_path FROM visu_csv_export_files WHERE id = ? LIMIT 1"); + $stmt->execute([$fileId]); + $filePath = (string)($stmt->fetchColumn() ?: ''); + + if ($filePath !== '') { + $absolutePath = visuCsvExportAbsolutePath($filePath); + if (is_file($absolutePath)) { + @unlink($absolutePath); + } + } + + $stmt = $pdo->prepare(" + UPDATE visu_csv_export_files + SET status = 'deleted' + WHERE id = ? + "); + $stmt->execute([$fileId]); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Exportdatei konnte nicht markiert werden.'], 500); + } + + visuJson(['ok' => true]); +} + +if ($action === 'csv_export_file_download') { + $fileId = (int)($_GET['file_id'] ?? 0); + if ($fileId <= 0) { + visuJson(['ok' => false, 'error' => 'Datei fehlt.'], 400); + } + + if (!visuCsvExportFilesTableReady($pdo)) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_csv_export_files fehlt. Bitte SQL-Migration ausfuehren.'], 500); + } + + $stmt = $pdo->prepare(" + SELECT id, project_id, file_name, file_path, status + FROM visu_csv_export_files + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$fileId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + visuJson(['ok' => false, 'error' => 'Datei nicht gefunden.'], 404); + } + + $projectId = (int)($row['project_id'] ?? 0); + if ($projectId <= 0 || !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Kein Zugriff auf diese Exportdatei.'], 403); + } + + if (($row['status'] ?? '') !== 'success') { + visuJson(['ok' => false, 'error' => 'Datei ist noch nicht verfuegbar.'], 409); + } + + $absolutePath = visuCsvExportAbsolutePath((string)($row['file_path'] ?? '')); + if (!is_file($absolutePath)) { + visuJson(['ok' => false, 'error' => 'Datei fehlt im Export-Speicher.'], 404); + } + + header_remove('Content-Type'); + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Length: ' . (string)filesize($absolutePath)); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', (string)($row['file_name'] ?? 'export.csv')) . '"'); + readfile($absolutePath); + exit; +} + +if ($action === 'pages') { + $params = []; + $visSql = visuVisibilitySql($userId, $canViewAll, 'vp.project_id', $params, 'pages'); + + $stmt = $pdo->prepare(" + SELECT + vp.id, + vp.project_id, + vp.name, + vp.slug, + vp.description, + vp.width, + vp.height, + vp.background_color, + vp.background_image, + vp.grid_enabled, + vp.grid_size, + vp.snap_enabled, + vp.is_active, + vp.created_at, + vp.updated_at, + p.project_number, + p.year, + p.name AS project_name, + pc.company_name AS customer_name + FROM visu_pages vp + INNER JOIN projects p ON p.id = vp.project_id + LEFT JOIN project_customers pc ON pc.id = p.customer_id + WHERE vp.is_active = 1 + AND {$visSql} + ORDER BY p.year DESC, p.project_number ASC, vp.name ASC + "); + $stmt->execute($params); + + visuJson([ + 'ok' => true, + 'pages' => $stmt->fetchAll(PDO::FETCH_ASSOC), + 'can_edit' => $canEditDesigner, + 'can_design' => $canViewDesigner, + 'can_manage_assets' => $canManageAssets, + 'can_edit_assets' => $canEditAssets, + 'can_delete_assets' => $canDeleteAssets, + 'can_write_setpoints' => $canWriteSetpoints, + ]); +} + +if ($action === 'dashboard_widgets') { + $params = [':dashboard_user_id' => $userId]; + $visSql = visuVisibilitySql($userId, $canViewAll, 'dw.project_id', $params, 'dashboard_widgets'); + + $stmt = $pdo->prepare(" + SELECT + dw.*, + p.project_number, + p.year, + p.name AS project_name, + pc.company_name AS customer_name + FROM visu_dashboard_widgets dw + LEFT JOIN projects p ON p.id = dw.project_id + LEFT JOIN project_customers pc ON pc.id = p.customer_id + WHERE dw.user_id = :dashboard_user_id + AND dw.is_active = 1 + AND (dw.project_id IS NULL OR {$visSql}) + ORDER BY dw.sort_order ASC, dw.grid_y ASC, dw.grid_x ASC, dw.id ASC + "); + $stmt->execute($params); + + visuJson([ + 'ok' => true, + 'widgets' => $stmt->fetchAll(PDO::FETCH_ASSOC), + ]); +} + +if ($action === 'dashboard_widget_save') { + $widgetId = (int)($_POST['widget_id'] ?? 0); + $widgetType = trim((string)($_POST['widget_type'] ?? '')); + $title = visuCleanString($_POST['title'] ?? ''); + $projectId = (int)($_POST['project_id'] ?? 0); + $gridX = max(0, (int)($_POST['grid_x'] ?? 0)); + $gridY = max(0, (int)($_POST['grid_y'] ?? 0)); + $gridW = max(1, min(4, (int)($_POST['grid_w'] ?? 1))); + $gridH = max(1, min(4, (int)($_POST['grid_h'] ?? 1))); + $sortOrder = (int)($_POST['sort_order'] ?? 100); + $configJson = trim((string)($_POST['config_json'] ?? '{}')); + + if (!in_array($widgetType, ['simple_value', 'value_table', 'scale_bar', 'dial_gauge'], true)) { + visuJson(['ok' => false, 'error' => 'Ungültiger Widget-Typ.'], 400); + } + + $config = json_decode($configJson, true); + if (!is_array($config)) { + visuJson(['ok' => false, 'error' => 'Ungültige Widget-Konfiguration.'], 400); + } + + if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + $projectValue = $projectId > 0 ? $projectId : null; + $configText = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($widgetId > 0) { + $stmt = $pdo->prepare(" + UPDATE visu_dashboard_widgets + SET + widget_type = ?, + title = ?, + project_id = ?, + grid_x = ?, + grid_y = ?, + grid_w = ?, + grid_h = ?, + sort_order = ?, + config_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + AND user_id = ? + "); + $stmt->execute([ + $widgetType, + $title, + $projectValue, + $gridX, + $gridY, + $gridW, + $gridH, + $sortOrder, + $configText, + $widgetId, + $userId, + ]); + + if ($stmt->rowCount() === 0) { + $chk = $pdo->prepare("SELECT 1 FROM visu_dashboard_widgets WHERE id = ? AND user_id = ? LIMIT 1"); + $chk->execute([$widgetId, $userId]); + + if (!$chk->fetchColumn()) { + visuJson(['ok' => false, 'error' => 'Widget nicht gefunden.'], 404); + } + } + + visuJson(['ok' => true, 'widget_id' => $widgetId]); + } + + $stmt = $pdo->prepare(" + INSERT INTO visu_dashboard_widgets + ( + user_id, + project_id, + widget_type, + title, + grid_x, + grid_y, + grid_w, + grid_h, + sort_order, + config_json, + is_active + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + "); + $stmt->execute([ + $userId, + $projectValue, + $widgetType, + $title, + $gridX, + $gridY, + $gridW, + $gridH, + $sortOrder, + $configText, + ]); + + visuJson(['ok' => true, 'widget_id' => (int)$pdo->lastInsertId()]); +} + +if ($action === 'dashboard_widget_delete') { + $widgetId = (int)($_POST['widget_id'] ?? 0); + + if ($widgetId <= 0) { + visuJson(['ok' => false, 'error' => 'Widget fehlt.'], 400); + } + + $stmt = $pdo->prepare(" + UPDATE visu_dashboard_widgets + SET + is_active = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + AND user_id = ? + "); + $stmt->execute([$widgetId, $userId]); + + if ($stmt->rowCount() === 0) { + visuJson(['ok' => false, 'error' => 'Widget nicht gefunden.'], 404); + } + + visuJson(['ok' => true]); +} + +if ($action === 'templates') { + if (!$canViewDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Zugriff auf Templates.'], 403); + } + + $stmt = $pdo->query(" + SELECT + id, + name, + description, + width, + height, + placeholders_json, + updated_at + FROM visu_templates + WHERE is_active = 1 + ORDER BY updated_at DESC, name ASC + "); + + $templates = $stmt->fetchAll(PDO::FETCH_ASSOC); + + foreach ($templates as &$template) { + $placeholders = json_decode((string)($template['placeholders_json'] ?? '[]'), true); + $template['placeholder_count'] = is_array($placeholders) ? count($placeholders) : 0; + unset($template['placeholders_json']); + } + unset($template); + + visuJson(['ok' => true, 'templates' => $templates, 'can_edit' => $canEditDesigner]); +} + +if ($action === 'template') { + if (!$canViewDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Zugriff auf Templates.'], 403); + } + + $templateId = (int)($_GET['id'] ?? 0); + $stmt = $pdo->prepare("SELECT * FROM visu_templates WHERE id = ? AND is_active = 1 LIMIT 1"); + $stmt->execute([$templateId]); + $template = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$template) { + visuJson(['ok' => false, 'error' => 'Template nicht gefunden.'], 404); + } + + $placeholders = json_decode((string)($template['placeholders_json'] ?? '[]'), true); + $elements = json_decode((string)($template['elements_json'] ?? '[]'), true); + + unset($template['placeholders_json'], $template['elements_json']); + + visuJson([ + 'ok' => true, + 'template' => $template, + 'placeholders' => is_array($placeholders) ? $placeholders : [], + 'elements' => is_array($elements) ? $elements : [], + 'can_edit' => $canEditDesigner, + ]); +} + +if ($action === 'template_save' || $action === 'template_settings_save') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); + } + + $templateId = (int)($_POST['template_id'] ?? 0); + $name = visuCleanString($_POST['name'] ?? null) ?: 'Template'; + $description = visuCleanString($_POST['description'] ?? null); + $width = max(320, (int)($_POST['width'] ?? 1920)); + $height = max(240, (int)($_POST['height'] ?? 1080)); + $backgroundColor = visuCleanString($_POST['background_color'] ?? null) ?: '#0f172a'; + $gridEnabled = !empty($_POST['grid_enabled']) ? 1 : 0; + $snapEnabled = !empty($_POST['snap_enabled']) ? 1 : 0; + $gridSize = max(2, (int)($_POST['grid_size'] ?? 40)); + $placeholdersRaw = (string)($_POST['placeholders_json'] ?? '[]'); + $elementsRaw = (string)($_POST['elements_json'] ?? ''); + + $placeholders = json_decode($placeholdersRaw, true); + if (!is_array($placeholders)) { + visuJson(['ok' => false, 'error' => 'Ungültige Platzhalter.'], 400); + } + + $placeholdersJson = json_encode($placeholders, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($templateId > 0) { + $stmt = $pdo->prepare("SELECT elements_json, background_image FROM visu_templates WHERE id = ? AND is_active = 1 LIMIT 1"); + $stmt->execute([$templateId]); + $oldTemplate = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$oldTemplate) { + visuJson(['ok' => false, 'error' => 'Template nicht gefunden.'], 404); + } + + $backgroundImage = (string)($oldTemplate['background_image'] ?? ''); + $uploaded = visuSaveUploadedImage('background_image', VISU_BG_UPLOAD_DIR, VISU_BG_UPLOAD_URL); + + if ($uploaded) { + visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); + $backgroundImage = $uploaded; + } + + if (!empty($_POST['remove_background_image'])) { + visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); + $backgroundImage = null; + } + + if ($elementsRaw !== '') { + $elements = json_decode($elementsRaw, true); + if (!is_array($elements)) { + visuJson(['ok' => false, 'error' => 'Ungültige Elemente.'], 400); + } + $elementsJson = json_encode($elements, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $elementsJson = (string)($oldTemplate['elements_json'] ?? '[]'); + } + + $stmt = $pdo->prepare(" + UPDATE visu_templates + SET name = ?, + description = ?, + width = ?, + height = ?, + background_color = ?, + background_image = ?, + grid_enabled = ?, + snap_enabled = ?, + grid_size = ?, + placeholders_json = ?, + elements_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + $stmt->execute([ + $name, + $description, + $width, + $height, + $backgroundColor, + $backgroundImage, + $gridEnabled, + $snapEnabled, + $gridSize, + $placeholdersJson, + $elementsJson, + $templateId, + ]); + } else { + $elements = $elementsRaw !== '' ? json_decode($elementsRaw, true) : []; + if (!is_array($elements)) { + visuJson(['ok' => false, 'error' => 'Ungültige Elemente.'], 400); + } + + $stmt = $pdo->prepare(" + INSERT INTO visu_templates + (user_id, name, description, width, height, background_color, background_image, grid_enabled, snap_enabled, grid_size, placeholders_json, elements_json) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $userId, + $name, + $description, + $width, + $height, + $backgroundColor, + visuSaveUploadedImage('background_image', VISU_BG_UPLOAD_DIR, VISU_BG_UPLOAD_URL) ?: null, + $gridEnabled, + $snapEnabled, + $gridSize, + $placeholdersJson, + json_encode($elements, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + $templateId = (int)$pdo->lastInsertId(); + } + + visuJson(['ok' => true, 'template_id' => $templateId]); +} + +if ($action === 'template_delete') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen.'], 403); + } + + $templateId = (int)($_POST['template_id'] ?? 0); + $stmt = $pdo->prepare("UPDATE visu_templates SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$templateId]); + visuJson(['ok' => true]); +} + +if ($action === 'template_import') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Importieren.'], 403); + } + + $payload = json_decode((string)($_POST['import_json'] ?? ''), true); + if (!is_array($payload)) { + visuJson(['ok' => false, 'error' => 'Ungültige Import-Datei.'], 400); + } + + $template = is_array($payload['template'] ?? null) ? $payload['template'] : []; + $placeholders = is_array($payload['placeholders'] ?? null) ? $payload['placeholders'] : []; + $elements = is_array($payload['elements'] ?? null) ? $payload['elements'] : []; + + $stmt = $pdo->prepare(" + INSERT INTO visu_templates + (user_id, name, description, width, height, background_color, background_image, grid_enabled, snap_enabled, grid_size, placeholders_json, elements_json) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $userId, + visuCleanString(($template['name'] ?? 'Importiertes Template') . ' Import') ?: 'Importiertes Template', + visuCleanString($template['description'] ?? null), + max(320, (int)($template['width'] ?? 1920)), + max(240, (int)($template['height'] ?? 1080)), + visuCleanString($template['background_color'] ?? null) ?: '#0f172a', + visuCleanString($template['background_image'] ?? null), + !empty($template['grid_enabled']) ? 1 : 0, + !empty($template['snap_enabled']) ? 1 : 0, + max(2, (int)($template['grid_size'] ?? 40)), + json_encode($placeholders, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + json_encode($elements, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + + visuJson(['ok' => true, 'template_id' => (int)$pdo->lastInsertId()]); +} + +if ($action === 'page') { + $pageId = (int)($_GET['id'] ?? 0); + $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); + + if (!$page) { + visuJson(['ok' => false, 'error' => 'Visualisierung nicht gefunden.'], 404); + } + + $stmt = $pdo->prepare(" + SELECT + ve.*, + va.name AS asset_name, + va.asset_type + FROM visu_elements ve + LEFT JOIN visu_assets va ON va.id = ve.asset_id + WHERE ve.page_id = ? + ORDER BY ve.z_index ASC, ve.id ASC + "); + $stmt->execute([$pageId]); + $elements = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $elementIds = array_map(fn($e) => (int)$e['id'], $elements); + $assetIds = array_map(fn($e) => (int)($e['asset_id'] ?? 0), $elements); + + $statesByAsset = visuLoadAssetStatesForAssets($pdo, $assetIds); + $rulesByElement = []; + + if (!empty($elementIds)) { + $ph = implode(',', array_fill(0, count($elementIds), '?')); + + $stmt = $pdo->prepare(" + SELECT + r.*, + s.state_key, + s.label AS state_label, + s.image_file + FROM visu_element_state_rules r + INNER JOIN visu_asset_states s ON s.id = r.asset_state_id + WHERE r.element_id IN ($ph) + ORDER BY r.element_id ASC, r.priority ASC, r.id ASC + "); + $stmt->execute($elementIds); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $eid = (int)$row['element_id']; + $rulesByElement[$eid][] = $row; + } + } + + foreach ($elements as &$el) { + $eid = (int)$el['id']; + $aid = (int)($el['asset_id'] ?? 0); + $el['rules'] = $rulesByElement[$eid] ?? []; + $el['asset_states'] = $statesByAsset[$aid] ?? []; + } + unset($el); + + visuJson([ + 'ok' => true, + 'page' => $page, + 'elements' => $elements, + 'can_edit' => $canEditDesigner, + 'can_design' => $canViewDesigner, + 'can_manage_assets' => $canManageAssets, + 'can_edit_assets' => $canEditAssets, + 'can_delete_assets' => $canDeleteAssets, + 'can_write_setpoints' => $canWriteSetpoints, + ]); +} + +if ($action === 'page_create') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Erstellen.'], 403); + } + + $projectId = (int)($_POST['project_id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $description = visuCleanString($_POST['description'] ?? ''); + $width = (int)($_POST['width'] ?? 1920); + $height = (int)($_POST['height'] ?? 1080); + + if ($projectId <= 0 || $name === '') { + visuJson(['ok' => false, 'error' => 'Projekt oder Name fehlt.'], 400); + } + + if ($width < 320 || $width > 10000) { + $width = 1920; + } + + if ($height < 240 || $height > 10000) { + $height = 1080; + } + + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + $slugBase = visuSlug($name); + $slug = $slugBase; + $i = 2; + + while (true) { + $chk = $pdo->prepare("SELECT 1 FROM visu_pages WHERE project_id = ? AND slug = ? LIMIT 1"); + $chk->execute([$projectId, $slug]); + + if (!$chk->fetchColumn()) { + break; + } + + $slug = $slugBase . '-' . $i; + $i++; + } + + $stmt = $pdo->prepare(" + INSERT INTO visu_pages + (project_id, name, slug, description, width, height, is_active, created_by) + VALUES + (?, ?, ?, ?, ?, ?, 1, ?) + "); + $stmt->execute([$projectId, $name, $slug, $description, $width, $height, $userId]); + + visuJson([ + 'ok' => true, + 'page_id' => (int)$pdo->lastInsertId(), + ]); +} + +if ($action === 'page_settings_save') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Bearbeiten.'], 403); + } + + $pageId = (int)($_POST['page_id'] ?? 0); + $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); + + if (!$page) { + visuJson(['ok' => false, 'error' => 'Seite nicht gefunden.'], 404); + } + + $backgroundColor = trim((string)($_POST['background_color'] ?? '#0f172a')); + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $backgroundColor)) { + $backgroundColor = '#0f172a'; + } + + $gridEnabled = !empty($_POST['grid_enabled']) ? 1 : 0; + $snapEnabled = !empty($_POST['snap_enabled']) ? 1 : 0; + $gridSize = (int)($_POST['grid_size'] ?? 40); + $width = (int)($_POST['width'] ?? $page['width'] ?? 1920); + $height = (int)($_POST['height'] ?? $page['height'] ?? 1080); + + if ($gridSize < 5) { + $gridSize = 5; + } + + if ($gridSize > 200) { + $gridSize = 200; + } + + if ($width < 320 || $width > 10000) { + $width = (int)($page['width'] ?? 1920); + } + + if ($height < 240 || $height > 10000) { + $height = (int)($page['height'] ?? 1080); + } + + $backgroundImage = (string)($page['background_image'] ?? ''); + $uploaded = visuSaveUploadedImage('background_image', VISU_BG_UPLOAD_DIR, VISU_BG_UPLOAD_URL); + + if ($uploaded) { + visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); + $backgroundImage = $uploaded; + } + + if (!empty($_POST['remove_background_image'])) { + visuDeleteUploadedFile($backgroundImage, VISU_BG_UPLOAD_URL, VISU_BG_UPLOAD_DIR); + $backgroundImage = null; + } + + $stmt = $pdo->prepare(" + UPDATE visu_pages + SET + width = ?, + height = ?, + background_color = ?, + background_image = ?, + grid_enabled = ?, + grid_size = ?, + snap_enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + $stmt->execute([ + $width, + $height, + $backgroundColor, + $backgroundImage, + $gridEnabled, + $gridSize, + $snapEnabled, + $pageId, + ]); + + visuJson(['ok' => true]); +} + +if ($action === 'page_delete') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen.'], 403); + } + + $pageId = (int)($_POST['page_id'] ?? 0); + $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); + + if (!$page) { + visuJson(['ok' => false, 'error' => 'Seite nicht gefunden.'], 404); + } + + $stmt = $pdo->prepare("UPDATE visu_pages SET is_active = 0 WHERE id = ?"); + $stmt->execute([$pageId]); + + visuJson(['ok' => true]); +} + +if ($action === 'assets') { + $projectId = (int)($_GET['project_id'] ?? 0); + + $params = []; + $where = ["1=1"]; + + if ($projectId > 0) { + if (!visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + $where[] = "(va.is_global = 1 OR va.project_id = :project_id)"; + $params[':project_id'] = $projectId; + } else { + $where[] = "va.is_global = 1"; + } + + $stmt = $pdo->prepare(" + SELECT + va.id, + va.project_id, + va.name, + va.description, + va.asset_type, + va.is_global, + va.created_at + FROM visu_assets va + WHERE " . implode(' AND ', $where) . " + ORDER BY va.asset_type ASC, va.name ASC + "); + $stmt->execute($params); + $assets = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $assetIds = array_map(fn($a) => (int)$a['id'], $assets); + $statesByAsset = visuLoadAssetStatesForAssets($pdo, $assetIds); + + foreach ($assets as &$asset) { + $asset['states'] = $statesByAsset[(int)$asset['id']] ?? []; + } + unset($asset); + + visuJson([ + 'ok' => true, + 'assets' => $assets, + 'can_manage_assets' => $canManageAssets, + 'can_edit_assets' => $canEditAssets, + 'can_delete_assets' => $canDeleteAssets, + ]); +} + +if ($action === 'image_library') { + if (!$canViewDesigner && !$canEditAssets) { + visuJson(['ok' => false, 'error' => 'Kein Zugriff auf Bilder.'], 403); + } + + $projectId = (int)($_GET['project_id'] ?? 0); + + if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + try { + $stmt = $pdo->prepare(" + INSERT IGNORE INTO visu_image_library + (project_id, file_url, original_name, created_by, is_active) + SELECT + MIN(va.project_id) AS project_id, + vas.image_file, + SUBSTRING_INDEX(vas.image_file, '/', -1) AS original_name, + ? AS created_by, + 1 AS is_active + FROM visu_asset_states vas + INNER JOIN visu_assets va ON va.id = vas.asset_id + WHERE vas.image_file LIKE ? + GROUP BY vas.image_file + "); + $stmt->execute([$userId, rtrim(VISU_ASSET_UPLOAD_URL, '/') . '/%']); + + $params = []; + $where = ['vil.is_active = 1']; + + if ($projectId > 0) { + $where[] = '(vil.project_id IS NULL OR vil.project_id = :project_id)'; + $params[':project_id'] = $projectId; + } + + $stmt = $pdo->prepare(" + SELECT + vil.id, + vil.project_id, + vil.file_url, + vil.original_name, + vil.mime_type, + vil.file_size, + vil.created_at, + ( + SELECT COUNT(*) + FROM visu_asset_states vas + WHERE vas.image_file = vil.file_url + ) AS usage_count + FROM visu_image_library vil + WHERE " . implode(' AND ', $where) . " + ORDER BY vil.created_at DESC, vil.id DESC + "); + $stmt->execute($params); + $images = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Tabelle visu_image_library fehlt oder ist nicht erreichbar.'], 500); + } + + visuJson([ + 'ok' => true, + 'images' => $images, + 'can_edit' => $canEditAssets, + 'can_delete' => $canDeleteAssets, + ]); +} + +if ($action === 'image_library_upload') { + if (!$canEditAssets) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Hochladen von Bildern.'], 403); + } + + $projectId = (int)($_POST['project_id'] ?? 0); + $libraryProjectId = $projectId > 0 ? $projectId : null; + + if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + $files = $_FILES['images'] ?? null; + $uploaded = []; + + if (!$files || !is_array($files)) { + visuJson(['ok' => false, 'error' => 'Keine Bilder uebergeben.'], 400); + } + + if (is_array($files['name'] ?? null)) { + $count = count($files['name']); + for ($i = 0; $i < $count; $i++) { + $file = [ + 'name' => $files['name'][$i] ?? '', + 'type' => $files['type'][$i] ?? '', + 'tmp_name' => $files['tmp_name'][$i] ?? '', + 'error' => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE, + 'size' => $files['size'][$i] ?? 0, + ]; + $saved = visuSaveUploadedImageFile($file, VISU_ASSET_UPLOAD_DIR, VISU_ASSET_UPLOAD_URL); + if ($saved) { + visuImageLibraryRegister($pdo, $saved['url'], $saved['original_name'], $saved['mime_type'], $saved['file_size'], $libraryProjectId, $userId); + $uploaded[] = $saved; + } + } + } else { + $saved = visuSaveUploadedImageFile($files, VISU_ASSET_UPLOAD_DIR, VISU_ASSET_UPLOAD_URL); + if ($saved) { + visuImageLibraryRegister($pdo, $saved['url'], $saved['original_name'], $saved['mime_type'], $saved['file_size'], $libraryProjectId, $userId); + $uploaded[] = $saved; + } + } + + visuJson([ + 'ok' => true, + 'images' => $uploaded, + ]); +} + +if ($action === 'image_library_delete') { + if (!$canDeleteAssets) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Loeschen von Bildern.'], 403); + } + + $imageId = (int)($_POST['image_id'] ?? 0); + + if ($imageId <= 0) { + visuJson(['ok' => false, 'error' => 'Bild fehlt.'], 400); + } + + try { + $stmt = $pdo->prepare(" + SELECT + vil.file_url, + ( + SELECT COUNT(*) + FROM visu_asset_states vas + WHERE vas.image_file = vil.file_url + ) AS usage_count + FROM visu_image_library vil + WHERE vil.id = ? + AND vil.is_active = 1 + LIMIT 1 + "); + $stmt->execute([$imageId]); + $image = $stmt->fetch(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + visuJson(['ok' => false, 'error' => 'Bilderbibliothek ist nicht erreichbar.'], 500); + } + + if (!$image) { + visuJson(['ok' => false, 'error' => 'Bild nicht gefunden.'], 404); + } + + if ((int)($image['usage_count'] ?? 0) > 0) { + visuJson(['ok' => false, 'error' => 'Bild wird noch verwendet und kann nicht geloescht werden.'], 409); + } + + $stmt = $pdo->prepare("UPDATE visu_image_library SET is_active = 0 WHERE id = ?"); + $stmt->execute([$imageId]); + visuDeleteUploadedFile((string)$image['file_url'], VISU_ASSET_UPLOAD_URL, VISU_ASSET_UPLOAD_DIR); + + visuJson(['ok' => true]); +} + +if ($action === 'asset_create') { + if (!$canEditAssets) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Erstellen von Symbolen.'], 403); + } + + $name = trim((string)($_POST['name'] ?? '')); + $description = visuCleanString($_POST['description'] ?? ''); + $assetType = (string)($_POST['asset_type'] ?? 'custom'); + $projectId = (int)($_POST['project_id'] ?? 0); + $isGlobal = !empty($_POST['is_global']) ? 1 : 0; + + if ($name === '') { + visuJson(['ok' => false, 'error' => 'Name fehlt.'], 400); + } + + if (!in_array($assetType, ['background', 'pump', 'heat_generation', 'cold_generation', 'heat_recovery', 'valve', 'fan', 'sensor', 'text', 'custom'], true)) { + $assetType = 'custom'; + } + + if ($projectId > 0 && !visuProjectVisible($pdo, $projectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Projekt nicht sichtbar.'], 403); + } + + if ($projectId <= 0) { + $projectId = null; + $isGlobal = 1; + } + + $stmt = $pdo->prepare(" + INSERT INTO visu_assets + (project_id, name, description, asset_type, is_global, created_by) + VALUES + (?, ?, ?, ?, ?, ?) + "); + $stmt->execute([$projectId, $name, $description, $assetType, $isGlobal, $userId]); + + visuJson([ + 'ok' => true, + 'asset_id' => (int)$pdo->lastInsertId(), + ]); +} + +if ($action === 'asset_update') { + if (!$canEditAssets) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Bearbeiten von Symbolen.'], 403); + } + + $assetId = (int)($_POST['asset_id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $description = visuCleanString($_POST['description'] ?? ''); + $assetType = (string)($_POST['asset_type'] ?? 'custom'); + $isGlobal = !empty($_POST['is_global']) ? 1 : 0; + + if ($assetId <= 0 || $name === '') { + visuJson(['ok' => false, 'error' => 'Symbol oder Name fehlt.'], 400); + } + + if (!in_array($assetType, ['background', 'pump', 'heat_generation', 'cold_generation', 'heat_recovery', 'valve', 'fan', 'sensor', 'text', 'custom'], true)) { + $assetType = 'custom'; + } + + $stmt = $pdo->prepare(" + UPDATE visu_assets + SET + name = ?, + description = ?, + asset_type = ?, + is_global = ? + WHERE id = ? + "); + $stmt->execute([$name, $description, $assetType, $isGlobal, $assetId]); + + visuJson(['ok' => true, 'asset_id' => $assetId]); +} + +if ($action === 'asset_delete') { + if (!$canDeleteAssets) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen von Symbolen.'], 403); + } + + $assetId = (int)($_POST['asset_id'] ?? 0); + + if ($assetId <= 0) { + visuJson(['ok' => false, 'error' => 'Symbol fehlt.'], 400); + } + + $stmt = $pdo->prepare("SELECT image_file FROM visu_asset_states WHERE asset_id = ?"); + $stmt->execute([$assetId]); + $files = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)); + + $stmt = $pdo->prepare("DELETE FROM visu_assets WHERE id = ?"); + $stmt->execute([$assetId]); + + foreach ($files as $file) { + visuDeleteAssetImageIfUnused($pdo, $file); + } + + visuJson(['ok' => true]); +} + +if ($action === 'asset_state_create' || $action === 'asset_state_update') { + if (!$canEditAssets) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Bearbeiten von Zuständen.'], 403); + } + + $stateId = (int)($_POST['state_id'] ?? 0); + $assetId = (int)($_POST['asset_id'] ?? 0); + $label = trim((string)($_POST['label'] ?? '')); + $stateKey = trim((string)($_POST['state_key'] ?? '')); + $sortOrder = (int)($_POST['sort_order'] ?? 100); + $isDefault = !empty($_POST['is_default']) ? 1 : 0; + $libraryUrl = visuCleanString($_POST['image_library_url'] ?? ''); + + if ($assetId <= 0 || $label === '') { + visuJson(['ok' => false, 'error' => 'Symbol oder Zustandsname fehlt.'], 400); + } + + $stmt = $pdo->prepare("SELECT project_id FROM visu_assets WHERE id = ? LIMIT 1"); + $stmt->execute([$assetId]); + $assetProjectId = (int)($stmt->fetchColumn() ?: 0); + + $stateKey = $stateKey === '' ? visuSlug($label) : visuSlug($stateKey); + $uploaded = visuSaveUploadedImage('image_file', VISU_ASSET_UPLOAD_DIR, VISU_ASSET_UPLOAD_URL); + if ($uploaded) { + $uploadFile = $_FILES['image_file'] ?? []; + visuImageLibraryRegister( + $pdo, + $uploaded, + (string)($uploadFile['name'] ?? ''), + (string)($uploadFile['type'] ?? ''), + (int)($uploadFile['size'] ?? 0), + $assetProjectId > 0 ? $assetProjectId : null, + $userId + ); + } + + if (!$uploaded && $libraryUrl && !visuImageLibraryAllowed($pdo, $libraryUrl, $assetProjectId, $userId, $canViewAll)) { + visuJson(['ok' => false, 'error' => 'Bild aus der Bibliothek ist nicht erlaubt.'], 403); + } + + $nextImageFile = $uploaded ?: ($libraryUrl ?: ''); + + if ($action === 'asset_state_create') { + $stmt = $pdo->prepare("SELECT COUNT(*) FROM visu_asset_states WHERE asset_id = ?"); + $stmt->execute([$assetId]); + if ((int)$stmt->fetchColumn() === 0) { + $isDefault = 1; + } + + if ($isDefault) { + $stmt = $pdo->prepare("UPDATE visu_asset_states SET is_default = 0 WHERE asset_id = ?"); + $stmt->execute([$assetId]); + } + + $stmt = $pdo->prepare(" + INSERT INTO visu_asset_states + (asset_id, state_key, label, image_file, sort_order, is_default) + VALUES + (?, ?, ?, ?, ?, ?) + "); + $stmt->execute([$assetId, $stateKey, $label, $nextImageFile, $sortOrder, $isDefault]); + + visuJson(['ok' => true, 'state_id' => (int)$pdo->lastInsertId()]); + } + + if ($stateId <= 0) { + visuJson(['ok' => false, 'error' => 'Zustand fehlt.'], 400); + } + + $stmt = $pdo->prepare("SELECT image_file FROM visu_asset_states WHERE id = ? AND asset_id = ? LIMIT 1"); + $stmt->execute([$stateId, $assetId]); + $oldState = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$oldState) { + visuJson(['ok' => false, 'error' => 'Zustand nicht gefunden.'], 404); + } + + $oldFile = (string)($oldState['image_file'] ?? ''); + + if ($isDefault) { + $stmt = $pdo->prepare("UPDATE visu_asset_states SET is_default = 0 WHERE asset_id = ?"); + $stmt->execute([$assetId]); + } + + if ($nextImageFile !== '') { + $stmt = $pdo->prepare(" + UPDATE visu_asset_states + SET + state_key = ?, + label = ?, + image_file = ?, + sort_order = ?, + is_default = ? + WHERE id = ? + AND asset_id = ? + "); + $stmt->execute([$stateKey, $label, $nextImageFile, $sortOrder, $isDefault, $stateId, $assetId]); + if ($oldFile !== $nextImageFile) { + visuDeleteAssetImageIfUnused($pdo, (string)$oldFile, $stateId); + } + } else { + $stmt = $pdo->prepare(" + UPDATE visu_asset_states + SET + state_key = ?, + label = ?, + sort_order = ?, + is_default = ? + WHERE id = ? + AND asset_id = ? + "); + $stmt->execute([$stateKey, $label, $sortOrder, $isDefault, $stateId, $assetId]); + } + + visuJson(['ok' => true, 'state_id' => $stateId]); +} + +if ($action === 'asset_state_delete') { + if (!$canDeleteAssets) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Löschen von Zuständen.'], 403); + } + + $stateId = (int)($_POST['state_id'] ?? 0); + + if ($stateId <= 0) { + visuJson(['ok' => false, 'error' => 'Zustand fehlt.'], 400); + } + + $stmt = $pdo->prepare("SELECT image_file FROM visu_asset_states WHERE id = ? LIMIT 1"); + $stmt->execute([$stateId]); + $file = $stmt->fetchColumn(); + + $stmt = $pdo->prepare("DELETE FROM visu_asset_states WHERE id = ?"); + $stmt->execute([$stateId]); + + visuDeleteAssetImageIfUnused($pdo, (string)$file); + + visuJson(['ok' => true]); +} + +if ($action === 'elements_save') { + if (!$canEditDesigner) { + visuJson(['ok' => false, 'error' => 'Kein Recht zum Speichern.'], 403); + } + + $pageId = (int)($_POST['page_id'] ?? 0); + $elementsRaw = (string)($_POST['elements_json'] ?? '[]'); + + $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); + + if (!$page) { + visuJson(['ok' => false, 'error' => 'Seite nicht sichtbar.'], 403); + } + + $elements = json_decode($elementsRaw, true); + + if (!is_array($elements)) { + visuJson(['ok' => false, 'error' => 'Ungültiges Elements-JSON.'], 400); + } + + $keepIds = []; + + try { + $pdo->beginTransaction(); + + foreach ($elements as $el) { + if (!is_array($el)) { + continue; + } + + $id = (int)($el['id'] ?? 0); + $elementType = (string)($el['element_type'] ?? 'asset'); + + if (!in_array($elementType, ['asset', 'value', 'text', 'shape', 'image'], true)) { + $elementType = 'asset'; + } + + $name = visuCleanString($el['name'] ?? null); + $label = visuCleanString($el['label'] ?? null); + $variableId = isset($el['variable_id']) && (int)$el['variable_id'] > 0 ? (int)$el['variable_id'] : null; + $assetId = isset($el['asset_id']) && (int)$el['asset_id'] > 0 ? (int)$el['asset_id'] : null; + + $x = (float)($el['x'] ?? 0); + $y = (float)($el['y'] ?? 0); + $w = max(5, (float)($el['w'] ?? 120)); + $h = max(5, (float)($el['h'] ?? 120)); + $rotation = (float)($el['rotation'] ?? 0); + $zIndex = (int)($el['z_index'] ?? 10); + $opacity = min(1, max(0, (float)($el['opacity'] ?? 1))); + $isLocked = !empty($el['is_locked']) ? 1 : 0; + + $stateRules = $el['state_rules'] ?? null; + $styleJson = $el['style_json'] ?? null; + + $stateRulesJson = $stateRules === null ? null : json_encode($stateRules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $styleJsonText = $styleJson === null ? null : json_encode($styleJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($id > 0) { + $stmt = $pdo->prepare(" + UPDATE visu_elements + SET + asset_id = ?, + element_type = ?, + name = ?, + variable_id = ?, + label = ?, + x = ?, + y = ?, + w = ?, + h = ?, + rotation = ?, + z_index = ?, + opacity = ?, + state_rules = ?, + style_json = ?, + is_locked = ? + WHERE id = ? + AND page_id = ? + "); + $stmt->execute([ + $assetId, + $elementType, + $name, + $variableId, + $label, + $x, + $y, + $w, + $h, + $rotation, + $zIndex, + $opacity, + $stateRulesJson, + $styleJsonText, + $isLocked, + $id, + $pageId, + ]); + + $dbElementId = $id; + } else { + $stmt = $pdo->prepare(" + INSERT INTO visu_elements + ( + page_id, + asset_id, + element_type, + name, + variable_id, + label, + x, + y, + w, + h, + rotation, + z_index, + opacity, + state_rules, + style_json, + is_locked + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $pageId, + $assetId, + $elementType, + $name, + $variableId, + $label, + $x, + $y, + $w, + $h, + $rotation, + $zIndex, + $opacity, + $stateRulesJson, + $styleJsonText, + $isLocked, + ]); + + $dbElementId = (int)$pdo->lastInsertId(); + } + + $keepIds[] = $dbElementId; + + $stmt = $pdo->prepare("DELETE FROM visu_element_state_rules WHERE element_id = ?"); + $stmt->execute([$dbElementId]); + + $rules = $el['rules'] ?? []; + + if (is_array($rules)) { + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + + $assetStateId = (int)($rule['asset_state_id'] ?? 0); + + if ($assetStateId <= 0) { + continue; + } + + $variableType = (string)($rule['variable_type'] ?? 'digital'); + + if (!in_array($variableType, ['analog', 'digital', 'alarm', 'setpoint'], true)) { + $variableType = 'digital'; + } + + $operator = (string)($rule['operator'] ?? 'eq'); + + if (!in_array($operator, ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'not_contains', 'truthy', 'falsy'], true)) { + $operator = 'eq'; + } + + $stmt = $pdo->prepare(" + INSERT INTO visu_element_state_rules + ( + element_id, + asset_state_id, + variable_type, + external_id, + variable_id, + point_key, + point_label, + operator, + compare_value, + priority + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $dbElementId, + $assetStateId, + $variableType, + visuCleanString($rule['external_id'] ?? null), + !empty($rule['variable_id']) ? (int)$rule['variable_id'] : null, + visuCleanString($rule['point_key'] ?? null), + visuCleanString($rule['point_label'] ?? null), + $operator, + visuCleanString($rule['compare_value'] ?? null), + (int)($rule['priority'] ?? 100), + ]); + } + } + } + + if (!empty($keepIds)) { + $ph = implode(',', array_fill(0, count($keepIds), '?')); + $params = array_merge([$pageId], $keepIds); + + $stmt = $pdo->prepare("DELETE FROM visu_elements WHERE page_id = ? AND id NOT IN ($ph)"); + $stmt->execute($params); + } else { + $stmt = $pdo->prepare("DELETE FROM visu_elements WHERE page_id = ?"); + $stmt->execute([$pageId]); + } + + $stmt = $pdo->prepare("UPDATE visu_pages SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$pageId]); + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + visuJson(['ok' => false, 'error' => $e->getMessage()], 500); + } + + visuJson(['ok' => true]); +} + +if ($action === 'live_values') { + $pageId = (int)($_GET['page_id'] ?? 0); + $page = visuPageVisible($pdo, $pageId, $userId, $canViewAll); + + if (!$page) { + visuJson(['ok' => false, 'error' => 'Seite nicht sichtbar.'], 403); + } + + $values = []; + foreach (visuGetProjectPointsWithVirtual($pdo, (int)$page['project_id'], true) as $point) { + $values[$point['source_key']] = $point; + + if (!empty($point['variable_id'])) { + $variableKey = (string)$point['variable_id']; + + if ( + !isset($values[$variableKey]) + || !visuPointHasDisplayValue($values[$variableKey]) + || visuPointHasDisplayValue($point) + ) { + $values[$variableKey] = $point; + } + } + } + + visuJson([ + 'ok' => true, + 'values' => $values, + 'can_write_setpoints' => $canWriteSetpoints, + ]); +} + +function canAccessVisuPage(PDO $pdo, int $pageId, int $userId, bool $canViewAll): bool +{ + if ($pageId <= 0 || $userId <= 0) { + return false; + } + + if ($canViewAll) { + $stmt = $pdo->prepare(" + SELECT 1 + FROM visu_pages vp + WHERE vp.id = ? + AND vp.is_active = 1 + LIMIT 1 + "); + $stmt->execute([$pageId]); + return (bool)$stmt->fetchColumn(); + } + + $stmt = $pdo->prepare(" + SELECT 1 + FROM visu_pages vp + INNER JOIN projects p ON p.id = vp.project_id + WHERE vp.id = ? + AND vp.is_active = 1 + AND ( + EXISTS ( + SELECT 1 + FROM project_assignments pa + WHERE pa.project_id = p.id + AND pa.user_id = ? + ) + OR EXISTS ( + SELECT 1 + FROM project_groups pg + JOIN roles r ON r.key_name = pg.group_name + JOIN user_roles ur ON ur.role_id = r.id + WHERE pg.project_id = p.id + AND ur.user_id = ? + ) + ) + LIMIT 1 + "); + $stmt->execute([$pageId, $userId, $userId]); + + return (bool)$stmt->fetchColumn(); +} + + + +if ($action === 'set_value') { + if (!$canWriteSetpoints) { + http_response_code(403); + echo json_encode([ + 'ok' => false, + 'error' => 'Keine Berechtigung zum Schreiben von Sollwerten.' + ]); + exit; + } + + $pageId = (int)($_POST['page_id'] ?? 0); + $externalId = trim((string)($_POST['external_id'] ?? '')); + $variableId = (int)($_POST['variable_id'] ?? 0); + $pointKey = trim((string)($_POST['point_key'] ?? '')); + $value = trim((string)($_POST['value'] ?? '')); + + if ($pageId <= 0 || $value === '') { + http_response_code(400); + echo json_encode([ + 'ok' => false, + 'error' => 'Ungültige Sollwertdaten.' + ]); + exit; + } + + if (!canAccessVisuPage($pdo, $pageId, $userId, $canViewAll)) { + http_response_code(403); + echo json_encode([ + 'ok' => false, + 'error' => 'Kein Zugriff auf diese Visualisierung.' + ]); + exit; + } + + /* + Wichtig: + Für Symcon/API schreiben wir bevorzugt über variable_id. + Falls deine getsetpoints stattdessen point_key/external_id braucht, + bleibt beides im Payload drin. + */ + $postData = [ + 'api_key' => $mainApiKey, + 'function' => 'setvalue', + 'value' => $value, + ]; + + if ($variableId > 0) { + $postData['id'] = (string)$variableId; + } + + if ($externalId !== '') { + $postData['external_id'] = $externalId; + } + + if ($pointKey !== '') { + $postData['point_key'] = $pointKey; + } + + $ch = curl_init('https://main.api.se-inno.de'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postData, + CURLOPT_TIMEOUT => 15, + ]); + + $resp = curl_exec($ch); + $err = curl_error($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($err) { + http_response_code(502); + echo json_encode([ + 'ok' => false, + 'error' => 'Upstream Fehler: ' . $err + ]); + exit; + } + + $raw = trim((string)$resp); + + /* + main.api kann JSON liefern, muss aber nicht. + Deshalb akzeptieren wir auch: + OK, 1, true, saved, success usw. + */ + $decoded = json_decode($raw, true); + + if (is_array($decoded)) { + $upstreamOk = + ($decoded['ok'] ?? null) === true || + ($decoded['success'] ?? null) === true || + (string)($decoded['status'] ?? '') === 'ok' || + (string)($decoded['result'] ?? '') === 'ok'; + + if (!$upstreamOk && isset($decoded['error'])) { + http_response_code(502); + echo json_encode([ + 'ok' => false, + 'error' => 'Upstream: ' . (is_string($decoded['error']) ? $decoded['error'] : json_encode($decoded['error'])), + 'raw' => $raw, + ]); + exit; + } + + echo json_encode([ + 'ok' => true, + 'upstream' => $decoded, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + $okRawValues = ['ok', '1', 'true', 'success', 'saved', 'gespeichert']; + + if ( + $code >= 200 && + $code < 300 && + ( + $raw === '' || + in_array(mb_strtolower($raw), $okRawValues, true) || + str_contains(mb_strtolower($raw), 'ok') || + str_contains(mb_strtolower($raw), 'success') + ) + ) { + echo json_encode([ + 'ok' => true, + 'raw' => $raw, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + http_response_code(502); + echo json_encode([ + 'ok' => false, + 'error' => 'Upstream hat keine gültige Erfolgsantwort geliefert.', + 'http_code' => $code, + 'raw' => $raw, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +visuJson(['ok' => false, 'error' => 'Unbekannte Aktion.'], 400); diff --git a/sql/visu_csv_exports.sql b/sql/visu_csv_exports.sql new file mode 100644 index 0000000..8510239 --- /dev/null +++ b/sql/visu_csv_exports.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS visu_csv_export_jobs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + project_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + selection_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + name VARCHAR(190) NOT NULL, + export_kind ENUM('trend_csv', 'lastvalues_csv') NOT NULL DEFAULT 'trend_csv', + csv_delimiter VARCHAR(4) NOT NULL DEFAULT ';', + enabled TINYINT(1) NOT NULL DEFAULT 1, + schedule_kind ENUM('interval', 'daily', 'weekly', 'monthly') NOT NULL DEFAULT 'daily', + interval_minutes INT UNSIGNED NOT NULL DEFAULT 60, + time_of_day CHAR(5) NOT NULL DEFAULT '00:00', + weekdays VARCHAR(32) NOT NULL DEFAULT '', + month_day TINYINT UNSIGNED NOT NULL DEFAULT 1, + target_dir VARCHAR(255) NOT NULL DEFAULT 'csv_exports', + filename_pattern VARCHAR(255) NOT NULL DEFAULT '{selection}_{yyyy}-{mm}-{dd}_{hh}-{ii}-{ss}.csv', + retention_days INT UNSIGNED NOT NULL DEFAULT 30, + last_run_at DATETIME NULL DEFAULT NULL, + next_run_at DATETIME NULL DEFAULT NULL, + last_status ENUM('idle', 'running', 'success', 'error') NOT NULL DEFAULT 'idle', + last_error TEXT NULL DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_visu_csv_export_jobs_selection (selection_id), + KEY idx_visu_csv_export_jobs_project (project_id), + KEY idx_visu_csv_export_jobs_enabled_next_run (enabled, next_run_at), + KEY idx_visu_csv_export_jobs_status (last_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS visu_csv_export_files ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + job_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + project_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + selection_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + status ENUM('queued', 'running', 'success', 'error', 'deleted') NOT NULL DEFAULT 'queued', + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + row_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + export_started_at DATETIME NULL DEFAULT NULL, + export_finished_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + error_message TEXT NULL DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_visu_csv_export_files_job (job_id), + KEY idx_visu_csv_export_files_selection (selection_id), + KEY idx_visu_csv_export_files_project (project_id), + KEY idx_visu_csv_export_files_status (status), + KEY idx_visu_csv_export_files_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/visu_virtual_points.sql b/sql/visu_virtual_points.sql new file mode 100644 index 0000000..db319f6 --- /dev/null +++ b/sql/visu_virtual_points.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `visu_virtual_points` ( + `id` int NOT NULL AUTO_INCREMENT, + `project_id` int NOT NULL, + `point_key` varchar(190) NOT NULL, + `name` varchar(190) NOT NULL, + `description` text DEFAULT NULL, + `unit` varchar(32) NOT NULL DEFAULT '', + `decimals` tinyint NOT NULL DEFAULT 2, + `sort_order` int NOT NULL DEFAULT 100, + `formula_json` longtext NOT NULL, + `created_by` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_visu_virtual_point_project_key` (`project_id`, `point_key`), + KEY `idx_visu_virtual_points_project` (`project_id`, `is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/visu-src/.gitignore b/visu-src/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/visu-src/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/visu-src/README.md b/visu-src/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/visu-src/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/visu-src/eslint.config.js b/visu-src/eslint.config.js new file mode 100644 index 0000000..ea36dd3 --- /dev/null +++ b/visu-src/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/visu-src/index.html b/visu-src/index.html new file mode 100644 index 0000000..1445b57 --- /dev/null +++ b/visu-src/index.html @@ -0,0 +1,13 @@ + + + + + + + visu-src + + +
+ + + diff --git a/visu-src/package-lock.json b/visu-src/package-lock.json new file mode 100644 index 0000000..9c5d451 --- /dev/null +++ b/visu-src/package-lock.json @@ -0,0 +1,2410 @@ +{ + "name": "visu-src", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "visu-src", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/visu-src/package.json b/visu-src/package.json new file mode 100644 index 0000000..74e409c --- /dev/null +++ b/visu-src/package.json @@ -0,0 +1,27 @@ +{ + "name": "visu-src", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } +} diff --git a/visu-src/public/favicon.svg b/visu-src/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/visu-src/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/visu-src/public/icons.svg b/visu-src/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/visu-src/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/visu-src/src/App.css b/visu-src/src/App.css new file mode 100644 index 0000000..bbec811 --- /dev/null +++ b/visu-src/src/App.css @@ -0,0 +1,4404 @@ +:root { + color-scheme: dark; + --visu-root-height: 100vh; + --glass-border: rgba(255, 255, 255, 0.14); + --glass-border-strong: rgba(255, 255, 255, 0.22); + --glass-panel-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)); + --glass-panel-bg-soft: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)); + --glass-surface: rgba(255, 255, 255, 0.06); + --glass-surface-strong: rgba(255, 255, 255, 0.1); + --glass-input-bg: rgba(15, 23, 42, 0.34); + --glass-accent-bg: rgba(191, 219, 254, 0.16); + --glass-accent-border: rgba(191, 219, 254, 0.28); + --glass-accent-text: rgba(226, 232, 240, 0.94); +} + +* { + box-sizing: border-box; +} + +html, +body { + max-width: 100%; +} + +body.visu-page, +body:has(#root .visu-workspace), +body:has(#root .viewer-full), +body:has(#root .overview-full) { + overflow: hidden; +} + +#root { + height: var(--visu-root-height); + max-height: var(--visu-root-height); + overflow: hidden; +} + +/* -------------------------------------------------------- + Layout Grundgerüst +-------------------------------------------------------- */ + +.page-shell { + width: 100%; + max-width: 1800px; + margin: 0 auto; + padding: 10px 12px; + height: 100%; + overflow: auto; +} + +.page-shell.full { + max-width: none; + overflow: hidden; +} + +.overview-full { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + overflow: hidden; +} + +.glass-card { + width: 100%; + border: 1px solid var(--glass-border); + background: var(--glass-panel-bg); + border-radius: 24px; + padding: 10px; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.22); + backdrop-filter: blur(22px); +} + +.overview-full .glass-card { + background: var(--glass-panel-bg-soft); + backdrop-filter: blur(26px); +} + +.overview-create { + flex: 0 0 auto; +} + +.overview-list-card { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.overview-main-card { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.visu-workspace { + height: 100%; + max-height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.visu-topbar { + flex: 0 0 auto; + margin-bottom: 8px; +} + +.section-head h1, +.section-head h2, +.section-head h3, +.form-head h2, +.modal-head h2 { + margin: 0; + color: white; + font-weight: 900; +} + +.section-head h1 { + font-size: 18px; +} + +.section-head p, +.subline { + margin: 2px 0 0; + color: rgba(209, 213, 219, 0.72); + font-size: 11px; +} + +.eyebrow { + color: rgba(226, 232, 240, 0.82); + font-size: 10px; + letter-spacing: .14em; + text-transform: uppercase; + font-weight: 800; + margin-bottom: 2px; +} + +/* -------------------------------------------------------- + Formulare / Buttons +-------------------------------------------------------- */ + +.form-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.form-head span, +.section-head > span, +.project-visu-head > span, +.overview-head-actions > span { + padding: 7px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: var(--glass-accent-text); + font-size: 12px; + font-weight: 700; +} + +.form-grid { + display: grid; + grid-template-columns: minmax(280px, 2fr) minmax(180px, 1fr) 120px 120px auto; + gap: 10px; + align-items: end; +} + +.form-grid.compact { + grid-template-columns: minmax(240px, 2fr) minmax(180px, 1.2fr) 96px 96px auto; + gap: 10px; +} + +.form-wide { + grid-column: span 2; +} + +.field { + display: grid; + gap: 4px; +} + +.field label, +.state-form label, +.color-editor > label { + font-size: 11px; + color: rgba(209, 213, 219, 0.78); + font-weight: 700; +} + +.form-input, +.field input, +.field select, +.properties input, +.properties select { + width: 100%; + border: 1px solid var(--glass-border); + background: var(--glass-input-bg); + color: white; + border-radius: 10px; + padding: 7px 8px; + outline: none; +} + +.form-input:focus, +.field input:focus, +.field select:focus, +.properties input:focus, +.properties select:focus { + border-color: rgba(226, 232, 240, 0.38); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08); +} + +.form-input:disabled, +.field input:disabled, +.field select:disabled, +button:disabled, +.disabled-link { + opacity: .45; + cursor: not-allowed; + pointer-events: none; +} + +.btn, +.mini-btn, +.tool-btn { + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.08); + color: white; + border-radius: 9px; + padding: 5px 8px; + text-decoration: none; + font-weight: 800; + font-size: 10px; + line-height: 1.15; + cursor: pointer; +} + +.btn:hover, +.mini-btn:hover, +.tool-btn:hover { + background: rgba(255, 255, 255, 0.14); +} + +.btn.primary, +.tool-btn.active { + background: var(--glass-accent-bg); + border-color: var(--glass-accent-border); +} + +.btn.primary:hover { + background: rgba(191, 219, 254, 0.22); +} + +.btn.small, +.mini-btn { + padding: 5px 7px; + font-size: 10px; +} + +.danger, +.btn.danger, +.mini-btn.danger { + background: rgba(185, 28, 28, 0.7); + border-color: rgba(248, 113, 113, 0.58); +} + +.error-box { + margin-top: 12px; + border: 1px solid rgba(248, 113, 113, 0.58); + background: rgba(127, 29, 29, 0.5); + color: #fecaca; + padding: 12px; + border-radius: 16px; + font-weight: 700; +} + +.error-box.small, +.tiny-error { + font-size: 12px; + padding: 8px; +} + +.empty-box, +.empty-mini { + border: 1px dashed rgba(156, 163, 175, 0.35); + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 16px; + color: rgba(209, 213, 219, 0.72); +} + +.empty-mini { + font-size: 12px; + padding: 10px; +} + +.small-title { + font-size: 12px; + color: white; + font-weight: 900; +} + +.small-muted { + font-size: 11px; + color: rgba(209, 213, 219, 0.65); +} + +/* -------------------------------------------------------- + Übersicht +-------------------------------------------------------- */ + +.overview-head-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: min(560px, 48vw); +} + +.overview-search { + min-width: 320px; +} + +.project-visu-grid { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(520px, 1fr)); + gap: 14px; + padding-right: 4px; +} + +.project-visu-card { + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + border-radius: 22px; + padding: 14px; + min-height: 0; +} + +.project-visu-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.project-visu-title { + color: white; + font-size: 15px; + font-weight: 900; +} + +.project-visu-meta { + margin-top: 4px; + color: rgba(209, 213, 219, 0.68); + font-size: 12px; +} + +.project-page-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 10px; +} + +.visu-card { + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + border-radius: 20px; + padding: 16px; +} + +.visu-card.compact { + padding: 12px; + border-radius: 16px; +} + +.visu-title { + font-size: 18px; + font-weight: 900; + color: white; +} + +.visu-card.compact .visu-title { + font-size: 15px; +} + +.visu-meta, +.visu-description { + margin-top: 6px; + color: rgba(209, 213, 219, 0.7); + font-size: 12px; +} + +.visu-actions, +.designer-actions, +.asset-actions, +.asset-form-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.designer-actions .autosave-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--glass-border); + border-radius: 10px; + padding: 5px 7px; + background: rgba(255, 255, 255, 0.08); + color: rgba(226, 232, 240, .88); + font-size: 10px; + font-weight: 800; + cursor: pointer; +} + +.designer-actions .autosave-toggle input { + margin: 0; +} + +.unsaved-badge { + align-self: center; + border: 1px solid rgba(251, 191, 36, .42); + background: rgba(146, 64, 14, .32); + color: #fde68a; + border-radius: 999px; + padding: 7px 9px; + font-size: 11px; + font-weight: 800; +} + +.overview-main-layout { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 290px minmax(0, 1fr); + gap: 8px; + overflow: hidden; +} + +.overview-tree { + height: 100%; + background: rgba(255, 255, 255, 0.05); +} + +.overview-full .viewer-tree-project-block { + background: rgba(2, 6, 23, 0.18); +} + +.overview-full .viewer-tree-project-toggle:hover { + background: rgba(255, 255, 255, 0.08); +} + +.overview-dashboard-card { + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.05); + border-radius: 18px; + padding: 10px; + backdrop-filter: blur(20px); +} + +.dashboard-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.dashboard-toolbar-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.dashboard-grid { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: grid; + grid-template-columns: repeat(var(--dashboard-columns), minmax(0, 1fr)); + grid-auto-rows: var(--dashboard-row-height); + gap: var(--dashboard-gap); + align-content: start; + padding-right: 4px; +} + +.dashboard-empty { + grid-column: 1 / -1; + border: 1px dashed rgba(156, 163, 175, 0.35); + background: rgba(255, 255, 255, 0.05); + border-radius: 18px; + padding: 18px; + color: rgba(209, 213, 219, 0.72); +} + +.dashboard-widget { + position: relative; + min-width: 0; + min-height: 0; + border: 1px solid var(--glass-border); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.04)); + border-radius: 18px; + overflow: hidden; + display: flex; + flex-direction: column; + backdrop-filter: blur(18px); +} + +.dashboard-widget.is-moving { + opacity: .8; + border-color: rgba(226, 232, 240, 0.36); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); +} + +.dashboard-widget-header { + flex: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 10px 11px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + cursor: grab; +} + +.dashboard-widget-header strong { + display: block; + color: white; + font-size: 13px; +} + +.dashboard-widget-header small { + color: rgba(209, 213, 219, 0.68); + font-size: 11px; +} + +.dashboard-widget-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.subtle-icon { + width: 24px; + height: 24px; + padding: 0; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.14); +} + +.subtle-icon.is-active { + background: rgba(191, 219, 254, 0.16); + border-color: rgba(191, 219, 254, 0.3); +} + +.dashboard-widget-body { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + padding: 10px; +} + +.dashboard-value-widget, +.dashboard-table-widget, +.dashboard-instrument-widget { + width: 100%; + height: 100%; +} + +.dashboard-value-widget { + display: grid; + align-content: center; + gap: 10px; + text-align: center; +} + +.dashboard-value-label { + color: rgba(209, 213, 219, 0.72); + font-size: 13px; + font-weight: 800; +} + +.dashboard-value-number { + color: white; + font-size: clamp(24px, 2.5vw, 44px); + font-weight: 900; +} + +.dashboard-table-widget { + display: grid; + align-content: start; + gap: 8px; +} + +.dashboard-table-value-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border: 1px solid rgba(75, 85, 99, 0.58); + border-radius: 12px; + background: rgba(2, 6, 23, 0.34); +} + +.dashboard-table-value-row span { + color: rgba(209, 213, 219, 0.8); + font-size: 12px; + font-weight: 700; +} + +.dashboard-table-value-row strong { + color: white; + font-size: 14px; +} + +.dashboard-instrument-widget .instrument-svg { + width: 100%; + height: 100%; +} + +.dashboard-resize-handle { + position: absolute; + right: 8px; + bottom: 6px; + width: 22px; + height: 22px; + border: 0; + border-radius: 10px; + background: rgba(255, 255, 255, 0.16); + color: white; + font-size: 12px; + font-weight: 900; + cursor: nwse-resize; +} + +.overview-tree-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.overview-tree-delete { + border: 0; +} + +.overview-mode-tabs { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.overview-mode-tabs > span { + padding: 7px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: rgba(241, 245, 249, 0.92); + font-size: 12px; + font-weight: 700; +} + +.manage-main-card { + padding-bottom: 12px; +} + +.overview-manage-layout { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 330px minmax(0, 1fr); + gap: 12px; + overflow: hidden; +} + +.overview-manage-layout.single-column { + grid-template-columns: 1fr; + grid-template-rows: auto auto minmax(0, 1fr); +} + +.overview-manage-content { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; +} + +.overview-create.compact { + padding: 10px 12px; +} + +.create-form.compact .form-head { + margin-bottom: 10px; +} + +.create-form.compact .form-head h2 { + font-size: 22px; +} + +.create-form.compact .field { + gap: 4px; +} + +.create-form.compact .form-input, +.create-form.compact .field input, +.create-form.compact .field select { + padding: 7px 9px; + border-radius: 10px; +} + +.manage-info-card { + min-height: 0; + display: grid; + align-content: start; + gap: 8px; +} + +.manage-page-grid { + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + align-content: start; + overflow: auto; + padding-right: 4px; +} + +.manage-page-grid .project-visu-card { + display: flex; + flex-direction: column; + padding: 10px; + border-radius: 18px; +} + +.manage-page-grid .project-page-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: start; +} + +.manage-page-grid .visu-card.compact { + display: flex; + flex-direction: column; + gap: 5px; + min-height: 0; + flex: 1 1 220px; + min-width: 220px; + padding: 9px; + border-radius: 14px; +} + +.manage-page-grid .visu-actions { + margin-top: auto; + gap: 6px; +} + +.manage-page-grid .project-visu-head { + margin-bottom: 8px; +} + +.manage-page-grid .project-visu-title { + font-size: 12px; + line-height: 1.2; +} + +.manage-page-grid .project-visu-meta { + font-size: 10px; + margin-top: 2px; +} + +.manage-page-grid .project-visu-head > span { + font-size: 10px; + padding: 5px 8px; +} + +.manage-page-grid .visu-title, +.manage-page-grid .visu-card.compact .visu-title { + font-size: 11px; + line-height: 1.2; +} + +.manage-page-grid .visu-meta, +.manage-page-grid .visu-description { + font-size: 10px; + margin-top: 2px; +} + +.manage-page-grid .mini-btn { + font-size: 9px; + padding: 4px 6px; +} + +.manage-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 1500px) { + .manage-page-grid .project-page-grid { + flex-direction: column; + } + + .manage-page-grid .visu-card.compact { + min-width: 0; + width: 100%; + } +} + +.dashboard-widget-modal { + width: min(980px, 96vw); +} + +.dashboard-widget-form { + display: grid; + gap: 10px; +} + +.dashboard-widget-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 4px; +} + +.dashboard-color-row { + display: grid; + grid-template-columns: 52px minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +.dashboard-color-row input[type="color"] { + width: 52px; + height: 42px; + padding: 0; + border-radius: 12px; + border: 1px solid rgba(75, 85, 99, 0.82); + background: rgba(15, 23, 42, 0.64); +} + +.dashboard-ranges, +.dashboard-table-editor { + display: grid; + gap: 8px; + border: 1px solid rgba(75, 85, 99, 0.72); + background: rgba(2, 6, 23, 0.3); + border-radius: 14px; + padding: 10px; +} + +.dashboard-range-row, +.dashboard-table-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) 64px auto; + gap: 8px; + align-items: center; +} + +.linked-gap-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +/* -------------------------------------------------------- + Viewer +-------------------------------------------------------- */ + +.viewer-full { + height: 100%; + overflow: hidden; +} + +.viewer-workspace { + height: 100%; +} + +.viewer-workspace.focus-mode { + padding: 6px; +} + +.viewer-layout { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 8px; + overflow: hidden; +} + +.viewer-layout.tree-collapsed { + grid-template-columns: 1fr; +} + +.viewer-layout.focus-active { + gap: 0; +} + +.viewer-tree { + min-height: 0; + overflow: auto; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + border-radius: 18px; + padding: 10px; +} + +.viewer-tree-title { + color: white; + font-weight: 900; + font-size: 13px; + margin-bottom: 8px; +} + +.viewer-tree-project { + display: grid; + gap: 2px; + padding: 10px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.14); + margin-bottom: 10px; +} + +.viewer-tree-project strong { + color: rgba(241, 245, 249, 0.96); + font-size: 13px; +} + +.viewer-tree-project span { + color: rgba(226, 232, 240, .78); + font-size: 12px; +} + +.viewer-tree-list { + display: grid; + gap: 6px; +} + +.viewer-tree-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 5px; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + overflow: hidden; +} + +.viewer-tree-row.active { + border-color: rgba(226, 232, 240, 0.3); + background: rgba(191, 219, 254, 0.14); +} + +.viewer-tree-row a { + display: grid; + color: white; + text-decoration: none; + padding: 7px 8px; + min-width: 0; +} + +.viewer-tree-row a span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 800; + font-size: 12px; +} + +.viewer-tree-row a small { + color: rgba(209, 213, 219, 0.6); + font-size: 10px; + margin-top: 2px; +} + +.viewer-tree-edit { + display: grid !important; + place-items: center; + width: 30px; + height: 100%; + padding: 0 !important; + border-left: 1px solid rgba(75, 85, 99, 0.58); +} + +.viewer-scroll { + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid var(--glass-border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + padding: 10px; + position: relative; +} + +.viewer-workspace.focus-mode .viewer-scroll { + border-radius: 20px; +} + +.viewer-stage { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.viewer-fit-plane { + position: relative; + flex: 0 0 auto; +} + +.viewer-canvas { + position: relative; + transform-origin: left top; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + overflow: hidden; +} + +/* -------------------------------------------------------- + Designer +-------------------------------------------------------- */ + +.designer-layout { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 230px minmax(0, 1fr) 310px; + gap: 8px; + align-items: stretch; + overflow: hidden; +} + +.toolbox, +.properties { + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + border-radius: 18px; + padding: 10px; + height: 100%; + min-height: 0; + overflow: auto; +} + +.toolbox h3, +.properties h3 { + margin: 0 0 8px; + color: white; + font-size: 11px; + font-weight: 900; +} + +.tool-btn { + width: 100%; + text-align: left; + margin-bottom: 6px; + font-size: 11px; + line-height: 1.2; +} + +.element-list-panel { + margin-top: 10px; +} + +.element-list-body { + display: grid; + gap: 5px; + max-height: 220px; + overflow: auto; +} + +.element-list-item { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 10px; + background: rgba(15, 23, 42, 0.32); + color: rgba(226, 232, 240, 0.86); + padding: 6px 7px; + text-align: left; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + cursor: pointer; +} + +.element-list-item:hover, +.element-list-item.is-selected { + border-color: rgba(96, 165, 250, 0.78); + background: rgba(96, 165, 250, 0.14); +} + +.element-list-item.is-template-group { + border-color: rgba(167, 139, 250, 0.42); + background: rgba(167, 139, 250, 0.12); +} + +.element-list-type { + grid-column: 1 / -1; + color: rgba(191, 219, 254, 0.86); + font-size: 9px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.element-list-item strong { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; +} + +.element-list-item em { + align-self: center; + color: rgba(209, 213, 219, 0.58); + font-size: 9px; + font-style: normal; +} + +.draw-hint { + color: rgba(226, 232, 240, 0.92); + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 10px; + font-size: 12px; + line-height: 1.45; + margin-bottom: 10px; +} + +.point-edit-topbar { + flex: 0 0 auto; + margin-bottom: 10px; + border: 1px solid rgba(34, 197, 94, 0.48); + background: rgba(22, 101, 52, 0.45); + color: #dcfce7; + border-radius: 18px; + padding: 10px 12px; + display: flex; + align-items: center; + gap: 12px; +} + +.point-edit-topbar strong { + color: white; +} + +.point-edit-topbar span { + flex: 1; + font-size: 12px; + color: rgba(220, 252, 231, .78); +} + +.page-settings { + display: grid; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(75, 85, 99, 0.72); +} + +.toolbox-section { + margin-top: 10px; +} + +.toolbox-section-body.page-settings { + margin-top: 0; + padding-top: 0; + border-top: 0; + max-height: none; +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.canvas-wrap { + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.canvas-toolbar { + flex: 0 0 auto; + display: flex; + justify-content: space-between; + gap: 8px; + color: rgba(209, 213, 219, 0.72); + font-size: 11px; + margin-bottom: 6px; +} + +.zoom-control { + display: flex; + align-items: center; + gap: 8px; +} + +.canvas-toolbar-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.zoom-control input { + width: 140px; +} + +.canvas-scroll { + width: 100%; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border: 1px solid rgba(75, 85, 99, 0.72); + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); +} + +.canvas-scroll.is-panning, +.canvas-scroll.is-panning .canvas, +.canvas-scroll.is-panning .canvas-element { + cursor: grabbing; +} + +.canvas-zoom-plane { + position: relative; +} + +.canvas { + position: relative; + transform-origin: left top; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + overflow: hidden; +} + +.canvas.is-drawing { + cursor: crosshair; +} + +.canvas.is-selecting { + cursor: crosshair; +} + +.selection-marquee { + position: absolute; + z-index: 999998; + pointer-events: none; + border: 1px solid rgba(147, 197, 253, 0.96); + background: rgba(59, 130, 246, 0.16); + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.62); +} + +.canvas.grid-on::before, +.viewer-canvas.grid-on::before { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(148, 163, 184, 0.12) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px); + background-size: var(--grid-size) var(--grid-size); + pointer-events: none; + z-index: 0; +} + +/* -------------------------------------------------------- + Canvas Elemente +-------------------------------------------------------- */ + +.canvas-element { + position: absolute; + left: 0; + top: 0; + transform-origin: center center; + cursor: grab; + user-select: none; +} + +.canvas-element.is-selected { + outline: 2px solid rgba(96, 165, 250, 0.95); + outline-offset: 3px; +} + +.canvas-element.is-point-edit { + outline: 2px dashed rgba(34, 197, 94, 0.95); + outline-offset: 4px; +} + +.template-group-selection { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + border: 2px solid rgba(167, 139, 250, 0.95); + border-radius: 12px; + box-shadow: + 0 0 0 3px rgba(15, 23, 42, 0.34), + 0 0 18px rgba(167, 139, 250, 0.45); + z-index: 999998; +} + +.template-group-selection span { + position: absolute; + left: 8px; + top: -24px; + max-width: 260px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + border: 1px solid rgba(167, 139, 250, 0.55); + border-radius: 999px; + background: rgba(15, 23, 42, 0.78); + color: rgba(237, 233, 254, 0.96); + padding: 3px 8px; + font-size: 10px; + font-weight: 900; +} + +.resize-handle { + position: absolute; + width: 12px; + height: 12px; + border-radius: 999px; + background: #93c5fd; + border: 2px solid #0f172a; + z-index: 999999; + box-shadow: 0 0 12px rgba(59, 130, 246, 0.9); +} + +.handle-nw { + left: -8px; + top: -8px; + cursor: nwse-resize; +} + +.handle-n { + left: calc(50% - 6px); + top: -8px; + cursor: ns-resize; +} + +.handle-ne { + right: -8px; + top: -8px; + cursor: nesw-resize; +} + +.handle-e { + right: -8px; + top: calc(50% - 6px); + cursor: ew-resize; +} + +.handle-se { + right: -8px; + bottom: -8px; + cursor: nwse-resize; +} + +.handle-s { + left: calc(50% - 6px); + bottom: -8px; + cursor: ns-resize; +} + +.handle-sw { + left: -8px; + bottom: -8px; + cursor: nesw-resize; +} + +.handle-w { + left: -8px; + top: calc(50% - 6px); + cursor: ew-resize; +} + +.asset-canvas-image { + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; + user-select: none; +} + +.asset-missing { + width: 100%; + height: 100%; + display: grid; + place-items: center; + border: 2px dashed rgba(248, 113, 113, 0.65); + color: #fecaca; + border-radius: 14px; + background: rgba(127, 29, 29, 0.18); + font-size: 12px; +} + +.text-element { + width: 100%; + height: 100%; + display: grid; + align-items: center; + font-weight: 900; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); +} + +.text-link-anchor, +.text-menu-item { + color: inherit; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.14em; +} + +.text-link-anchor.as-button, +.text-menu-item.as-button { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 6px 12px; + color: inherit; + text-decoration: none; + text-align: center; + backdrop-filter: blur(10px); +} + +.text-link-anchor { + display: inline-block; + width: 100%; +} + +.text-menu { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; +} + +.text-menu.is-vertical { + flex-direction: column; +} + +.text-menu.is-horizontal { + flex-direction: row; + flex-wrap: nowrap; +} + +.text-menu-item { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + white-space: nowrap; +} + +.text-menu.has-button-items.is-vertical { + align-items: stretch; + justify-content: flex-start; +} + +.text-menu.has-button-items.is-horizontal { + align-items: stretch; + justify-content: flex-start; +} + + +/* -------------------------------------------------------- + Trendanzeige +-------------------------------------------------------- */ + +.trend-chart { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 14px; + + background: rgba(15, 23, 42, 0.46); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 10px 28px rgba(0, 0, 0, 0.18); + + backdrop-filter: blur(10px); +} + +.trend-chart svg { + width: 100%; + height: 100%; + display: block; +} + +.trend-chart-frame { + fill: transparent; + stroke: transparent; +} + +.trend-grid-line { + stroke: rgba(148, 163, 184, 0.14); + stroke-width: 1; +} + +.trend-axis { + stroke: rgba(203, 213, 225, 0.34); + stroke-width: 1; +} + +.trend-axis-label { + fill: rgba(226, 232, 240, 0.72); + font-size: 10px; + font-weight: 800; +} + +.trend-x-label { + fill: rgba(226, 232, 240, 0.68); + font-size: 9px; + font-weight: 800; +} + +.trend-selection { + fill: rgba(56, 189, 248, 0.18); + stroke: rgba(125, 211, 252, 0.82); + stroke-width: 1; +} + +/* Kopfbereich */ + +.trend-chart-head { + position: absolute; + top: 9px; + left: 14px; + right: 14px; + z-index: 3; + + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + + pointer-events: none; + color: rgba(248, 250, 252, 0.95); + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.42); +} + +.trend-chart-head strong { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + font-size: 11px; + line-height: 1.15; + font-weight: 900; +} + +.trend-chart-head span { + flex: 0 0 auto; + + color: rgba(203, 213, 225, 0.72); + font-size: 10px; + line-height: 1.15; + font-weight: 800; +} + +/* Legende unten links */ + +.trend-legend { + position: absolute; + left: 16px; + right: 16px; + bottom: 12px; + z-index: 3; + + display: flex; + align-items: center; + gap: 7px 12px; + flex-wrap: nowrap; + + min-width: 0; + max-height: 18px; + overflow: hidden; + + pointer-events: none; +} + +.trend-legend.has-footer { + right: 92px; +} + +.trend-legend span { + display: inline-flex; + align-items: center; + gap: 5px; + + min-width: 0; + max-width: 220px; + overflow: hidden; + + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + + color: rgba(226, 232, 240, 0.84); + font-size: 10px; + line-height: 1; + font-weight: 800; + white-space: nowrap; + text-overflow: ellipsis; +} + +.trend-legend i { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 999px; + box-shadow: 0 0 8px currentColor; +} + +/* Hover / Tooltip-Punkt */ + +.trend-hover-point { + stroke: rgba(248, 250, 252, 0.95); + stroke-width: 2; + filter: drop-shadow(0 0 7px rgba(56, 189, 248, 0.75)); + pointer-events: none; +} + +/* Leer- und Fehlerzustand */ + +.trend-empty, +.trend-error { + position: absolute; + inset: 0; + z-index: 4; + + display: grid; + place-items: center; + + padding: 14px; + text-align: center; + + color: rgba(226, 232, 240, 0.72); + font-size: 12px; + font-weight: 900; + + pointer-events: none; +} + +.trend-error { + color: #fecaca; +} + +/* Footer unten rechts */ + +.trend-footer { + position: absolute; + right: 9px; + bottom: 7px; + z-index: 5; + + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + + pointer-events: auto; +} + +.trend-footer select, +.trend-footer button { + height: 22px; + + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + + background: rgba(15, 23, 42, 0.78); + color: rgba(248, 250, 252, 0.9); + + font-size: 10px; + line-height: 1; + font-weight: 850; + + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 4px 12px rgba(0, 0, 0, 0.22); +} + +.trend-footer select { + width: 82px; + max-width: 82px; + padding: 0 6px; +} + +.trend-footer button { + width: 24px; + min-width: 24px; + padding: 0; + + display: grid; + place-items: center; + + cursor: pointer; +} + +.trend-footer select:hover, +.trend-footer button:hover { + background: rgba(30, 41, 59, 0.92); + border-color: rgba(191, 219, 254, 0.34); +} + +.trend-footer select:focus, +.trend-footer button:focus-visible { + outline: none; + border-color: rgba(125, 211, 252, 0.58); + box-shadow: + 0 0 0 2px rgba(56, 189, 248, 0.16), + 0 4px 12px rgba(0, 0, 0, 0.22); +} + +/* Exportmenü */ + +.export-menu { + position: relative; +} + +.export-menu > summary { + list-style: none; +} + +.export-menu > summary::-webkit-details-marker { + display: none; +} + +.export-menu-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + + display: grid; + gap: 6px; + + min-width: 132px; + padding: 8px; + + border: 1px solid var(--glass-border); + border-radius: 12px; + + background: rgba(15, 23, 42, 0.92); + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.34); +} + +.export-menu-panel .mini-btn { + justify-content: flex-start; + width: 100%; +} + +/* Vergrößerte Trendansicht */ + +.trend-expanded-canvas { + position: absolute; + inset: 0; + z-index: 20; + + background: rgba(15, 23, 42, 0.86); + border-radius: 0; +} + +.trend-expanded-canvas .trend-chart { + border-radius: 0; + border-color: rgba(148, 163, 184, 0.2); +} + +.trend-expanded-canvas .trend-legend { + bottom: 16px; +} + +.trend-expanded-canvas .trend-footer { + right: 14px; + bottom: 12px; +} + +.trend-expanded-close { + position: absolute; + top: 12px; + right: 12px; + z-index: 30; + + width: 34px; + height: 34px; + + border-radius: 999px; + border: 1px solid rgba(248, 250, 252, 0.26); + + background: rgba(15, 23, 42, 0.66); + color: white; + + font-size: 22px; + line-height: 1; + font-weight: 900; + + cursor: pointer; +} + +.trend-expanded-close:hover { + background: rgba(30, 41, 59, 0.92); + border-color: rgba(248, 250, 252, 0.38); +} + + + +.export-menu { + position: relative; +} + +.export-menu > summary { + list-style: none; +} + +.export-menu > summary::-webkit-details-marker { + display: none; +} + +.export-menu-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + display: grid; + gap: 6px; + min-width: 132px; + padding: 8px; + border: 1px solid var(--glass-border); + border-radius: 12px; + background: rgba(15, 23, 42, 0.92); + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.34); +} + +.export-menu-panel .mini-btn { + justify-content: flex-start; + width: 100%; +} + +.trend-footer button { + cursor: pointer; +} + +.trend-expanded-canvas { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.86); + border-radius: 0; +} + +.trend-expanded-canvas .trend-chart { + border-radius: 0; + border-color: rgba(148, 163, 184, 0.2); +} + +.trend-expanded-close { + position: absolute; + top: 12px; + right: 12px; + width: 34px; + height: 34px; + border-radius: 999px; + border: 1px solid rgba(248, 250, 252, 0.26); + background: rgba(15, 23, 42, 0.66); + color: white; + font-size: 22px; + font-weight: 900; + cursor: pointer; + z-index: 2; +} + +.sensor-element { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 10px; + border: 3px solid; + background: rgba(15, 23, 42, 0.82); + border-radius: 18px; + padding: 10px; +} + +.sensor-element.is-setpoint { + background: rgba(8, 47, 73, 0.82); +} + +.sensor-marker { + width: 34px; + height: 34px; + border-radius: 999px; + display: grid; + place-items: center; + color: #0f172a; + font-weight: 950; + font-size: 13px; + border: 2px solid rgba(255,255,255,.5); + flex: 0 0 auto; +} + +.sensor-label { + color: var(--sensor-label-color, rgba(209, 213, 219, 0.78)); + font-size: var(--sensor-label-size, 12px); + font-weight: 800; +} + +.sensor-value { + font-weight: 900; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.sensor-color-editor { + display: grid; + gap: 8px; +} + +.sensor-copy-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.sensor-copy-grid button { + min-height: 28px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + color: rgba(248, 250, 252, 0.88); + font-weight: 900; + cursor: pointer; +} + +.setpoint-edit-btn { + position: absolute; + right: 8px; + top: 8px; + border: 1px solid rgba(125, 211, 252, .6); + background: rgba(14, 116, 144, .75); + color: white; + border-radius: 10px; + width: 28px; + height: 28px; + cursor: pointer; +} + +.setpoint-popover { + position: absolute; + right: 0; + top: 42px; + width: 230px; + padding: 10px; + border-radius: 16px; + border: 1px solid rgba(125, 211, 252, .5); + background: rgba(15, 23, 42, .96); + z-index: 999999; + box-shadow: 0 20px 50px rgba(0,0,0,.4); +} + +.setpoint-popover form { + display: grid; + gap: 8px; +} + +.setpoint-actions { + display: flex; + gap: 8px; +} + +/* -------------------------------------------------------- + Angeheftete Werte +-------------------------------------------------------- */ + +.attached-value { + position: absolute; + transform: translate(-50%, -50%); + min-width: 90px; + padding: 5px 8px; + border-radius: 10px; + background: rgba(15, 23, 42, .84); + border: 1px solid rgba(148, 163, 184, .45); + color: white; + text-align: center; + pointer-events: auto; + cursor: help; + z-index: 3; +} + +.attached-value span { + display: block; + color: rgba(209, 213, 219, .72); + font-weight: 700; +} + +.attached-value strong { + display: block; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.viewer-tooltip { + position: fixed; + z-index: 9999998; + width: min(300px, calc(100vw - 16px)); + max-height: min(300px, calc(100vh - 16px)); + overflow: auto; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 14px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(15, 23, 42, 0.5)); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.38); + backdrop-filter: blur(16px); + padding: 8px 9px; + pointer-events: none; +} + +.viewer-tooltip-title { + color: rgba(241, 245, 249, 0.88); + font-size: 10px; + font-weight: 900; + letter-spacing: .05em; + text-transform: uppercase; + margin-bottom: 6px; +} + +.viewer-tooltip-list { + display: grid; + gap: 7px; +} + +.viewer-tooltip-row { + display: grid; + gap: 2px; + padding: 7px 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 11px; + background: rgba(255, 255, 255, 0.05); +} + +.viewer-tooltip-row strong { + color: white; + font-size: 11px; + line-height: 1.2; +} + +.viewer-tooltip-row span { + color: #f8fafc; + font-size: 12px; + font-weight: 900; + line-height: 1.2; +} + +.viewer-tooltip-row small { + color: rgba(209, 213, 219, 0.74); + font-size: 10px; + line-height: 1.2; +} + +.viewer-tooltip-position-select { + width: 140px; + min-width: 140px; +} + +.viewer-trend-timebar { + border: 1px solid var(--glass-border); + background: #ffffff12; + border-radius: 10px; + align-items: center; + gap: 6px; + padding: 4px 6px; + display: inline-flex; +} + +.viewer-trend-timebar span { + color: #e2e8f0d1; + font-size: 10px; + font-weight: 800; +} + +.viewer-trend-range-select { + width: 86px; + min-width: 86px; + padding: 4px 7px; + font-size: 10px; +} + +.monitoring-page { + gap: 10px; +} + +.monitoring-layout { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(360px, 1.1fr); + gap: 10px; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.monitoring-panel { + min-height: 0; + overflow: auto; + padding: 10px; + border: 1px solid var(--glass-border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.07); +} + +.monitoring-point-list, +.monitoring-selection-list, +.monitoring-saved-list { + display: grid; + gap: 6px; + margin-top: 10px; +} + +.monitoring-point-row, +.monitoring-selection-row, +.monitoring-saved-row { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 12px; + background: rgba(15, 23, 42, 0.42); + padding: 8px; +} + +.monitoring-point-row { + width: 100%; + color: white; + text-align: left; + cursor: pointer; +} + +.monitoring-point-row.is-selected { + border-color: rgba(96, 165, 250, 0.78); + background: rgba(37, 99, 235, 0.26); +} + +.monitoring-point-row strong, +.monitoring-selection-row strong { + display: block; + color: rgba(248, 250, 252, 0.95); + font-size: 11px; +} + +.monitoring-point-row span, +.monitoring-selection-row span { + display: block; + margin-top: 3px; + color: rgba(203, 213, 225, 0.72); + font-size: 10px; +} + +.monitoring-selection-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: end; +} + +.monitoring-selection-row, +.monitoring-saved-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.monitoring-export-layout { + display: grid; + grid-template-columns: minmax(360px, 1.15fr) minmax(320px, 0.85fr); + gap: 10px; +} + +.monitoring-export-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.monitoring-export-head h2 { + margin: 4px 0 0; + font-size: 18px; +} + +.monitoring-job-list, +.monitoring-file-list { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.monitoring-job-row, +.monitoring-file-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 14px; + background: + linear-gradient(135deg, rgba(15, 23, 42, 0.56), rgba(30, 41, 59, 0.34)), + rgba(255, 255, 255, 0.03); +} + +.monitoring-job-row strong, +.monitoring-file-row strong { + display: block; + color: rgba(248, 250, 252, 0.95); + font-size: 12px; +} + +.monitoring-job-row span, +.monitoring-file-row span { + display: block; + margin-top: 4px; + color: rgba(203, 213, 225, 0.76); + font-size: 10px; + line-height: 1.45; +} + +.monitoring-row-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.monitoring-row-actions .mini-btn { + text-decoration: none; +} + +.monitoring-job-form { + margin-top: 12px; + padding: 12px; + border: 1px solid rgba(96, 165, 250, 0.18); + border-radius: 18px; + background: + radial-gradient(circle at top right, rgba(34, 197, 94, 0.08), transparent 42%), + linear-gradient(180deg, rgba(15, 23, 42, 0.5), rgba(15, 23, 42, 0.28)); + display: grid; + gap: 10px; +} + +.monitoring-weekdays { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.monitoring-weekday { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 999px; + background: rgba(15, 23, 42, 0.36); + color: rgba(226, 232, 240, 0.82); + cursor: pointer; + transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease; +} + +.monitoring-weekday input { + margin: 0; + accent-color: #38bdf8; +} + +.monitoring-weekday.is-selected { + border-color: rgba(56, 189, 248, 0.7); + background: rgba(14, 116, 144, 0.28); + color: rgba(240, 249, 255, 0.98); +} + +.monitoring-job-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + font-size: 11px; + color: rgba(191, 219, 254, 0.84); +} + +.danger-text { + color: rgba(252, 165, 165, 0.96); +} + +@media (max-width: 1200px) { + .monitoring-export-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 960px) { + .monitoring-layout { + grid-template-columns: 1fr; + } + + .monitoring-selection-head, + .monitoring-job-row, + .monitoring-file-row { + grid-template-columns: 1fr; + } + + .monitoring-job-row, + .monitoring-file-row, + .monitoring-selection-row, + .monitoring-saved-row { + align-items: flex-start; + flex-direction: column; + } + + .monitoring-row-actions { + width: 100%; + justify-content: flex-start; + } +} + +.shape-svg { + width: 100%; + height: 100%; + overflow: visible; +} + +.instrument-svg { + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.instrument-rail, +.instrument-range, +.instrument-fill { + fill: none; + stroke-width: 15; + stroke-linecap: butt; +} + +.instrument-fill { + stroke-linecap: round; +} + +.instrument-dial-rail, +.instrument-dial-range, +.instrument-dial-fill { + fill: none; + stroke-width: 16; + stroke-linecap: butt; +} + +.instrument-dial-fill { + stroke-linecap: round; +} + +.instrument-major, +.instrument-minor { + stroke: #e2e8f0; + stroke-width: 2; +} + +.instrument-minor { + stroke: #94a3b8; + stroke-width: 1.4; +} + +.instrument-label { + fill: #e2e8f0; + text-anchor: middle; + font-weight: 700; + user-select: none; +} + +.instrument-label.end { + text-anchor: end; +} + +.instrument-value { + fill: white; + text-anchor: middle; + font-size: 14px; + font-weight: 900; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.instrument-needle { + stroke-width: 4; + stroke-linecap: round; +} + +.instrument-needle.slim { + stroke-width: 2; +} + +.instrument-pointer-line { + stroke-width: 3; + stroke-linecap: round; +} + +.shape-flow-line { + pointer-events: none; + animation-name: shape-flow; + animation-timing-function: linear; + animation-iteration-count: infinite; +} + +@keyframes shape-flow { + from { + stroke-dashoffset: 0; + } + + to { + stroke-dashoffset: -64; + } +} + +.poly-point { + fill: #bbf7d0; + stroke: #15803d; + stroke-width: 3; + cursor: grab; +} + +.draft-line-layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + z-index: 999998; +} + +/* -------------------------------------------------------- + Symbolleiste unten +-------------------------------------------------------- */ + +.asset-bottom-bar { + flex: 0 0 auto; + margin-top: 8px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + border-radius: 22px; + padding: 8px; + + /* vorher 188px */ + height: 215px; + + overflow: hidden; + display: flex; + flex-direction: column; +} + +.asset-bottom-head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; +} + +.asset-bottom-head strong { + color: white; +} + +.asset-bottom-head span { + color: rgba(209, 213, 219, .7); + font-size: 12px; +} + +.asset-bottom-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.asset-bottom-actions .btn { + flex: 0 0 96px; + width: 96px; +} + +.asset-search { + flex: 0 0 192px; + width: 192px; + min-width: 192px; +} + +.asset-category-strip { + display: flex; + gap: 10px; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 4px; +} + +.asset-category-strip.full { + flex: 1 1 auto; + min-height: 0; +} + +.asset-category { + min-width: 245px; + max-width: 245px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + padding: 8px; + background: rgba(255, 255, 255, 0.04); + + /* vorher war hier overflow:hidden; das killt die Buttons */ + overflow: visible; +} + + +.asset-category-title { + font-size: 12px; + font-weight: 900; + color: white; + margin-bottom: 8px; +} + +.asset-row-scroll { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 8px; + + /* wichtig: genug Höhe für Bild + Name + Icons */ + height: 118px; + + /* X/Y sauber */ + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 6px; +} + +.asset-tile { + flex: 0 0 72px; + min-height: 104px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 5px; + overflow: visible; +} + + +.asset-tile-main span { + display: block; + margin-top: 5px; + font-size: 10px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-height: 14px; +} + +.asset-tile-main span { + display: block; + margin-top: 5px; + font-size: 10px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + +.asset-preview, +.state-image { + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + display: grid; + place-items: center; + overflow: hidden; + margin: 0 auto; +} + +.asset-preview img, +.state-image img { + max-width: 100%; + max-height: 100%; +} + +.state-image span { + color: rgba(226, 232, 240, 0.68); + font-size: 8px; + font-weight: 900; + text-align: center; + line-height: 1.1; +} + + +.asset-icon-actions { + display: flex; + justify-content: center; + gap: 4px; + margin-top: 6px; + min-height: 24px; +} + + + +.asset-icon-actions button, +.icon-btn { + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.08); + color: white; + border-radius: 8px; + cursor: pointer; + padding: 3px 5px; + font-size: 11px; + line-height: 1; +} + +.danger-icon { + background: rgba(127, 29, 29, .72); +} + +/* Redesigned symbol library */ +.asset-bottom-bar { + margin-top: 10px; + height: 170px; + padding: 8px; +} + +.asset-bottom-head { + margin-bottom: 8px; +} + +.asset-search { + width: 192px; + padding: 6px 9px; +} + +.asset-category-strip { + align-items: stretch; + gap: 9px; + padding: 0 2px 5px 0; + scrollbar-width: thin; +} + +.asset-category { + flex: 0 0 185px; + min-width: 185px; + max-width: none; + height: 100%; + border-radius: 15px; + padding: 6px; + background: rgba(255, 255, 255, 0.04); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.asset-category-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; + font-size: 10px; + font-weight: 800; + line-height: 1.1; +} + +.asset-category-title span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-category-title em { + font-style: normal; + min-width: 18px; + height: 17px; + padding: 1px 5px; + border-radius: 999px; + color: #bfdbfe; + background: rgba(37, 99, 235, .28); + text-align: center; + font-size: 9px; +} + +.asset-row-scroll { + flex: 1 1 auto; + min-height: 0; + height: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + gap: 5px; + overflow-x: hidden; + overflow-y: auto; + padding: 0; +} + +.asset-tile { + min-width: 0; + min-height: 0; + height: 72px; + padding: 4px; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 3px; +} + +.asset-tile-main { + flex: 1 1 auto; + min-height: 0; + width: 100%; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + border: 0; + background: transparent; + color: white; + cursor: pointer; +} + +.asset-tile-main strong { + display: block; + width: 100%; + font-size: 8px; + line-height: 1.1; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.asset-tile .asset-preview { + width: 28px; + height: 28px; +} + +.asset-context-menu { + width: 150px; +} + +/* Compact symbol category bar */ +.asset-bottom-bar { + height: 42px; + margin-top: 6px; + padding: 5px 8px; + overflow: visible; + position: relative; + z-index: 30; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.045); + border-color: rgba(255, 255, 255, 0.12); +} + +.asset-bottom-head { + display: contents; + margin: 0; +} + +.asset-bottom-head > div:first-child { + grid-row: 1; + grid-column: 1; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + opacity: 0.86; +} + +.asset-bottom-head strong { + font-size: 10px; + letter-spacing: 0.01em; +} + +.asset-bottom-head span { + font-size: 8px; + color: rgba(209, 213, 219, 0.55); +} + +.asset-bottom-actions { + grid-row: 1; + grid-column: 3; + gap: 6px; +} + +.asset-bottom-actions .btn { + flex-basis: 78px; + width: 78px; + padding-inline: 7px; +} + +.asset-search { + flex-basis: 160px; + width: 160px; + min-width: 160px; + height: 26px; + padding: 4px 8px; + border-radius: 9px; +} + +.asset-category-strip, +.asset-category-strip.full { + grid-row: 1; + grid-column: 2; + flex: 0 0 auto; + min-height: 0; + min-width: 0; + overflow: visible; + display: flex; + align-items: center; + gap: 5px; + padding: 0; + justify-content: flex-start; +} + +.asset-category { + position: relative; + flex: 0 0 auto; + min-width: 0; + width: auto; + height: auto; + padding: 0; + border: 0; + background: transparent; + overflow: visible; +} + +.asset-category::after { + content: ""; + position: absolute; + left: 0; + bottom: 100%; + width: min(420px, 70vw); + height: 12px; + pointer-events: auto; +} + +.asset-category-title { + margin: 0; + min-height: 22px; + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 999px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.105), rgba(255, 255, 255, 0.055)); + padding: 2px 5px 2px 6px; + cursor: default; + white-space: nowrap; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 1px 5px rgba(0, 0, 0, 0.16); +} + +.asset-category-title::before { + content: attr(data-icon); + width: 16px; + height: 16px; + border-radius: 999px; + display: inline-grid; + place-items: center; + font-size: 10px; + line-height: 1; + background: rgba(255, 255, 255, 0.09); +} + +.asset-category-title span { + max-width: 126px; + font-size: 8px; + line-height: 1; + color: rgba(248, 250, 252, 0.82); + font-weight: 800; +} + +.asset-category-title em { + min-width: 15px; + height: 15px; + padding: 1px 4px; + font-size: 8px; + color: rgba(219, 234, 254, 0.88); + background: rgba(96, 165, 250, 0.24); +} + +.asset-category:hover .asset-category-title, +.asset-category:focus-within .asset-category-title { + background: linear-gradient(180deg, rgba(191, 219, 254, 0.22), rgba(147, 197, 253, 0.12)); + border-color: rgba(191, 219, 254, 0.36); + transform: translateY(-1px); +} + +.asset-category .asset-row-scroll { + position: absolute; + left: 0; + bottom: calc(100% + 10px); + width: min(390px, 70vw); + max-height: 260px; + height: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(66px, 1fr)); + gap: 6px; + overflow: auto; + padding: 10px; + border: 1px solid var(--glass-border); + border-radius: 18px; + background: linear-gradient(135deg, rgba(42, 50, 61, 0.86), rgba(17, 24, 39, 0.82)); + backdrop-filter: blur(22px); + box-shadow: 0 18px 46px rgba(0, 0, 0, 0.42); + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(6px); + transition: opacity .12s ease, transform .12s ease, visibility .12s ease; + z-index: 50; +} + +.asset-category:hover .asset-row-scroll, +.asset-category:focus-within .asset-row-scroll { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(0); +} + +.asset-category:nth-last-child(-n + 2) .asset-row-scroll { + left: auto; + right: 0; +} + +.asset-category .asset-tile { + height: 74px; + min-height: 0; + border-radius: 12px; + background: rgba(255, 255, 255, 0.075); + border-color: rgba(255, 255, 255, 0.13); + transition: transform .12s ease, background .12s ease, border-color .12s ease; +} + +.asset-category .asset-tile:hover { + transform: translateY(-1px); + background: rgba(191, 219, 254, 0.14); + border-color: rgba(191, 219, 254, 0.32); +} + +.asset-category .asset-tile .asset-preview { + width: 32px; + height: 32px; + border-radius: 10px; + background: rgba(2, 6, 23, 0.28); +} + +.asset-category .asset-tile-main strong { + color: rgba(248, 250, 252, 0.9); + font-size: 8px; + line-height: 1.15; +} + +/* -------------------------------------------------------- + Modal Symbolverwaltung +-------------------------------------------------------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 9999999; + display: grid; + place-items: center; + padding: 24px; + background: rgba(2, 6, 23, 0.62); + backdrop-filter: blur(8px); +} + +.asset-modal { + width: min(920px, 96vw); + max-height: min(860px, 92vh); + overflow: auto; + border: 1px solid rgba(96, 165, 250, 0.42); + border-radius: 28px; + background: linear-gradient(135deg, rgba(15, 23, 42, .96), rgba(30, 41, 59, .92)); + box-shadow: 0 28px 90px rgba(0,0,0,.55); + padding: 18px; +} + +.image-library-modal { + width: min(980px, 96vw); +} + +.image-library-drop { + display: grid; + gap: 6px; + place-items: center; + margin-bottom: 12px; + padding: 18px; + border: 1px dashed rgba(147, 197, 253, 0.55); + border-radius: 16px; + background: rgba(15, 23, 42, 0.34); + color: rgba(241, 245, 249, 0.94); + text-align: center; +} + +.image-library-drop span { + color: rgba(203, 213, 225, 0.72); + font-size: 11px; +} + +.image-library-drop input { + max-width: 260px; + font-size: 11px; +} + +.image-library-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 10px; +} + +.image-library-card { + display: grid; + gap: 7px; + padding: 8px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 14px; + background: rgba(15, 23, 42, 0.28); +} + +.image-library-card.is-selected { + border-color: rgba(96, 165, 250, 0.85); + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.28); +} + +.image-library-preview { + height: 96px; + padding: 6px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 12px; + background: + linear-gradient(45deg, rgba(255, 255, 255, 0.07) 25%, transparent 25%), + linear-gradient(-45deg, rgba(255, 255, 255, 0.07) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.07) 75%), + linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.07) 75%); + background-size: 18px 18px; + background-position: 0 0, 0 9px, 9px -9px, -9px 0; + cursor: pointer; +} + +.image-library-preview img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.image-library-info { + min-width: 0; + display: grid; + gap: 2px; +} + +.image-library-info strong { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; +} + +.image-library-info span { + color: rgba(203, 213, 225, 0.72); + font-size: 10px; +} + +.state-image-picker { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.state-image-picker-preview { + width: 54px; + height: 54px; + padding: 4px; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 12px; + background: rgba(15, 23, 42, 0.38); +} + +.state-image-picker-preview img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.state-image-picker-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.modal-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 14px; +} + +.icon-close { + width: 38px; + height: 38px; + border-radius: 14px; + border: 1px solid rgba(75, 85, 99, 0.82); + background: rgba(31, 41, 55, 0.72); + color: white; + font-weight: 900; + cursor: pointer; +} + +.asset-modal-form { + display: grid; + gap: 12px; + margin-bottom: 14px; +} + +.modal-grid { + display: grid; + grid-template-columns: 1.4fr 220px 150px; + gap: 12px; + align-items: end; +} + +.modal-wide { + grid-column: 1 / -1; +} + +.modal-check { + padding-bottom: 9px; +} + +.modal-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.template-map-row { + display: grid; + grid-template-columns: minmax(150px, 0.7fr) minmax(220px, 1.3fr); + gap: 12px; + align-items: start; + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 16px; + background: rgba(15, 23, 42, 0.36); +} + +.template-binding-field { + display: grid; + gap: 7px; +} + +.segmented { + display: inline-flex; + gap: 4px; + padding: 3px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 999px; + background: rgba(15, 23, 42, 0.24); +} + +.segmented button { + border: 0; + border-radius: 999px; + background: transparent; + color: rgba(226, 232, 240, 0.76); + font-weight: 900; + cursor: pointer; +} + +.segmented.tiny button { + padding: 5px 9px; + font-size: 10px; +} + +.segmented button.active { + color: #ffffff; + background: rgba(96, 165, 250, 0.48); +} + +.compact-field { + margin: 0; +} + +.asset-form-grid, +.rule-grid, +.prop-grid-2, +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.prop-grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.range-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 88px; + gap: 8px; + align-items: center; +} + +.z-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.check-line { + display: flex; + align-items: center; + gap: 8px; + color: rgba(209, 213, 219, 0.78); + font-size: 12px; + font-weight: 800; +} + +.properties .check-line { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 7px; + margin: 0; +} + +.properties .check-line input[type="checkbox"] { + width: auto; + flex: 0 0 auto; + margin: 0; + padding: 0; + accent-color: #3b82f6; +} + +.states-editor { + display: grid; + gap: 10px; + border: 1px solid rgba(75, 85, 99, 0.72); + background: rgba(2, 6, 23, 0.32); + border-radius: 16px; + padding: 10px; +} + +.state-form { + display: grid; + gap: 8px; + border: 1px solid rgba(75, 85, 99, 0.72); + background: rgba(2, 6, 23, 0.38); + border-radius: 16px; + padding: 10px; +} + +.state-form.compact, +.states-editor.compact { + padding: 8px; +} + +.state-list { + display: grid; + gap: 8px; +} + +.state-row { + display: grid; + grid-template-columns: 46px 1fr auto auto; + gap: 8px; + align-items: center; + border: 1px solid rgba(75, 85, 99, 0.62); + border-radius: 14px; + padding: 8px; +} + +.state-info strong { + display: block; + font-size: 12px; + color: white; +} + +.state-info span { + display: block; + font-size: 10px; + color: rgba(209, 213, 219, 0.62); +} + +/* -------------------------------------------------------- + Properties +-------------------------------------------------------- */ + +.properties { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + height: 100%; + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: thin; +} + +.properties-scroll { + flex: 0 0 auto; + min-height: auto; + overflow-y: visible; + overflow-x: hidden; + display: grid; + align-content: start; + gap: 6px; + padding-right: 0; +} + +.properties-footer { + flex: 0 0 auto; + padding-top: 2px; +} + +.properties-footer .btn { + width: 100%; +} + +.properties h3 { + margin-bottom: 5px; + font-size: 11px; +} + +.property-section { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + overflow: hidden; + min-height: 0; +} + +.property-section summary { + position: relative; + list-style: none; + cursor: pointer; + padding: 8px 32px 8px 9px; + color: #e5e7eb; + font-size: 11px; + font-weight: 900; + user-select: none; +} + +.property-section summary::-webkit-details-marker { + display: none; +} + +.property-section summary::after { + content: "+"; + position: absolute; + right: 10px; + top: 5px; + color: rgba(226, 232, 240, 0.76); + font-size: 18px; + line-height: 1; +} + +.property-section[open] summary { + background: rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.property-section[open] summary::after { + content: "-"; +} + +.property-section-body { + display: grid; + gap: 7px; + padding: 7px; + min-height: 0; + max-height: none; + overflow: visible; +} + +.properties .field { + gap: 4px; +} + +.properties .field label, +.properties .small-title, +.properties .small-muted, +.properties .check-line, +.properties .rule-footer, +.properties .state-form label, +.properties .success-notice { + font-size: 10px; +} + +.properties .form-input, +.properties .field input, +.properties .field select, +.properties input, +.properties select { + border-radius: 9px; + padding: 6px 8px; + font-size: 11px; +} + +.property-section-body .rules-editor, +.property-section-body .attached-editor { + border-top: 0; + padding-top: 0; +} + +.properties .color-pair-editor { + grid-template-columns: 1fr; +} + +.properties .color-copy-buttons { + display: flex; + justify-content: center; + gap: 6px; + padding-top: 0; +} + +.rotation-editor { + display: grid; + gap: 8px; +} + +.rotation-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + color: rgba(209, 213, 219, 0.8); + font-weight: 800; +} + +.rotation-jumps { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.arrow-rotation-jumps .btn { + min-width: 0; + padding-inline: 0; +} + +.rules-editor, +.attached-editor { + display: grid; + gap: 7px; + border-top: 1px solid rgba(75, 85, 99, 0.72); + padding-top: 8px; +} + +.rule-box, +.attached-editor-row { + display: grid; + gap: 6px; + border: 1px solid rgba(75, 85, 99, 0.72); + background: rgba(2, 6, 23, 0.34); + border-radius: 11px; + padding: 7px; +} + +.trend-editor { + display: grid; + gap: 8px; +} + +.trend-series-list { + display: grid; + gap: 7px; + border-top: 1px solid rgba(75, 85, 99, 0.72); + padding-top: 8px; +} + +.trend-series-row { + display: grid; + gap: 6px; + border: 1px solid rgba(75, 85, 99, 0.72); + background: rgba(2, 6, 23, 0.28); + border-radius: 11px; + padding: 7px; +} + +.rule-footer { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + color: rgba(209, 213, 219, 0.65); + font-size: 11px; +} + +.color-pair-editor { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 8px; + align-items: start; +} + +.color-editor { + display: grid; + gap: 4px; + min-width: 0; +} + +.color-main-row { + display: grid; + grid-template-columns: 42px 1fr; + gap: 6px; +} + +.color-main-row input[type="color"] { + height: 34px; + padding: 2px; +} + +.rgb-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 4px; +} + +.rgb-row input { + padding: 5px 4px; + text-align: center; + font-size: 11px; +} + +.color-copy-buttons { + display: grid; + gap: 6px; + padding-top: 21px; +} + +.color-copy-buttons button { + width: 30px; + height: 30px; + border-radius: 10px; + border: 1px solid rgba(75, 85, 99, 0.82); + background: rgba(31, 41, 55, 0.72); + color: white; + cursor: pointer; + font-weight: 900; +} + +.gradient-origin-picker { + width: 108px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 5px; + padding: 5px; + border: 1px solid rgba(75, 85, 99, 0.72); + border-radius: 14px; + background: rgba(2, 6, 23, 0.32); +} + +.gradient-origin-picker button { + width: 25px; + height: 25px; + display: grid; + place-items: center; + border: 1px solid rgba(75, 85, 99, 0.82); + border-radius: 8px; + background: rgba(31, 41, 55, 0.72); + cursor: pointer; +} + +.gradient-origin-picker button.active { + border-color: rgba(96, 165, 250, 0.92); + background: rgba(37, 99, 235, 0.55); +} + +.gradient-origin-picker button span { + width: 9px; + height: 9px; + border-radius: 50%; + background: #93c5fd; +} + +.instrument-editor, +.instrument-ranges { + display: grid; + gap: 7px; +} + +.instrument-colors { + grid-template-columns: 1fr; + align-items: start; +} + +.instrument-range-row { + display: grid; + grid-template-columns: 1fr 1fr 38px 32px; + gap: 5px; + align-items: center; +} + +.instrument-range-row input[type="color"] { + height: 32px; + padding: 2px; +} + +.instrument-range-row .mini-btn { + padding: 6px 4px; +} + +.success-notice { + border: 1px solid rgba(74, 222, 128, .58); + background: rgba(22, 101, 52, .52); + color: #dcfce7; + border-radius: 11px; + padding: 8px 10px; + font-size: 12px; + font-weight: 800; +} + +/* -------------------------------------------------------- + Kontextmenü +-------------------------------------------------------- */ + +.context-menu { + position: fixed; + z-index: 9999999; + width: 186px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(15, 23, 42, 0.56)); + border-radius: 12px; + padding: 4px; + box-shadow: 0 14px 34px rgba(0,0,0,.34); + backdrop-filter: blur(16px); +} + +.context-menu button { + width: 100%; + border: 0; + background: transparent; + color: white; + cursor: pointer; + text-align: left; + padding: 7px 8px; + border-radius: 9px; + font-weight: 800; + font-size: 10px; + line-height: 1.2; +} + +.context-menu button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.context-menu button.danger-row:hover { + background: rgba(185, 28, 28, 0.28); +} + +/* -------------------------------------------------------- + Responsive +-------------------------------------------------------- */ + +@media (max-width: 1500px) { + .designer-layout { + grid-template-columns: 270px minmax(0, 1fr); + } + + .properties { + grid-column: 1 / -1; + max-height: 320px; + } + + .project-visu-grid { + grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); + } +} + +@media (max-width: 1100px) { + .viewer-layout { + grid-template-columns: 1fr; + } + + .viewer-tree { + max-height: 220px; + } + + .viewer-tree-list { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + } +} + +@media (max-width: 1000px) { + body.visu-page, + body:has(#root .visu-workspace), + body:has(#root .viewer-full), + body:has(#root .overview-full) { + overflow: auto; + } + + #root { + height: auto; + max-height: none; + overflow: visible; + } + + .page-shell.full, + .overview-full { + overflow: auto; + height: auto; + } + + .form-grid, + .designer-layout, + .modal-grid { + grid-template-columns: 1fr; + } + + .form-wide, + .modal-wide { + grid-column: auto; + } + + .toolbox, + .properties { + height: auto; + max-height: none; + } + + .canvas-scroll, + .viewer-scroll { + height: 70vh; + } + + .asset-bottom-bar { + height: auto; + max-height: 360px; + overflow: auto; + } + + .color-pair-editor { + grid-template-columns: 1fr; + } + + .color-copy-buttons { + grid-template-columns: 1fr 1fr; + padding-top: 0; + } + + .overview-head-actions { + width: 100%; + min-width: 0; + justify-content: stretch; + flex-wrap: wrap; + } + + .overview-search { + min-width: 0; + flex: 1; + } + + .project-visu-grid { + grid-template-columns: 1fr; + } + + .project-page-grid { + grid-template-columns: 1fr; + } +} + +.viewer-tree { + min-height: 0; + overflow: auto; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + border-radius: 22px; + padding: 10px; + position: relative; +} + +.viewer-tree-title { + color: white; + font-weight: 900; + font-size: 11px; + margin-bottom: 8px; +} + +.viewer-tree-search { + margin-bottom: 8px; +} + +.viewer-tree-project-list { + display: grid; + gap: 8px; + padding-bottom: 28px; +} + +.viewer-tree-project-block { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + border-radius: 16px; + overflow: hidden; +} + +.viewer-tree-project-block.has-current-page { + border-color: rgba(226, 232, 240, 0.28); + background: rgba(191, 219, 254, 0.12); +} + +.viewer-tree-project-toggle { + width: 100%; + border: 0; + background: transparent; + color: white; + padding: 8px; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 6px; + align-items: start; + cursor: pointer; + text-align: left; +} + +.viewer-tree-project-toggle:hover { + background: rgba(255, 255, 255, 0.08); +} + +.viewer-tree-arrow { + color: rgba(226, 232, 240, 0.76); + font-size: 12px; + line-height: 1.2; + padding-top: 1px; +} + +.viewer-tree-project-text { + min-width: 0; + display: grid; + gap: 2px; +} + +.viewer-tree-project-text strong { + color: rgba(241, 245, 249, 0.96); + font-size: 11px; + line-height: 1.25; +} + +.viewer-tree-project-text small { + color: rgba(226, 232, 240, 0.78); + font-size: 10px; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-tree-customer { + color: rgba(209, 213, 219, 0.55) !important; +} + +.viewer-tree-project-toggle em { + min-width: 24px; + height: 20px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: rgba(241, 245, 249, 0.92); + font-size: 10px; + font-style: normal; + font-weight: 900; + display: grid; + place-items: center; +} + +.viewer-tree-list { + display: grid; + gap: 6px; + padding: 0 6px 6px 24px; +} + +.viewer-tree-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + border-radius: 14px; + overflow: hidden; +} + +.viewer-tree-row.active { + border-color: rgba(226, 232, 240, 0.3); + background: rgba(191, 219, 254, 0.14); +} + +.viewer-tree-row a { + display: grid; + color: white; + text-decoration: none; + padding: 7px 8px; + min-width: 0; +} + +.viewer-tree-row a span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 800; + font-size: 11px; +} + +.viewer-tree-row a small { + color: rgba(209, 213, 219, 0.6); + font-size: 9px; + margin-top: 2px; +} + +.viewer-tree-edit { + display: grid !important; + place-items: center; + width: 30px; + height: 100%; + padding: 0 !important; + border-left: 1px solid rgba(75, 85, 99, 0.58); +} + +.viewer-tree-collapse { + position: absolute; + left: 10px; + bottom: 10px; + width: 22px; + height: 22px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(241, 245, 249, 0.88); + display: grid; + place-items: center; + cursor: pointer; + font-size: 11px; + font-weight: 900; + z-index: 2; +} + +.viewer-tree-collapse-floating { + left: 10px; + bottom: 10px; +} + +.viewer-focus-toggle { + position: absolute; + top: 10px; + right: 10px; + width: 24px; + height: 24px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(241, 245, 249, 0.88); + display: grid; + place-items: center; + cursor: pointer; + font-size: 11px; + font-weight: 900; + z-index: 2; +} + +.sankey-chart { + position: relative; + width: 100%; + height: 100%; + border: 1px solid rgba(148, 163, 184, 0.38); + border-radius: 16px; + overflow: hidden; +} + +.sankey-chart svg { + width: 100%; + height: 100%; + display: block; +} + +.sankey-chart-head { + position: absolute; + top: 10px; + left: 12px; + right: 12px; + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 11px; + color: rgba(226, 232, 240, 0.86); + pointer-events: none; +} + +.sankey-chart-head strong { + font-size: 12px; + color: #f8fafc; +} + +.sankey-legend { + position: absolute; + left: 12px; + right: 12px; + bottom: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 10px; + color: rgba(226, 232, 240, 0.84); + pointer-events: none; +} + +.sankey-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.sankey-legend i { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; +} + +.sankey-node-label, +.sankey-link-label { + font-size: 11px; + font-weight: 700; +} + +.sankey-link-label { + paint-order: stroke; + stroke: rgba(15, 23, 42, 0.5); + stroke-width: 4px; + stroke-linejoin: round; +} + +.sankey-editor, +.vdp-editor-panel { + display: grid; + gap: 14px; +} + +.sankey-overview-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.sankey-overview-card, +.sankey-control-card { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 16px; + background: rgba(15, 23, 42, 0.22); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.sankey-overview-card span { + font-size: 11px; + color: rgba(191, 219, 254, 0.78); +} + +.sankey-overview-card strong { + font-size: 24px; + color: #f8fafc; +} + +.sankey-control-grid { + display: grid; + gap: 12px; +} + +.sankey-section-head, +.vdp-formula-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.sankey-node-list, +.sankey-link-list, +.vdp-formula-editor { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: rgba(15, 23, 42, 0.22); +} + +.sankey-node-row, +.sankey-link-row, +.vdp-token-card, +.vdp-preview-card { + display: grid; + gap: 10px; + padding: 12px; + border-radius: 14px; + background: rgba(30, 41, 59, 0.5); + border: 1px solid rgba(148, 163, 184, 0.14); +} + +.vdp-layout { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 16px; + min-height: 0; +} + +.vdp-sidebar { + display: grid; + align-content: start; + gap: 12px; +} + +.vdp-point-list { + display: grid; + gap: 8px; + max-height: 62vh; + overflow: auto; +} + +.vdp-point-row { + display: grid; + gap: 3px; + text-align: left; + padding: 10px 12px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.3); + border: 1px solid rgba(148, 163, 184, 0.16); + color: #e2e8f0; +} + +.vdp-point-row.is-selected { + border-color: rgba(56, 189, 248, 0.48); + background: rgba(14, 165, 233, 0.18); +} + +.vdp-point-row span { + color: rgba(191, 219, 254, 0.78); + font-size: 11px; +} + +.vdp-token-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.vdp-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.vdp-flow-badge { + padding: 8px 12px; + border-radius: 999px; + background: rgba(56, 189, 248, 0.14); + border: 1px solid rgba(56, 189, 248, 0.22); + color: #bae6fd; + font-size: 12px; + font-weight: 700; +} + +.vdp-token-card { + display: grid; + gap: 10px; + width: 280px; + min-height: 170px; + cursor: grab; +} + +.vdp-token-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 10px; +} + +.vdp-token-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: auto; +} + +.vdp-token-live { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + margin-top: auto; + padding-top: 6px; +} + +.vdp-token-live span, +.vdp-flow-target-value span { + font-size: 11px; + color: rgba(191, 219, 254, 0.78); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.vdp-token-live strong, +.vdp-flow-target-value strong { + font-size: 22px; + line-height: 1; + color: #f8fafc; +} + +.vdp-flow-stage { + position: relative; + overflow: hidden; + padding: 10px 0 2px; +} + +.vdp-flow-scroll { + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 8px; +} + +.vdp-flow-list { + display: inline-flex; + align-items: stretch; + gap: 0; + min-width: 100%; +} + +.vdp-flow-item, +.vdp-flow-target-wrap { + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; +} + +.vdp-flow-link { + width: 46px; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 46px; +} + +.vdp-flow-link span { + position: relative; + display: block; + width: 100%; + height: 2px; + background: linear-gradient(90deg, rgba(56, 189, 248, 0.12), rgba(56, 189, 248, 0.75)); +} + +.vdp-flow-link span::after { + content: ""; + position: absolute; + right: -1px; + top: 50%; + width: 9px; + height: 9px; + border-top: 2px solid rgba(56, 189, 248, 0.75); + border-right: 2px solid rgba(56, 189, 248, 0.75); + transform: translateY(-50%) rotate(45deg); +} + +.vdp-flow-item:first-child .vdp-flow-link { + width: 16px; + flex-basis: 16px; +} + +.vdp-flow-item:first-child .vdp-flow-link span { + opacity: 0.28; +} + +.vdp-flow-target { + display: grid; + gap: 8px; + min-width: 220px; + padding: 18px 16px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(8, 47, 73, 0.9), rgba(14, 116, 144, 0.64)); + border: 1px solid rgba(125, 211, 252, 0.32); +} + +.vdp-flow-target strong { + font-size: 18px; + color: #f8fafc; +} + +.vdp-flow-target span { + font-size: 12px; + color: rgba(224, 242, 254, 0.82); +} + +.vdp-flow-target-value { + display: grid; + gap: 4px; + margin-top: 8px; + padding-top: 10px; + border-top: 1px solid rgba(125, 211, 252, 0.16); +} + +.vdp-formula-preview { + padding: 10px 12px; + border-radius: 12px; + background: rgba(2, 6, 23, 0.46); + color: rgba(226, 232, 240, 0.88); + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; +} + +.vdp-preview-card strong { + font-size: 12px; + color: rgba(191, 219, 254, 0.86); +} + +.vdp-preview-card span { + font-size: 18px; + font-weight: 800; + color: #f8fafc; +} + +.compact-field label { + font-size: 11px; +} + +@media (max-width: 1100px) { + .sankey-overview-strip, + .vdp-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .vdp-layout { + grid-template-columns: 1fr; + } +} diff --git a/visu-src/src/App.jsx b/visu-src/src/App.jsx new file mode 100644 index 0000000..1b0659a --- /dev/null +++ b/visu-src/src/App.jsx @@ -0,0 +1,36 @@ +import { useMemo } from 'react' +import './App.css' +import { useVisuRootHeight } from './lib/visuUtils' +import Overview from './pages/Overview' +import Viewer from './pages/Viewer' +import Designer from './pages/Designer' +import TemplateDesigner from './pages/TemplateDesigner' +import MonitoringData from './pages/MonitoringData' +import CsvExports from './pages/CsvExports' +import VirtualPoints from './pages/VirtualPoints' + +function getRoute() { + const params = new URLSearchParams(window.location.search) + + return { + mode: params.get('mode') || 'dashboard', + pageId: Number(params.get('page_id') || 0), + templateId: Number(params.get('template_id') || 0), + } +} + +export default function App() { + useVisuRootHeight() + + const route = useMemo(() => getRoute(), []) + + if (route.mode === 'designer') return + if (route.mode === 'templates') return + if (route.mode === 'monitoring') return + if (route.mode === 'csv_exports') return + if (route.mode === 'virtual_points') return + if (route.mode === 'viewer') return + if (route.mode === 'manage') return + + return +} diff --git a/visu-src/src/assets/hero.png b/visu-src/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/visu-src/src/assets/react.svg b/visu-src/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/visu-src/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/visu-src/src/assets/vite.svg b/visu-src/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/visu-src/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/visu-src/src/components/SankeyEditor.jsx b/visu-src/src/components/SankeyEditor.jsx new file mode 100644 index 0000000..62f0d22 --- /dev/null +++ b/visu-src/src/components/SankeyEditor.jsx @@ -0,0 +1,547 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../lib/visuApi' +import { sourceKeyFromRef } from '../lib/visuUtils' + +function nextId(prefix) { + return `${prefix}-${Date.now()}-${Math.round(Math.random() * 100000)}` +} + +function influxPointKey(point) { + return ['influx', point.database || '', point.measurement || '', point.tag_key || '', point.tag_value || ''].join('|') +} + +function influxMeasurement(point) { + return String(point?.measurement || '').trim() +} + +function clonePointRef(point) { + if (!point) return null + + return { + type: point.type || '', + variable_type: point.type || point.variable_type || '', + external_id: point.external_id || '', + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || point.point_label || '', + label: point.label || point.point_label || '', + source_key: point.source_key || sourceKeyFromRef(point), + } +} + +function nodePaletteColor(index) { + return ['#38bdf8', '#0ea5e9', '#22c55e', '#f59e0b', '#f97316', '#a78bfa', '#ef4444', '#14b8a6'][index % 8] +} + +function ProjectPointSelect({ projectId, value, onChange, disabled = false }) { + const [values, setValues] = useState([]) + const [q, setQ] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!projectId) { + setValues([]) + return + } + + let alive = true + setLoading(true) + + api('project_values', { project_id: projectId }) + .then((data) => { + if (alive) setValues(data.values || []) + }) + .catch(() => { + if (alive) setValues([]) + }) + .finally(() => { + if (alive) setLoading(false) + }) + + return () => { + alive = false + } + }, [projectId]) + + const filtered = useMemo(() => values.filter((item) => { + const hay = [ + item.type, + item.label, + item.point_key, + item.variable_id, + item.value_formatted, + item.group, + ].filter(Boolean).join(' ').toLowerCase() + + return hay.includes(q.trim().toLowerCase()) + }), [values, q]) + + function handleSelect(sourceKey) { + const point = values.find((item) => item.source_key === sourceKey) + onChange(point ? clonePointRef(point) : null) + } + + return ( +
+ + setQ(e.target.value)} + placeholder={loading ? 'Laedt...' : 'Datenpunkt suchen'} + /> + +
+ ) +} + +function InfluxPointSelect({ value, onChange, disabled = false }) { + const [databases, setDatabases] = useState([]) + const [database, setDatabase] = useState(value?.database || '') + const [points, setPoints] = useState([]) + const [q, setQ] = useState('') + const [measurementFilter, setMeasurementFilter] = useState(value?.measurement || '') + const [loading, setLoading] = useState(false) + + useEffect(() => { + let alive = true + setLoading(true) + + api('trend_sources', { database }) + .then((data) => { + if (!alive) return + setDatabases(data.databases || []) + setDatabase((current) => current || data.selected_database || data.databases?.[0] || '') + setPoints(data.points || []) + }) + .catch(() => { + if (alive) { + setDatabases([]) + setPoints([]) + } + }) + .finally(() => { + if (alive) setLoading(false) + }) + + return () => { + alive = false + } + }, [database]) + + const measurements = useMemo(() => ( + Array.from(new Set(points.map(influxMeasurement).filter(Boolean))).sort((a, b) => ( + a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' }) + )) + ), [points]) + + const filtered = useMemo(() => points.filter((point) => { + const hay = `${point.measurement} ${point.tag_key} ${point.tag_value} ${point.label}`.toLowerCase() + const matchesSearch = !q.trim() || hay.includes(q.trim().toLowerCase()) + const matchesMeasurement = !measurementFilter || influxMeasurement(point) === measurementFilter + return matchesSearch && matchesMeasurement + }), [points, q, measurementFilter]) + + function handleSelect(key) { + const point = points.find((item) => influxPointKey(item) === key) + onChange(point ? { + database: point.database, + measurement: point.measurement, + tag_key: point.tag_key, + tag_value: point.tag_value, + label: point.label || point.tag_value, + } : null) + } + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+ + setQ(e.target.value)} + placeholder={loading ? 'Laedt...' : 'Measurement / Tag suchen'} + /> + +
+
+ ) +} + +export default function SankeyEditor({ selected, page, onChange, disabled = false }) { + const style = selected?.style_json || {} + + if (!selected || selected.element_type !== 'value' || style.kind !== 'sankey_chart') return null + + const nodes = Array.isArray(style.nodes) ? style.nodes : [] + const links = Array.isArray(style.links) ? style.links : [] + + function updateStyle(patch) { + onChange({ + ...selected, + style_json: { + ...style, + ...patch, + }, + }) + } + + function updateNode(index, patch) { + const nextNodes = [...nodes] + nextNodes[index] = { ...nextNodes[index], ...patch } + updateStyle({ nodes: nextNodes }) + } + + function addNode() { + const maxColumn = nodes.reduce((max, node) => Math.max(max, Number(node.column ?? 0)), 0) + + updateStyle({ + nodes: [ + ...nodes, + { + id: nextId('sankey-node'), + label: `Knoten ${nodes.length + 1}`, + column: nodes.length ? maxColumn + 1 : 0, + order: nodes.length, + color: nodePaletteColor(nodes.length), + }, + ], + }) + } + + function removeNode(index) { + const nextNodes = nodes.filter((_, nodeIndex) => nodeIndex !== index) + const removedId = nodes[index]?.id + + updateStyle({ + nodes: nextNodes, + links: links.filter((link) => link.sourceId !== removedId && link.targetId !== removedId), + }) + } + + function updateLink(index, patch) { + const nextLinks = [...links] + nextLinks[index] = { ...nextLinks[index], ...patch } + updateStyle({ links: nextLinks }) + } + + function addLink() { + updateStyle({ + links: [ + ...links, + { + id: nextId('sankey-link'), + sourceId: nodes[0]?.id || '', + targetId: nodes[1]?.id || nodes[0]?.id || '', + label: `Section ${links.length + 1}`, + sourceMode: 'static', + staticValue: '10', + point: null, + influx: null, + color: '#38bdf8', + opacity: Number(style.linkOpacity ?? 0.46), + thicknessFactor: 1, + }, + ], + }) + } + + function removeLink(index) { + updateStyle({ links: links.filter((_, linkIndex) => linkIndex !== index) }) + } + + const influxCount = links.filter((link) => link.sourceMode === 'influx').length + const pointCount = links.filter((link) => link.sourceMode === 'point').length + + return ( +
+
+
+ Knoten + {nodes.length} +
+
+ Sections + {links.length} +
+
+ API / VDP + {pointCount} +
+
+ Influx + {influxCount} +
+
+ +
+
+
Daten
+
+
+ + updateStyle({ refreshSeconds: Math.max(0, Number(e.target.value || 0)) })} /> +
+
+ + updateStyle({ decimals: Math.max(0, Math.min(8, Number(e.target.value || 0))) })} /> +
+
+ + updateStyle({ unit: e.target.value })} placeholder="z.B. kW" /> +
+
+ + updateStyle({ radius: Number(e.target.value || 14) })} /> +
+
+
+ +
+
Look and Feel
+
+
+ + updateStyle({ backgroundColor: e.target.value, backgroundColorHex: e.target.value })} /> +
+
+ + updateStyle({ borderColor: e.target.value, borderColorHex: e.target.value })} /> +
+
+ + updateStyle({ labelColor: e.target.value })} /> +
+
+ + updateStyle({ curve: Number(e.target.value) })} /> +
+
+ +
+
+ + updateStyle({ linkOpacity: Number(e.target.value) })} /> +
+
+ + updateStyle({ nodeOpacity: Number(e.target.value) })} /> +
+
+ + + + +
+ +
+
Geometrie
+
+
+ + updateStyle({ nodeWidth: Number(e.target.value || 26) })} /> +
+
+ + updateStyle({ nodeRadius: Number(e.target.value || 10) })} /> +
+
+ + updateStyle({ nodeGap: Number(e.target.value || 14) })} /> +
+
+ + updateStyle({ minLinkWidth: Number(e.target.value || 4) })} /> +
+
+ + updateStyle({ minNodeHeight: Number(e.target.value || 24) })} /> +
+
+
+
+ +
+
+
+
Knoten
+
Spalten und Reihenfolge bestimmen das Grundlayout.
+
+ +
+ + {nodes.map((node, index) => ( +
+
+
+ + updateNode(index, { label: e.target.value })} /> +
+
+ + updateNode(index, { color: e.target.value })} /> +
+
+ + updateNode(index, { column: Number(e.target.value || 0) })} /> +
+
+ + updateNode(index, { order: Number(e.target.value || 0) })} /> +
+
+ +
+ {node.id} + +
+
+ ))} + + {nodes.length === 0 &&
Noch keine Knoten vorhanden.
} +
+ +
+
+
+
Sections / Fluesse
+
Jede Section verbindet genau zwei Knoten und kann statisch, per API/VDP oder per Influx gespeist werden.
+
+ +
+ + {links.map((link, index) => ( +
+
+
+ + updateLink(index, { label: e.target.value })} placeholder="z.B. Waermemenge" /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + updateLink(index, { color: e.target.value })} /> +
+
+ + updateLink(index, { thicknessFactor: Math.max(0.25, Number(e.target.value || 1)) })} /> +
+
+ + updateLink(index, { opacity: Number(e.target.value) })} /> +
+
+ + {link.sourceMode === 'static' && ( +
+ + updateLink(index, { staticValue: e.target.value })} placeholder="z.B. 125" /> +
+ )} + + {link.sourceMode === 'point' && ( + updateLink(index, { point })} + /> + )} + + {link.sourceMode === 'influx' && ( + updateLink(index, { influx })} + /> + )} + +
+ + {link.sourceMode === 'point' + ? (link.point?.point_label || link.point?.label || 'API / VDP') + : link.sourceMode === 'influx' + ? (link.influx?.label || link.influx?.tag_value || 'Influx') + : 'Statisch'} + + +
+
+ ))} + + {links.length === 0 &&
Noch keine Fluesse vorhanden.
} +
+
+ ) +} diff --git a/visu-src/src/components/SankeyGraphic.jsx b/visu-src/src/components/SankeyGraphic.jsx new file mode 100644 index 0000000..895494e --- /dev/null +++ b/visu-src/src/components/SankeyGraphic.jsx @@ -0,0 +1,354 @@ +import { useEffect, useMemo, useState } from 'react' +import { apiPost } from '../lib/visuApi' +import { livePointFromRef, resolveTemplateRef } from '../lib/visuUtils' + +function numericValue(value) { + if (value === null || value === undefined || value === '') return null + const match = String(value).replace(',', '.').match(/-?\d+(?:\.\d+)?/) + return match ? Number(match[0]) : null +} + +function formatValue(value, decimals = 1, unit = '') { + if (!Number.isFinite(value)) return '--' + const formatted = value.toLocaleString('de-DE', { + minimumFractionDigits: Math.max(0, Math.min(8, decimals)), + maximumFractionDigits: Math.max(0, Math.min(8, decimals)), + }) + return unit ? `${formatted} ${unit}` : formatted +} + +function linkInfluxSignature(link) { + return JSON.stringify({ + database: link?.influx?.database || '', + measurement: link?.influx?.measurement || '', + tag_key: link?.influx?.tag_key || '', + tag_value: link?.influx?.tag_value || '', + }) +} + +function validInfluxLink(link) { + return Boolean( + link?.sourceMode === 'influx' + && link?.influx?.database + && link?.influx?.measurement + && link?.influx?.tag_key + && link?.influx?.tag_value, + ) +} + +function resolveLinkNumericValue(link, style, liveValues, influxValues) { + if (link?.sourceMode === 'static') { + return numericValue(link.staticValue) + } + + if (link?.sourceMode === 'point') { + const ref = resolveTemplateRef(link.point || {}, style) + const point = livePointFromRef(ref, liveValues) + return numericValue(point?.value ?? point?.value_formatted) + } + + if (link?.sourceMode === 'influx') { + return numericValue(influxValues[link.id]) + } + + return null +} + +export default function SankeyGraphic({ element, liveValues = {} }) { + const style = element?.style_json || {} + const nodes = Array.isArray(style.nodes) ? style.nodes : [] + const links = Array.isArray(style.links) ? style.links : [] + const refreshSeconds = Math.max(0, Number(style.refreshSeconds ?? 30)) + const [influxValues, setInfluxValues] = useState({}) + + const influxLinks = useMemo( + () => links.filter(validInfluxLink), + [links], + ) + const influxSignature = JSON.stringify(influxLinks.map((link) => ({ + id: link.id, + signature: linkInfluxSignature(link), + }))) + + useEffect(() => { + let alive = true + let timer = null + + async function loadInfluxValues() { + if (!influxLinks.length) { + setInfluxValues({}) + return + } + + try { + const res = await apiPost('influx_latest_values', { + series_json: JSON.stringify(influxLinks.map((link) => ({ + id: link.id, + database: link.influx.database, + measurement: link.influx.measurement, + tag_key: link.influx.tag_key, + tag_value: link.influx.tag_value, + }))), + }) + + if (!alive) return + + const nextValues = {} + ;(res.series || []).forEach((serie) => { + nextValues[serie.id] = serie.value + }) + setInfluxValues(nextValues) + } catch { + if (alive) setInfluxValues({}) + } + } + + loadInfluxValues() + + if (refreshSeconds > 0 && influxLinks.length) { + timer = window.setInterval(loadInfluxValues, refreshSeconds * 1000) + } + + return () => { + alive = false + if (timer) window.clearInterval(timer) + } + }, [refreshSeconds, influxSignature]) + + const width = Math.max(260, Number(element?.w || 560)) + const height = Math.max(180, Number(element?.h || 320)) + + const layout = useMemo(() => { + const nodeMap = new Map( + nodes.map((node, index) => [ + node.id || `node-${index}`, + { + ...node, + id: node.id || `node-${index}`, + label: node.label || `Knoten ${index + 1}`, + column: Number(node.column ?? 0), + order: Number(node.order ?? index), + color: node.color || '#38bdf8', + incoming: [], + outgoing: [], + }, + ]), + ) + + const resolvedLinks = links + .map((link, index) => { + const sourceId = link.sourceId || '' + const targetId = link.targetId || '' + const source = nodeMap.get(sourceId) + const target = nodeMap.get(targetId) + const value = resolveLinkNumericValue(link, style, liveValues, influxValues) + + if (!source || !target || !Number.isFinite(value) || value <= 0) return null + + return { + ...link, + id: link.id || `link-${index}`, + sourceId, + targetId, + label: link.label || '', + color: link.color || source.color || '#38bdf8', + opacity: Math.max(0.08, Math.min(1, Number(link.opacity ?? style.linkOpacity ?? 0.46))), + thicknessFactor: Math.max(0.25, Number(link.thicknessFactor ?? 1)), + value, + source, + target, + } + }) + .filter(Boolean) + + resolvedLinks.forEach((link) => { + link.source.outgoing.push(link) + link.target.incoming.push(link) + }) + + const preparedNodes = Array.from(nodeMap.values()) + + preparedNodes.forEach((node) => { + const inTotal = node.incoming.reduce((sum, link) => sum + link.value, 0) + const outTotal = node.outgoing.reduce((sum, link) => sum + link.value, 0) + node.total = Math.max(inTotal, outTotal, 0.0001) + }) + + const grouped = new Map() + preparedNodes.forEach((node) => { + const key = Number.isFinite(node.column) ? node.column : 0 + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key).push(node) + }) + + const columns = Array.from(grouped.keys()).sort((a, b) => a - b) + const top = 18 + const bottom = 18 + const left = 16 + const right = 16 + const nodeWidth = Math.max(18, Number(style.nodeWidth ?? 26)) + const nodeRadius = Math.max(0, Number(style.nodeRadius ?? 10)) + const gapY = Math.max(6, Number(style.nodeGap ?? 14)) + const innerH = Math.max(40, height - top - bottom) + + let scale = Number(style.scaleFactor ?? 1) + + if (!(scale > 0)) { + const candidates = columns + .map((column) => { + const columnNodes = (grouped.get(column) || []).sort((a, b) => a.order - b.order) + const total = columnNodes.reduce((sum, node) => sum + node.total, 0) + const gaps = Math.max(0, columnNodes.length - 1) * gapY + return total > 0 ? (innerH - gaps) / total : 0 + }) + .filter((value) => value > 0) + + scale = candidates.length ? Math.min(...candidates) : 18 + } + + const minNodeHeight = Math.max(20, Number(style.minNodeHeight ?? 24)) + const xSpan = Math.max(1, columns.length - 1) + const availableW = Math.max(40, width - left - right - nodeWidth) + + columns.forEach((column) => { + const columnNodes = (grouped.get(column) || []).sort((a, b) => a.order - b.order) + const rawHeights = columnNodes.map((node) => Math.max(node.total * scale, minNodeHeight)) + const totalHeight = rawHeights.reduce((sum, item) => sum + item, 0) + const freeSpace = Math.max(0, innerH - totalHeight - Math.max(0, columnNodes.length - 1) * gapY) + let cursorY = top + freeSpace / 2 + + columnNodes.forEach((node, index) => { + const heightValue = rawHeights[index] + const flowHeight = Math.max(1, node.total * scale) + node.w = nodeWidth + node.h = heightValue + node.flowHeight = Math.min(flowHeight, heightValue) + node.flowOffset = (heightValue - node.flowHeight) / 2 + node.x = left + (columns.length === 1 ? availableW / 2 : ((column - columns[0]) / xSpan) * availableW) + node.y = cursorY + node.radius = nodeRadius + node.outCursor = 0 + node.inCursor = 0 + cursorY += heightValue + gapY + }) + }) + + const minLinkWidth = Math.max(1, Number(style.minLinkWidth ?? 4)) + const curve = Math.max(0.15, Math.min(0.6, Number(style.curve ?? 0.42))) + + const preparedLinks = resolvedLinks.map((link) => { + const sourceWidth = Math.max(minLinkWidth, link.value * scale * link.thicknessFactor) + const targetWidth = Math.max(minLinkWidth, link.value * scale * link.thicknessFactor) + const sourceY = link.source.y + link.source.flowOffset + link.source.outCursor + const targetY = link.target.y + link.target.flowOffset + link.target.inCursor + + link.source.outCursor += sourceWidth + link.target.inCursor += targetWidth + + const x0 = link.source.x + nodeWidth + const x1 = link.target.x + + if (![x0, x1, sourceY, targetY, sourceWidth, targetWidth].every(Number.isFinite)) { + return null + } + + const cx = Math.max(20, (x1 - x0) * curve) + const y0Top = sourceY + const y0Bottom = sourceY + sourceWidth + const y1Top = targetY + const y1Bottom = targetY + targetWidth + const path = [ + `M ${x0.toFixed(2)} ${y0Top.toFixed(2)}`, + `C ${(x0 + cx).toFixed(2)} ${y0Top.toFixed(2)} ${(x1 - cx).toFixed(2)} ${y1Top.toFixed(2)} ${x1.toFixed(2)} ${y1Top.toFixed(2)}`, + `L ${x1.toFixed(2)} ${y1Bottom.toFixed(2)}`, + `C ${(x1 - cx).toFixed(2)} ${y1Bottom.toFixed(2)} ${(x0 + cx).toFixed(2)} ${y0Bottom.toFixed(2)} ${x0.toFixed(2)} ${y0Bottom.toFixed(2)}`, + 'Z', + ].join(' ') + + return { + ...link, + width: sourceWidth, + path, + labelX: x0 + (x1 - x0) / 2, + labelY: (y0Top + y0Bottom + y1Top + y1Bottom) / 4, + } + }).filter(Boolean) + + return { + nodes: preparedNodes, + links: preparedLinks, + } + }, [nodes, links, style, width, height, liveValues, influxValues]) + + const background = style.backgroundColor || 'rgba(15, 23, 42, 0.42)' + const border = style.borderColor || 'rgba(148, 163, 184, 0.38)' + const labelColor = style.labelColor || '#e2e8f0' + const nodeOpacity = Math.max(0.2, Math.min(1, Number(style.nodeOpacity ?? 0.9))) + const showValues = style.showValues !== false + const showLegend = style.showLegend !== false + + return ( +
+ + + + {layout.links.map((link) => ( + + + {showValues && link.label && ( + + {link.label} + + )} + + ))} + + {layout.nodes.map((node) => ( + + + + {node.label} + + + ))} + + +
+ {element.label || element.name || 'Sankey'} + {layout.links.length} Flüsse · {layout.nodes.length} Knoten +
+ + {showLegend && showValues && layout.links.length > 0 && ( +
+ {layout.links.slice(0, 6).map((link) => ( + + + {link.label || `${link.source.label} → ${link.target.label}`}: {formatValue(link.value, Number(style.decimals ?? 1), style.unit || '')} + + ))} +
+ )} + + {layout.links.length === 0 &&
Sankey-Flüsse konfigurieren
} +
+ ) +} diff --git a/visu-src/src/components/ShapeGraphic.jsx b/visu-src/src/components/ShapeGraphic.jsx new file mode 100644 index 0000000..a5dcf86 --- /dev/null +++ b/visu-src/src/components/ShapeGraphic.jsx @@ -0,0 +1,284 @@ +import { compareRule, livePointFromRef, resolveTemplateRef, valueText } from '../lib/visuUtils' + +const GRADIENT_ORIGINS = { + top_left: { x: 0, y: 0 }, + top_center: { x: 50, y: 0 }, + top_right: { x: 100, y: 0 }, + middle_left: { x: 0, y: 50 }, + center: { x: 50, y: 50 }, + middle_right: { x: 100, y: 50 }, + bottom_left: { x: 0, y: 100 }, + bottom_center: { x: 50, y: 100 }, + bottom_right: { x: 100, y: 100 }, +} + +function pointFromRef(ref, liveValues) { + return livePointFromRef(ref, liveValues) +} + +function resolveShapePaint(style, liveValues) { + const base = { + fillColor: style.fillColor || 'transparent', + gradientColor: style.gradientColor || style.fillColor || '#000000', + } + + const rules = Array.isArray(style.colorRules) + ? [...style.colorRules].sort((a, b) => Number(a.priority || 100) - Number(b.priority || 100)) + : [] + + for (const rule of rules) { + const resolvedRule = resolveTemplateRef(rule, style) + const point = pointFromRef(resolvedRule, liveValues) + + if (point && compareRule(resolvedRule, point)) { + return { + fillColor: resolvedRule.fillColor || base.fillColor, + gradientColor: resolvedRule.gradientColor || base.gradientColor, + } + } + } + + return base +} + +function resolveGradientOrigin(style) { + return GRADIENT_ORIGINS[style.gradientOrigin] || { + x: Number(style.gradientOriginX ?? 50), + y: Number(style.gradientOriginY ?? 50), + } +} + +function resolveFlow(style, liveValues) { + const point = pointFromRef(resolveTemplateRef(style.flowPoint || {}, style), liveValues) + const raw = Number(valueText(point).replace(',', '.')) + const min = Number(style.flowMin ?? 0) + const max = Number(style.flowMax ?? 100) + const spread = max - min + let ratio = Number.isFinite(raw) && spread !== 0 ? (raw - min) / spread : 0.5 + ratio = Math.max(0, Math.min(1, ratio)) + + if (style.flowInvertSpeed) ratio = 1 - ratio + + const slowSeconds = Math.max(0.2, Number(style.flowSlowSeconds ?? 5)) + const fastSeconds = Math.max(0.1, Number(style.flowFastSeconds ?? 0.7)) + + return { + duration: slowSeconds - ratio * (slowSeconds - fastSeconds), + reverse: style.flowDirection === 'reverse', + } +} + +function arrowPath(type) { + switch (type) { + case 'diamond': + return 'M 0 0 L -5 -5 L -10 0 L -5 5 z' + case 'chevron': + return 'M 0 0 L -7 -5 L -7 -2.5 L -10 -2.5 L -3 0 L -10 2.5 L -7 2.5 L -7 5 z' + case 'kite': + return 'M 0 0 L -6 -5 L -10 0 L -6 5 z' + case 'triangle': + default: + return 'M 0 0 L -10 -5 L -10 5 z' + } +} + +function normalizedPoint(point) { + return { + x: Number(point?.x ?? 0), + y: Number(point?.y ?? 0), + } +} + +function segmentAngle(from, to) { + return Math.atan2(to.y - from.y, to.x - from.x) * 180 / Math.PI +} + +function renderLineArrow({ id, type, size, angle, gap, point, color }) { + const scale = Math.max(0.6, Number(size || 20) / 10) + const rad = angle * Math.PI / 180 + const tipX = point.x + Math.cos(rad) * Number(gap || 0) + const tipY = point.y + Math.sin(rad) * Number(gap || 0) + + return ( + + ) +} + +export default function ShapeGraphic({ + element, + liveValues = {}, + pointEditMode = false, + onPointPointerDown, +}) { + const style = element.style_json || {} + const strokeColor = style.strokeColor || style.color || '#60a5fa' + const strokeWidth = Number(style.strokeWidth || style.thickness || 4) + const arrowSize = Math.max(8, Number(style.arrowSize ?? 20)) + const legacyArrowGap = Math.max(-120, Math.min(120, Number(style.arrowGap ?? 0))) + const startArrowGap = Math.max(-120, Math.min(120, Number(style.startArrowGap ?? legacyArrowGap))) + const endArrowGap = Math.max(-120, Math.min(120, Number(style.endArrowGap ?? legacyArrowGap))) + const subtype = style.subtype || 'rect' + const paint = resolveShapePaint(style, liveValues) + const fillMode = style.fillMode || 'solid' + const idBase = String(element.id).replace(/[^a-zA-Z0-9_-]/g, '') + const fillId = `shape-fill-${idBase}` + const fill = fillMode === 'solid' ? paint.fillColor : `url(#${fillId})` + const points = Array.isArray(style.points) ? style.points.map(normalizedPoint) : [] + const pointString = points.map((p) => `${p.x},${p.y}`).join(' ') + const origin = resolveGradientOrigin(style) + const centerOrigin = origin.x === 50 && origin.y === 50 + const flow = resolveFlow(style, liveValues) + const showFlow = subtype === 'polyline' && Boolean(style.flowEnabled) + const startSegment = points.length > 1 ? { from: points[0], to: points[1] } : null + const endSegment = points.length > 1 ? { from: points[points.length - 2], to: points[points.length - 1] } : null + const startBaseAngle = startSegment ? segmentAngle(startSegment.from, startSegment.to) : 0 + const endBaseAngle = endSegment ? segmentAngle(endSegment.from, endSegment.to) : 0 + const startArrowRotation = Number(style.startArrowRotation ?? 0) + const endArrowRotation = Number(style.endArrowRotation ?? 0) + const startArrowAngle = style.startArrow === 'outward' + ? startBaseAngle + 180 + startArrowRotation + : startBaseAngle + startArrowRotation + const endArrowAngle = style.endArrow === 'outward' + ? endBaseAngle + endArrowRotation + : endBaseAngle + 180 + endArrowRotation + + return ( + + + {fillMode === 'radial' && ( + + + + + )} + + {fillMode === 'linear' && ( + + {centerOrigin ? ( + <> + + + + + ) : ( + <> + + + + )} + + )} + + + + {subtype === 'polyline' && ( + <> + {style.fillClosed && points.length > 2 && ( + + )} + + {showFlow && ( + + )} + {style.startArrow !== 'none' && startSegment && renderLineArrow({ + id: `start-${idBase}`, + type: style.startArrowType || 'triangle', + size: arrowSize, + angle: startArrowAngle, + gap: startArrowGap, + point: points[0], + color: strokeColor, + })} + {style.endArrow !== 'none' && endSegment && renderLineArrow({ + id: `end-${idBase}`, + type: style.endArrowType || 'triangle', + size: arrowSize, + angle: endArrowAngle, + gap: endArrowGap, + point: points[points.length - 1], + color: strokeColor, + })} + + )} + + {subtype === 'polygon' && ( + + )} + + {(subtype === 'ellipse' || subtype === 'circle') && ( + + )} + + {subtype === 'rect' && ( + + )} + + {pointEditMode && points.map((p, idx) => ( + onPointPointerDown(e, idx)} + /> + ))} + + ) +} diff --git a/visu-src/src/components/TextGraphic.jsx b/visu-src/src/components/TextGraphic.jsx new file mode 100644 index 0000000..28de5eb --- /dev/null +++ b/visu-src/src/components/TextGraphic.jsx @@ -0,0 +1,185 @@ +import { compareRule, FONT_OPTIONS, livePointFromRef, resolveTemplateRef } from '../lib/visuUtils' + +const GRADIENT_ORIGINS = { + top_left: { x: 0, y: 0 }, + top_center: { x: 50, y: 0 }, + top_right: { x: 100, y: 0 }, + middle_left: { x: 0, y: 50 }, + center: { x: 50, y: 50 }, + middle_right: { x: 100, y: 50 }, + bottom_left: { x: 0, y: 100 }, + bottom_center: { x: 50, y: 100 }, + bottom_right: { x: 100, y: 100 }, +} + +function resolveGradientOrigin(style) { + return GRADIENT_ORIGINS[style.gradientOrigin] || { + x: Number(style.gradientOriginX ?? 50), + y: Number(style.gradientOriginY ?? 50), + } +} + +function resolveText(element, liveValues) { + const style = element.style_json || {} + const subtype = style.subtype || 'text' + if (subtype !== 'text') return element.label || 'Text' + const rules = Array.isArray(style.textRules) + ? [...style.textRules].sort((a, b) => Number(a.priority || 100) - Number(b.priority || 100)) + : [] + + for (const rule of rules) { + const resolvedRule = resolveTemplateRef(rule, style) + const point = livePointFromRef(resolvedRule, liveValues) + if (point && compareRule(resolvedRule, point)) return resolvedRule.text || '' + } + + return element.label || 'Text' +} + +function resolveLinkHref(linkLike) { + if (!linkLike) return '' + if (linkLike.linkMode === 'page' && linkLike.linkPageId) return `/visu/?mode=viewer&page_id=${linkLike.linkPageId}` + if (linkLike.linkMode === 'external' && linkLike.linkUrl) return linkLike.linkUrl + return '' +} + +function menuItemsFromStyle(style) { + if (!Array.isArray(style.menuItems) || style.menuItems.length === 0) return [] + return style.menuItems.map((item, index) => ({ + id: item.id || `menu-${index + 1}`, + label: item.label || `Link ${index + 1}`, + linkMode: item.linkMode || 'page', + linkPageId: item.linkPageId || '', + linkUrl: item.linkUrl || '', + linkTarget: item.linkTarget || style.linkTarget || 'same_tab', + })) +} + +function buttonRadiusFromStyle(style) { + if (style.buttonShape === 'rect') return 0 + return Math.max(0, Number(style.buttonRadius ?? 16)) +} + +function buttonVisualStyle(style) { + return { + borderRadius: `${buttonRadiusFromStyle(style)}px`, + background: style.buttonFill || 'rgba(37, 99, 235, 0.78)', + border: `1px solid ${style.buttonBorderColor || 'rgba(147, 197, 253, 0.72)'}`, + boxShadow: '0 10px 24px rgba(15, 23, 42, 0.24)', + textDecoration: 'none', + } +} + +function textBoxStyle(style) { + const strokeWidth = Number(style.strokeWidth ?? 0) + const fillColor = style.fillColor || 'transparent' + const gradientColor = style.gradientColor || '#1d4ed8' + const fillMode = style.fillMode || 'solid' + const origin = resolveGradientOrigin(style) + const background = fillMode === 'linear' + ? (origin.x === 50 && origin.y === 50 + ? `linear-gradient(${Number(style.gradientAngle || 0)}deg, ${gradientColor} 0%, ${fillColor} 50%, ${gradientColor} 100%)` + : `linear-gradient(${Number(style.gradientAngle || 0)}deg, ${fillColor}, ${gradientColor})`) + : fillMode === 'radial' + ? `radial-gradient(circle at ${origin.x}% ${origin.y}%, ${fillColor}, ${gradientColor})` + : fillColor + + return { + border: `${Math.max(0, strokeWidth)}px solid ${style.strokeColor || style.color || '#60a5fa'}`, + background, + borderRadius: `${Number(style.radius || 0)}px`, + } +} + +function TextLink({ href, targetMode, interactive, children, className, style }) { + const target = targetMode === 'new_tab' ? '_blank' : undefined + const rel = target ? 'noreferrer noopener' : undefined + + if (!interactive || !href) { + return {children} + } + + return ( + + {children} + + ) +} + +export default function TextGraphic({ element, liveValues = {}, interactive = false }) { + const style = element.style_json || {} + const subtype = style.subtype || 'text' + const text = resolveText(element, liveValues) + const renderAsButton = Boolean(style.renderAsButton) && (subtype === 'link' || subtype === 'menu') + const menuOrientation = style.menuOrientation === 'horizontal' ? 'horizontal' : 'vertical' + const menuButtonSize = Math.max(22, Number(style.menuButtonSize ?? 42)) + const commonStyle = { + color: style.color || '#ffffff', + fontSize: style.fontSize || 20, + fontFamily: style.fontFamily || FONT_OPTIONS[0].value, + ...textBoxStyle(style), + } + + if (subtype === 'menu') { + const items = menuItemsFromStyle(style) + + return ( +
+ {items.map((item) => ( + + {item.label} + + ))} +
+ ) + } + + if (subtype === 'link') { + return ( +
+ + {text} + +
+ ) + } + + return ( +
+ {text} +
+ ) +} diff --git a/visu-src/src/components/TrendChartGraphic.jsx b/visu-src/src/components/TrendChartGraphic.jsx new file mode 100644 index 0000000..3eb6069 --- /dev/null +++ b/visu-src/src/components/TrendChartGraphic.jsx @@ -0,0 +1,816 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { apiPost } from '../lib/visuApi' + +const RANGE_OPTIONS = [ + { value: '15m', label: '15 Min' }, + { value: '1h', label: '1 h' }, + { value: '6h', label: '6 h' }, + { value: '12h', label: '12 h' }, + { value: '24h', label: '24 h' }, + { value: '7d', label: '7 Tage' }, + { value: '30d', label: '30 Tage' }, + { value: '90d', label: '90 Tage' }, + { value: '180d', label: '6 Monate' }, +] + +function numericValue(value) { + if (value === null || value === undefined || value === '') return null + const n = Number(String(value).replace(',', '.')) + return Number.isFinite(n) ? n : null +} + +function dateTick(value) { + if (!value) return '' + const d = new Date(value) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleString('de-DE', { hour: '2-digit', minute: '2-digit' }) +} + +function formatDateTime(value) { + if (!value) return '' + const d = new Date(value) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function formatNumber(value) { + if (!Number.isFinite(value)) return '' + return value.toLocaleString('de-DE', { maximumFractionDigits: 2 }) +} + +function niceBounds(values, fallbackMin = 0, fallbackMax = 100) { + const nums = values.filter((value) => Number.isFinite(value)) + if (!nums.length) return { min: fallbackMin, max: fallbackMax } + + let min = Math.min(...nums) + let max = Math.max(...nums) + + if (min === max) { + min -= 1 + max += 1 + } + + const pad = (max - min) * 0.08 + return { min: min - pad, max: max + pad } +} + +function finitePoints(points) { + return points.filter((point) => Number.isFinite(point.y)) +} + +function pointSegments(points) { + const segments = [] + let current = [] + + points.forEach((point) => { + if (!Number.isFinite(point.y)) { + if (current.length) segments.push(current) + current = [] + return + } + + current.push(point) + }) + + if (current.length) segments.push(current) + return segments +} + +function buildLinearPath(points, mapX, mapY) { + const commands = [] + let open = false + + points.forEach((point) => { + if (!Number.isFinite(point.y)) { + open = false + return + } + + commands.push(`${open ? 'L' : 'M'} ${mapX(point.x).toFixed(2)} ${mapY(point.y).toFixed(2)}`) + open = true + }) + + return commands.join(' ') +} + +function buildSmoothPath(points, mapX, mapY) { + const pts = finitePoints(points) + if (pts.length === 0) return '' + if (pts.length === 1) return `M ${mapX(pts[0].x).toFixed(2)} ${mapY(pts[0].y).toFixed(2)}` + + const scaled = pts.map((point) => ({ + x: mapX(point.x), + y: mapY(point.y), + })) + const segmentSlopes = [] + const tangents = new Array(scaled.length).fill(0) + + for (let index = 0; index < scaled.length - 1; index += 1) { + const dx = scaled[index + 1].x - scaled[index].x + segmentSlopes[index] = dx <= 0 ? 0 : (scaled[index + 1].y - scaled[index].y) / dx + } + + tangents[0] = segmentSlopes[0] ?? 0 + tangents[scaled.length - 1] = segmentSlopes[scaled.length - 2] ?? 0 + + for (let index = 1; index < scaled.length - 1; index += 1) { + const prevSlope = segmentSlopes[index - 1] + const nextSlope = segmentSlopes[index] + + if ( + !Number.isFinite(prevSlope) + || !Number.isFinite(nextSlope) + || prevSlope === 0 + || nextSlope === 0 + || prevSlope * nextSlope < 0 + ) { + tangents[index] = 0 + continue + } + + const prevDx = scaled[index].x - scaled[index - 1].x + const nextDx = scaled[index + 1].x - scaled[index].x + tangents[index] = (prevDx + nextDx) / ((prevDx / prevSlope) + (nextDx / nextSlope)) + } + + for (let index = 0; index < segmentSlopes.length; index += 1) { + const slope = segmentSlopes[index] + + if (!Number.isFinite(slope) || slope === 0) { + tangents[index] = 0 + tangents[index + 1] = 0 + continue + } + + const left = tangents[index] / slope + const right = tangents[index + 1] / slope + const length = (left * left) + (right * right) + + if (length > 9) { + const scale = 3 / Math.sqrt(length) + tangents[index] = scale * left * slope + tangents[index + 1] = scale * right * slope + } + } + + const commands = [`M ${scaled[0].x.toFixed(2)} ${scaled[0].y.toFixed(2)}`] + + for (let index = 0; index < scaled.length - 1; index += 1) { + const current = scaled[index] + const next = scaled[index + 1] + const dx = next.x - current.x + + if (dx <= 0) { + commands.push(`L ${next.x.toFixed(2)} ${next.y.toFixed(2)}`) + continue + } + + const cp1x = current.x + dx / 3 + const cp1y = current.y + (tangents[index] * dx) / 3 + const cp2x = next.x - dx / 3 + const cp2y = next.y - (tangents[index + 1] * dx) / 3 + + commands.push(`C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)} ${cp2x.toFixed(2)} ${cp2y.toFixed(2)} ${next.x.toFixed(2)} ${next.y.toFixed(2)}`) + } + + return commands.join(' ') +} + +function buildStepPath(points, mapX, mapY) { + const pts = finitePoints(points) + if (!pts.length) return '' + + const commands = [`M ${mapX(pts[0].x).toFixed(2)} ${mapY(pts[0].y).toFixed(2)}`] + + for (let index = 1; index < pts.length; index += 1) { + const previous = pts[index - 1] + const point = pts[index] + const midX = mapX(point.x) + commands.push(`L ${midX.toFixed(2)} ${mapY(previous.y).toFixed(2)}`) + commands.push(`L ${midX.toFixed(2)} ${mapY(point.y).toFixed(2)}`) + } + + return commands.join(' ') +} + +function buildSeriesPath(points, mapX, mapY, mode = 'linear', connectNulls = true) { + if (connectNulls) { + const source = finitePoints(points) + if (mode === 'smooth') return buildSmoothPath(source, mapX, mapY) + if (mode === 'step') return buildStepPath(source, mapX, mapY) + return buildLinearPath(source, mapX, mapY) + } + + return pointSegments(points).map((segment) => { + if (mode === 'smooth') return buildSmoothPath(segment, mapX, mapY) + if (mode === 'step') return buildStepPath(segment, mapX, mapY) + return buildLinearPath(segment, mapX, mapY) + }).join(' ') +} + +function buildFillPath(points, mapX, mapY, baseY, connectNulls = true) { + const segments = (connectNulls ? [finitePoints(points)] : pointSegments(points)).filter((segment) => segment.length > 0) + + return segments.map((segment) => { + const first = segment[0] + const last = segment[segment.length - 1] + return [ + `M ${mapX(first.x).toFixed(2)} ${baseY.toFixed(2)}`, + ...segment.map((point) => `L ${mapX(point.x).toFixed(2)} ${mapY(point.y).toFixed(2)}`), + `L ${mapX(last.x).toFixed(2)} ${baseY.toFixed(2)}`, + 'Z', + ].join(' ') + }).join(' ') +} + +export default function TrendChartGraphic({ + element, + interactive = false, + tooltipsEnabled = false, + timeRange, + customTimeRange, + controlsMode = 'local', + onTimeRangeChange, + onShowTooltip, + onMoveTooltip, + onHideTooltip, + onExpand, +}) { + const style = element.style_json || {} + const seriesConfig = Array.isArray(style.series) ? style.series : [] + const refreshSeconds = Math.max(0, Number(style.refreshSeconds ?? 60)) + const svgRef = useRef(null) + const dragRef = useRef(null) + const [rangeOverride, setRangeOverride] = useState(style.range || '24h') + const [customRange, setCustomRange] = useState(null) + const [selection, setSelection] = useState(null) + const [data, setData] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [hoverPoint, setHoverPoint] = useState(null) + const signature = JSON.stringify({ + series: seriesConfig, + range: timeRange || rangeOverride || style.range || '24h', + interval: style.interval || 'auto', + aggregate: style.aggregate || 'mean', + customRange: customTimeRange || customRange, + }) + + useEffect(() => { + if (controlsMode === 'global') return + setRangeOverride(style.range || '24h') + setCustomRange(null) + }, [controlsMode, element.id, style.range]) + + useEffect(() => { + if (!tooltipsEnabled) setHoverPoint(null) + }, [tooltipsEnabled]) + + useEffect(() => { + let alive = true + let timer = null + + async function load() { + if (!seriesConfig.length) { + setData(null) + setError('') + return + } + + setLoading(true) + + try { + const payload = { + series_json: JSON.stringify(seriesConfig), + range: timeRange || rangeOverride || style.range || '24h', + interval: style.interval || 'auto', + aggregate: style.aggregate || 'mean', + } + + const activeCustomRange = customTimeRange || customRange + if (activeCustomRange) { + payload.start = String(activeCustomRange.start) + payload.end = String(activeCustomRange.end) + } + + const res = await apiPost('trend_data', payload) + if (!alive) return + setData(res.data || null) + setError('') + } catch (err) { + if (!alive) return + setError(err.message || 'Trenddaten konnten nicht geladen werden.') + } finally { + if (alive) setLoading(false) + } + } + + load() + + if (refreshSeconds > 0) { + timer = window.setInterval(load, refreshSeconds * 1000) + } + + return () => { + alive = false + if (timer) window.clearInterval(timer) + } + }, [signature, refreshSeconds]) + + const chart = useMemo(() => { + const rawSeries = data?.series || [] + const allPoints = [] + const parsed = rawSeries.map((serie, index) => { + const cfg = seriesConfig.find((item) => String(item.id) === String(serie.id)) || seriesConfig[index] || {} + const points = (serie.values || []).map((row) => ({ + x: Date.parse(row.time), + time: row.time, + y: numericValue(row.value), + })).filter((point) => Number.isFinite(point.x)) + + points.forEach((point) => { + if (Number.isFinite(point.y)) allPoints.push(point) + }) + + return { + ...serie, + ...cfg, + color: cfg.color || serie.color || '#38bdf8', + label: cfg.label || serie.label || `Trend ${index + 1}`, + unit: cfg.unit || '', + yAxis: Math.max(1, Math.min(4, Number(cfg.yAxis || 1))), + points, + } + }) + + const xs = allPoints.map((point) => point.x) + const ys = allPoints.map((point) => point.y) + const xMin = xs.length ? Math.min(...xs) : Date.now() - 3600000 + const xMax = xs.length ? Math.max(...xs) : Date.now() + const { min: yMin, max: yMax } = niceBounds(ys, Number(style.min ?? 0), Number(style.max ?? 100)) + const yAxes = {} + + ;[1, 2, 3, 4].forEach((axis) => { + const axisValues = parsed + .filter((serie) => Number(serie.yAxis || 1) === axis) + .flatMap((serie) => serie.points.map((point) => point.y).filter((value) => Number.isFinite(value))) + const bounds = niceBounds(axisValues, yMin, yMax) + const unit = parsed.find((serie) => Number(serie.yAxis || 1) === axis && serie.unit)?.unit || '' + const color = parsed.find((serie) => Number(serie.yAxis || 1) === axis)?.color || '#cbd5e1' + yAxes[axis] = { ...bounds, unit, color } + }) + + return { series: parsed, xMin, xMax: xMax === xMin ? xMin + 1 : xMax, yMin, yMax, yAxes } + }, [data, seriesConfig, style.min, style.max]) + + const width = Math.max(240, Number(element.w || 520)) + const height = Math.max(160, Number(element.h || 300)) + const hasLegend = style.showLegend !== false && chart.series.length > 0 + const activeAxes = [1, 2, 3, 4].filter((axis) => chart.series.some((serie) => Number(serie.yAxis || 1) === axis)) + + const bottomSpace = interactive + ? (hasLegend ? 78 : 58) + : (hasLegend ? 52 : 42) + + const margin = { + left: 48, + right: 22 + Math.max(0, activeAxes.length - 1) * 42, + top: 38, + bottom: bottomSpace, + } + + + const plotW = Math.max(20, width - margin.left - margin.right) + const plotH = Math.max(20, height - margin.top - margin.bottom) + const mapX = (value) => margin.left + ((value - chart.xMin) / (chart.xMax - chart.xMin)) * plotW + const mapY = (value, axis = 1) => { + const bounds = chart.yAxes?.[axis] || { min: chart.yMin, max: chart.yMax } + return margin.top + (1 - ((value - bounds.min) / (bounds.max - bounds.min))) * plotH + } + const baseY = margin.top + plotH + const yTicksForAxis = (axis = 1) => { + const bounds = chart.yAxes?.[axis] || { min: chart.yMin, max: chart.yMax } + return [0, 0.25, 0.5, 0.75, 1].map((ratio) => bounds.min + (bounds.max - bounds.min) * ratio) + } + const xTicks = [chart.xMin, chart.xMin + (chart.xMax - chart.xMin) / 2, chart.xMax] + const background = style.backgroundColor || 'rgba(15, 23, 42, 0.46)' + const border = style.borderColor || 'rgba(148, 163, 184, 0.42)' + const activeCustomRange = customTimeRange || customRange + const currentRange = activeCustomRange ? 'custom' : (timeRange || rangeOverride || style.range || '24h') + const displayMode = style.displayMode || 'line' + const interpolation = style.interpolation || 'linear' + const connectNulls = style.connectNulls !== false + + function viewPointFromEvent(e) { + const rect = svgRef.current?.getBoundingClientRect() + if (!rect) return null + + return { + x: ((e.clientX - rect.left) / Math.max(1, rect.width)) * width, + y: ((e.clientY - rect.top) / Math.max(1, rect.height)) * height, + } + } + + function timeFromViewX(x) { + const clamped = Math.max(margin.left, Math.min(width - margin.right, x)) + const ratio = (clamped - margin.left) / plotW + return chart.xMin + ratio * (chart.xMax - chart.xMin) + } + + function nearestPointAt(e) { + const p = viewPointFromEvent(e) + if (!p) return null + + let best = null + + chart.series.forEach((serie) => { + serie.points.forEach((point) => { + if (!Number.isFinite(point.y)) return + const dx = mapX(point.x) - p.x + const dy = mapY(point.y) - p.y + const distance = Math.sqrt(dx * dx + dy * dy) + + if (!best || distance < best.distance) { + best = { serie, point, distance } + } + }) + }) + + return best && best.distance <= 28 ? best : null + } + + function pointerDown(e) { + if (!interactive || e.button !== 0) return + const p = viewPointFromEvent(e) + if (!p || p.x < margin.left || p.x > width - margin.right || p.y < margin.top || p.y > baseY) return + + e.preventDefault() + e.stopPropagation() + setHoverPoint(null) + dragRef.current = { startX: p.x, currentX: p.x } + setSelection({ startX: p.x, currentX: p.x }) + } + + function pointerMove(e) { + if (dragRef.current) { + const p = viewPointFromEvent(e) + if (!p) return + dragRef.current.currentX = p.x + setSelection({ startX: dragRef.current.startX, currentX: p.x }) + return + } + + if (!interactive || !tooltipsEnabled) return + + const nearest = nearestPointAt(e) + if (!nearest) { + setHoverPoint(null) + onHideTooltip?.() + return + } + + setHoverPoint({ + x: mapX(nearest.point.x), + y: mapY(nearest.point.y, nearest.serie.yAxis || 1), + color: nearest.serie.color || '#38bdf8', + }) + + onShowTooltip?.(e, [{ + key: `${nearest.serie.id || nearest.serie.label}-${nearest.point.time}`, + label: nearest.serie.label || 'Trend', + value: `${formatNumber(nearest.point.y)}${nearest.serie.unit ? ` ${nearest.serie.unit}` : ''}`, + updated: formatDateTime(nearest.point.time), + quality: 'trend', + pointKey: nearest.serie.tag_value || '', + type: nearest.serie.measurement || 'Influx', + group: nearest.serie.tag_key || '', + source: nearest.serie.database || '', + }], 'Trendwert') + onMoveTooltip?.(e) + } + + function pointerUp() { + if (!dragRef.current) return + + const { startX, currentX } = dragRef.current + dragRef.current = null + setSelection(null) + setHoverPoint(null) + + if (Math.abs(currentX - startX) < 12) return + + const start = timeFromViewX(Math.min(startX, currentX)) + const end = timeFromViewX(Math.max(startX, currentX)) + if (controlsMode === 'global') { + onTimeRangeChange?.({ customRange: { start: Math.round(start), end: Math.round(end) } }) + return + } + setCustomRange({ start: Math.round(start), end: Math.round(end) }) + } + + function resetRange(nextRange = style.range || '24h') { + if (controlsMode === 'global') { + onTimeRangeChange?.({ range: nextRange, customRange: null }) + return + } + setRangeOverride(nextRange) + setCustomRange(null) + } + + function resetZoomFromChart(e) { + if (!interactive) return + e.preventDefault() + e.stopPropagation() + resetRange(rangeOverride || style.range || '24h') + } + + return ( +
+ { + dragRef.current = null + setSelection(null) + setHoverPoint(null) + onHideTooltip?.() + }} + > + + + {displayMode !== 'donut' && displayMode !== 'digital_bar' && activeAxes.map((axis) => { + const isLeft = axis === activeAxes[0] + const axisOffset = activeAxes.indexOf(axis) * 42 + const labelX = isLeft ? margin.left - 8 : width - margin.right + 8 + axisOffset + const textAnchor = isLeft ? 'end' : 'start' + const ticks = yTicksForAxis(axis) + const axisInfo = chart.yAxes?.[axis] || {} + + return ( + + {ticks.map((tick) => { + const y = mapY(tick, axis) + return ( + + {isLeft && } + {formatNumber(tick)} + + ) + })} + {axisInfo.unit && ( + + {axisInfo.unit} + + )} + + ) + })} + + {displayMode !== 'donut' && xTicks.map((tick) => ( + + {dateTick(tick)} + + ))} + + {displayMode !== 'donut' && displayMode !== 'digital_bar' && ( + + )} + {displayMode !== 'donut' && displayMode !== 'digital_bar' && activeAxes.slice(1).map((axis, index) => ( + + ))} + {displayMode !== 'donut' && ( + + )} + + {displayMode === 'digital_bar' && chart.series.map((serie, index) => { + const rowHeight = plotH / Math.max(1, chart.series.length) + const y = margin.top + rowHeight * index + 5 + const h = Math.max(8, rowHeight - 10) + const color = serie.color || '#22c55e' + const points = serie.points.filter((point) => Number.isFinite(point.x)).sort((a, b) => a.x - b.x) + + return ( + + + {serie.label || `Trend ${index + 1}`} + + {points.map((point, pointIndex) => { + const next = points[pointIndex + 1] + const x = mapX(point.x) + const x2 = next ? mapX(next.x) : width - margin.right + const active = Number(point.y) >= 1 + return ( + + ) + })} + + ) + })} + + {displayMode === 'vertical_bar' && chart.series.map((serie, index) => { + const points = serie.points.filter((point) => Number.isFinite(point.y)) + const color = serie.color || '#38bdf8' + const groupWidth = plotW / Math.max(1, points.length) + const barWidth = Math.max(2, Math.min(28, groupWidth / Math.max(1, chart.series.length) - 2)) + + return ( + + {points.map((point, pointIndex) => { + const x = mapX(point.x) - groupWidth / 2 + index * (barWidth + 2) + const y = mapY(point.y, serie.yAxis || 1) + return ( + + ) + })} + + ) + })} + + {displayMode === 'donut' && (() => { + const cx = width / 2 + const cy = margin.top + plotH / 2 + const radius = Math.max(18, Math.min(plotW, plotH) / 2 - 10) + const strokeWidth = Math.max(12, radius * 0.28) + const values = chart.series.map((serie, index) => { + const last = [...serie.points].reverse().find((point) => Number.isFinite(point.y)) + return { + serie, + index, + value: Math.max(0, Math.abs(Number(last?.y || 0))), + } + }).filter((item) => item.value > 0) + const total = values.reduce((sum, item) => sum + item.value, 0) || 1 + let offset = 0 + + return ( + + + {values.map((item) => { + const share = item.value / total + const dash = `${(share * 100).toFixed(3)} ${(100 - share * 100).toFixed(3)}` + const currentOffset = offset + offset -= share * 100 + return ( + + ) + })} + + {formatNumber(total)} + + + ) + })()} + + {displayMode === 'line' && chart.series.map((serie, index) => { + const axisMapY = (value) => mapY(value, serie.yAxis || 1) + const linePath = buildSeriesPath(serie.points, mapX, axisMapY, interpolation, connectNulls) + const fillPath = buildFillPath(serie.points, mapX, axisMapY, baseY, connectNulls) + const color = serie.color || '#38bdf8' + const lineWidth = Math.max(1, Number(serie.lineWidth ?? style.lineWidth ?? 3)) + const fillOpacity = Math.max(0, Math.min(0.8, Number(serie.fillOpacity ?? style.fillOpacity ?? 0.18))) + + return ( + + {serie.fillEnabled && fillPath && ( + + )} + {linePath && ( + + )} + + ) + })} + + {hoverPoint && ( + + )} + + {selection && ( + + )} + + +
+ {element.label || element.name || 'Trendgraph'} + {currentRange === 'custom' ? 'Auswahl' : currentRange} · {data?.interval || style.interval || 'auto'} +
+ + {hasLegend && ( +
+ {chart.series.map((serie, index) => ( + + + {serie.label || `Trend ${index + 1}`} + + ))} +
+ )} + + {!seriesConfig.length &&
Datenpunkte auswählen
} + {loading && !data &&
Trend wird geladen...
} + {error &&
{error}
} + + {interactive && ( +
e.stopPropagation()}> + {controlsMode !== 'global' && ( + + )} + + {controlsMode !== 'global' && customRange && ( + + )} + + +
+ )} +
+ ) +} diff --git a/visu-src/src/components/ValueInstrumentGraphic.jsx b/visu-src/src/components/ValueInstrumentGraphic.jsx new file mode 100644 index 0000000..14a2b01 --- /dev/null +++ b/visu-src/src/components/ValueInstrumentGraphic.jsx @@ -0,0 +1,303 @@ +import { formatLiveValue, livePointFromRef, resolveTemplateRef } from '../lib/visuUtils' + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)) +} + +function livePoint(style, liveValues) { + const ref = resolveTemplateRef(style.point || {}, style) + return livePointFromRef(ref, liveValues) +} + +function numericPointValue(point, fallback) { + const raw = String(point?.value ?? point?.value_formatted ?? '').replace(',', '.') + const match = raw.match(/-?\d+(?:\.\d+)?/) + return match ? Number(match[0]) : fallback +} + +function scaleConfig(style, liveValues) { + const min = Number(style.min ?? 0) + const maxCandidate = Number(style.max ?? 100) + const max = maxCandidate === min ? min + 1 : maxCandidate + const low = Math.min(min, max) + const high = Math.max(min, max) + const point = livePoint(style, liveValues) + const preview = low + (high - low) * 0.55 + const value = clamp(numericPointValue(point, preview), low, high) + const ratio = (value - low) / (high - low) + const requestedDecimals = Number(style.decimals ?? 1) + const decimals = Number.isFinite(requestedDecimals) ? Math.max(0, Math.min(8, Math.round(requestedDecimals))) : 1 + + return { + low, + high, + value, + ratio, + point, + display: point + ? formatLiveValue(point, decimals) + : value.toLocaleString('de-DE', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }), + } +} + +function valuesForTicks(low, high, step) { + const safeStep = Math.max(0.0001, Number(step || (high - low) / 5)) + const values = [low] + + for (let value = low + safeStep; value < high && values.length < 100; value += safeStep) { + values.push(Number(value.toFixed(6))) + } + + if (values[values.length - 1] !== high) values.push(high) + return values +} + +function showLabel(mode, index, length) { + if (mode === 'none') return false + if (mode === 'ends') return index === 0 || index === length - 1 + return true +} + +function mappedRanges(style, low, high) { + const ranges = Array.isArray(style.ranges) ? style.ranges : [] + + return ranges.map((range) => ({ + start: clamp((Number(range.from ?? low) - low) / (high - low), 0, 1), + end: clamp((Number(range.to ?? high) - low) / (high - low), 0, 1), + color: range.color || '#22c55e', + })).filter((range) => range.end > range.start) +} + +function formatTick(value) { + return Number(value).toLocaleString('de-DE', { maximumFractionDigits: 2 }) +} + +function HorizontalPointer({ style, x, y }) { + const color = style.needleColor || '#f8fafc' + + if (style.pointerStyle === 'line') { + return + } + + if (style.pointerStyle === 'diamond') { + return + } + + return +} + +function VerticalPointer({ style, x, y }) { + const color = style.needleColor || '#f8fafc' + + if (style.pointerStyle === 'line') { + return + } + + if (style.pointerStyle === 'diamond') { + return + } + + return +} + +function HorizontalBar({ element, style, config }) { + const w = Math.max(80, Number(element.w || 360)) + const h = Math.max(70, Number(element.h || 110)) + const left = 22 + const right = w - 22 + const axisY = Math.min(44, h * 0.4) + const length = right - left + const major = valuesForTicks(config.low, config.high, style.majorStep) + const divisions = Math.max(0, Math.round(Number(style.minorDivisions ?? 4))) + const labelSize = Math.max(8, Number(style.labelFontSize ?? 12)) + const labelY = axisY + 25 + Number(style.labelOffset ?? 0) + const ranges = mappedRanges(style, config.low, config.high) + + return ( + + + {ranges.map((range, index) => ( + + ))} + {style.fillEnabled && ( + + )} + {major.flatMap((value, index) => { + const ratio = (value - config.low) / (config.high - config.low) + const x = left + ratio * length + const marks = [] + + if (index < major.length - 1 && divisions > 0) { + const nextRatio = (major[index + 1] - config.low) / (config.high - config.low) + for (let inner = 1; inner < divisions; inner += 1) { + const minorX = left + (ratio + (nextRatio - ratio) * (inner / divisions)) * length + marks.push() + } + } + + if (showLabel(style.labelMode || 'all', index, major.length)) { + marks.push({formatTick(value)}) + } + + return marks + })} + + {config.display} + + ) +} + +function VerticalBar({ element, style, config }) { + const w = Math.max(90, Number(element.w || 120)) + const h = Math.max(120, Number(element.h || 300)) + const top = 20 + const bottom = h - 28 + const axisX = Math.max(42, w * 0.52) + const length = bottom - top + const major = valuesForTicks(config.low, config.high, style.majorStep) + const divisions = Math.max(0, Math.round(Number(style.minorDivisions ?? 4))) + const labelSize = Math.max(8, Number(style.labelFontSize ?? 12)) + const labelX = axisX - 20 - Number(style.labelOffset ?? 0) + const ranges = mappedRanges(style, config.low, config.high) + + return ( + + + {ranges.map((range, index) => ( + + ))} + {style.fillEnabled && ( + + )} + {major.flatMap((value, index) => { + const ratio = (value - config.low) / (config.high - config.low) + const y = bottom - ratio * length + const marks = [] + + if (index < major.length - 1 && divisions > 0) { + const nextRatio = (major[index + 1] - config.low) / (config.high - config.low) + for (let inner = 1; inner < divisions; inner += 1) { + const minorY = bottom - (ratio + (nextRatio - ratio) * (inner / divisions)) * length + marks.push() + } + } + + if (showLabel(style.labelMode || 'all', index, major.length)) { + marks.push({formatTick(value)}) + } + + return marks + })} + + {config.display} + + ) +} + +function polar(cx, cy, radius, angle) { + const rad = (angle - 90) * Math.PI / 180 + return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) } +} + +function arcPath(cx, cy, radius, start, end) { + const from = polar(cx, cy, radius, start) + const to = polar(cx, cy, radius, end) + return `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${Math.abs(end - start) > 180 ? 1 : 0} 1 ${to.x} ${to.y}` +} + +function DialGauge({ element, style, config }) { + const w = Math.max(140, Number(element.w || 260)) + const h = Math.max(120, Number(element.h || 210)) + const cx = w / 2 + const cy = h * 0.6 + const radius = Math.max(34, Math.min(w * 0.38, h * 0.43)) + const startAngle = -135 + const endAngle = 135 + const span = endAngle - startAngle + const major = valuesForTicks(config.low, config.high, style.majorStep) + const divisions = Math.max(0, Math.round(Number(style.minorDivisions ?? 4))) + const labelSize = Math.max(8, Number(style.labelFontSize ?? 12)) + const labelRadius = radius + 22 + Number(style.labelOffset ?? 0) + const ranges = mappedRanges(style, config.low, config.high) + const pointerAngle = startAngle + config.ratio * span + const pointer = polar(cx, cy, radius - 12, pointerAngle) + const wedgeLeft = polar(cx, cy, 10, pointerAngle - 90) + const wedgeRight = polar(cx, cy, 10, pointerAngle + 90) + + return ( + + + {ranges.map((range, index) => ( + + ))} + {style.fillEnabled && config.ratio > 0 && ( + + )} + {major.flatMap((value, index) => { + const ratio = (value - config.low) / (config.high - config.low) + const angle = startAngle + ratio * span + const outer = polar(cx, cy, radius - 8, angle) + const inner = polar(cx, cy, radius - 20, angle) + const label = polar(cx, cy, labelRadius, angle) + const marks = [] + + if (index < major.length - 1 && divisions > 0) { + const nextRatio = (major[index + 1] - config.low) / (config.high - config.low) + for (let innerIndex = 1; innerIndex < divisions; innerIndex += 1) { + const innerAngle = startAngle + (ratio + (nextRatio - ratio) * (innerIndex / divisions)) * span + const minorOuter = polar(cx, cy, radius - 9, innerAngle) + const minorInner = polar(cx, cy, radius - 16, innerAngle) + marks.push() + } + } + + if (showLabel(style.labelMode || 'all', index, major.length)) { + marks.push({formatTick(value)}) + } + + return marks + })} + {style.pointerStyle === 'wedge' ? ( + + ) : ( + + )} + + {config.display} + + ) +} + +export default function ValueInstrumentGraphic({ element, liveValues = {} }) { + const style = element.style_json || {} + const config = scaleConfig(style, liveValues) + + if (style.kind === 'dial_gauge') { + return + } + + if (style.orientation === 'vertical') { + return + } + + return +} diff --git a/visu-src/src/components/ViewerShortcutButton.jsx b/visu-src/src/components/ViewerShortcutButton.jsx new file mode 100644 index 0000000..933e7a3 --- /dev/null +++ b/visu-src/src/components/ViewerShortcutButton.jsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react' +import { api } from '../lib/visuApi' + +export const LAST_VIEWER_PAGE_SESSION_KEY = 'visu:last-viewer-page' + +export function rememberViewerPage(pageId) { + const nextId = Number(pageId || 0) + if (nextId > 0) { + sessionStorage.setItem(LAST_VIEWER_PAGE_SESSION_KEY, String(nextId)) + } +} + +function resolveViewerHref(pages) { + const savedId = Number(sessionStorage.getItem(LAST_VIEWER_PAGE_SESSION_KEY) || 0) + const savedPage = pages.find((page) => Number(page.id) === savedId) + const fallbackPage = savedPage || pages[0] + return fallbackPage ? `/visu/?mode=viewer&page_id=${fallbackPage.id}` : '/visu/?mode=dashboard' +} + +export default function ViewerShortcutButton({ className = 'btn', label = 'Anzeigen', disabled = false }) { + const [href, setHref] = useState(() => { + const savedId = Number(sessionStorage.getItem(LAST_VIEWER_PAGE_SESSION_KEY) || 0) + return savedId > 0 ? `/visu/?mode=viewer&page_id=${savedId}` : '/visu/?mode=dashboard' + }) + + useEffect(() => { + let alive = true + + api('pages') + .then((data) => { + if (!alive) return + setHref(resolveViewerHref(data.pages || [])) + }) + .catch(() => {}) + + return () => { + alive = false + } + }, []) + + if (disabled) { + return {label} + } + + return {label} +} diff --git a/visu-src/src/index.css b/visu-src/src/index.css new file mode 100644 index 0000000..2c84af0 --- /dev/null +++ b/visu-src/src/index.css @@ -0,0 +1,111 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +body { + margin: 0; +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/visu-src/src/lib/templateInstances.js b/visu-src/src/lib/templateInstances.js new file mode 100644 index 0000000..93bc0c8 --- /dev/null +++ b/visu-src/src/lib/templateInstances.js @@ -0,0 +1,224 @@ +import { nextTempId, normalizeElement } from './visuUtils' + +function pointToTemplateMapping(point, templatePlaceholderKey = '') { + if (!point) return null + + if ((point.type || point.variable_type) === 'static') { + const value = point.static_value ?? point.value ?? '' + + return { + type: 'static', + variable_type: 'static', + external_id: '', + variable_id: '', + point_key: '', + point_label: point.point_label || point.label || `Fester Wert ${value}`, + label: point.label || point.point_label || `Fester Wert ${value}`, + static_value: value, + value, + value_formatted: String(value), + templatePlaceholderKey, + } + } + + return { + type: point.type || point.variable_type || '', + variable_type: point.type || point.variable_type || '', + external_id: point.external_id || '', + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.point_label || point.label || '', + label: point.label || point.point_label || '', + templatePlaceholderKey, + } +} + +function isPlaceholderRef(value) { + return Boolean( + value + && typeof value === 'object' + && !Array.isArray(value) + && (value.variable_type === 'placeholder' || value.type === 'placeholder') + && value.point_key + ) +} + +export function mapTemplatePlaceholders(value, mapping) { + if (Array.isArray(value)) { + return value.map((item) => mapTemplatePlaceholders(item, mapping)) + } + + if (!value || typeof value !== 'object') return value + + const next = {} + Object.entries(value).forEach(([key, item]) => { + next[key] = mapTemplatePlaceholders(item, mapping) + }) + + if (isPlaceholderRef(value)) { + const mapped = pointToTemplateMapping(mapping[value.point_key], value.point_key) + if (mapped) return { ...next, ...mapped } + return { ...next, templatePlaceholderKey: value.point_key } + } + + return next +} + +export function mapTemplateBindingRefs(value, mapping) { + if (Array.isArray(value)) return value.map((item) => mapTemplateBindingRefs(item, mapping)) + if (!value || typeof value !== 'object') return value + + const next = {} + Object.entries(value).forEach(([key, item]) => { + next[key] = mapTemplateBindingRefs(item, mapping) + }) + + const placeholderKey = value.templatePlaceholderKey + if (placeholderKey && mapping[placeholderKey]) { + return { + ...next, + ...pointToTemplateMapping(mapping[placeholderKey], placeholderKey), + } + } + + return next +} + +export function collectTemplateBindingRefs(value, out = {}) { + if (Array.isArray(value)) { + value.forEach((item) => collectTemplateBindingRefs(item, out)) + return out + } + + if (!value || typeof value !== 'object') return out + + const bindings = value.style_json?.templateBindings + if (bindings && typeof bindings === 'object' && !Array.isArray(bindings)) { + Object.entries(bindings).forEach(([key, point]) => { + if (point && !out[key]) out[key] = pointToTemplateMapping(point, key) + }) + } + + if (value.templatePlaceholderKey && !out[value.templatePlaceholderKey]) { + out[value.templatePlaceholderKey] = pointToTemplateMapping(value, value.templatePlaceholderKey) + } + + Object.values(value).forEach((item) => collectTemplateBindingRefs(item, out)) + return out +} + +export function stripTemplateRuntimeMeta(style = {}) { + const { + templateGroupId, + templateSourceId, + templateElementId, + templateName, + templatePlaceholders, + templateBindings, + ...clean + } = style || {} + + return clean +} + +function templateElementKey(element, index) { + return String( + element?.style_json?.templateElementUid || + element?.style_json?.templateElementId || + element?.id || + `index-${index}` + ) +} + +function boundsFor(items) { + return { + minX: Math.min(...items.map((item) => Number(item.x || 0))), + minY: Math.min(...items.map((item) => Number(item.y || 0))), + minZ: Math.min(...items.map((item) => Number(item.z_index || 10))), + } +} + +export async function refreshTemplateInstances(elements, loadTemplate) { + const groups = new Map() + + elements.forEach((element) => { + const style = element.style_json || {} + if (!style.templateGroupId || !style.templateSourceId) return + + if (!groups.has(style.templateGroupId)) groups.set(style.templateGroupId, []) + groups.get(style.templateGroupId).push(element) + }) + + if (groups.size === 0) return elements + + const replacements = new Map() + const templateCache = new Map() + + for (const [groupId, groupElements] of groups.entries()) { + const firstStyle = groupElements[0]?.style_json || {} + const sourceId = Number(firstStyle.templateSourceId || 0) + if (!sourceId) continue + + if (!templateCache.has(sourceId)) { + templateCache.set(sourceId, await loadTemplate(sourceId)) + } + + const templateData = templateCache.get(sourceId) + const templateElements = Array.isArray(templateData?.elements) ? templateData.elements.map(normalizeElement) : [] + if (templateElements.length === 0) continue + + const bindings = collectTemplateBindingRefs(groupElements) + const currentBounds = boundsFor(groupElements) + const templateBounds = boundsFor(templateElements) + const existingByKey = new Map() + + groupElements.forEach((element, index) => { + existingByKey.set(templateElementKey(element, index), element) + }) + + const refreshed = templateElements.map((templateElement, index) => { + const key = templateElementKey(templateElement, index) + const existing = existingByKey.get(key) || groupElements[index] + const mapped = mapTemplatePlaceholders(templateElement, bindings) + const style = mapped.style_json || {} + + return normalizeElement({ + ...mapped, + id: existing?.id || nextTempId(), + x: Number(mapped.x || 0) - templateBounds.minX + currentBounds.minX, + y: Number(mapped.y || 0) - templateBounds.minY + currentBounds.minY, + z_index: Number(mapped.z_index || 10) - templateBounds.minZ + currentBounds.minZ, + style_json: { + ...style, + templateGroupId: groupId, + templateSourceId: sourceId, + templateElementId: key, + templateName: templateData?.template?.name || firstStyle.templateName || 'Template', + templatePlaceholders: templateData?.placeholders || firstStyle.templatePlaceholders || [], + templateBindings: bindings, + }, + }) + }) + + replacements.set(groupId, refreshed) + } + + const output = [] + const emittedGroups = new Set() + + elements.forEach((element) => { + const groupId = element.style_json?.templateGroupId + + if (groupId && replacements.has(groupId)) { + if (!emittedGroups.has(groupId)) { + output.push(...replacements.get(groupId)) + emittedGroups.add(groupId) + } + return + } + + output.push(element) + }) + + return output +} diff --git a/visu-src/src/lib/visuApi.js b/visu-src/src/lib/visuApi.js new file mode 100644 index 0000000..89ee92e --- /dev/null +++ b/visu-src/src/lib/visuApi.js @@ -0,0 +1,87 @@ +export const API_BASE = '/api/visu_api.php' + +export async function api(action, params = {}) { + const url = new URL(API_BASE, window.location.origin) + url.searchParams.set('action', action) + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, value) + } + }) + + const res = await fetch(url.toString(), { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }) + + const text = await res.text() + let data + + try { + data = JSON.parse(text) + } catch { + throw new Error(text || 'API liefert kein JSON') + } + + if (!res.ok || !data.ok) { + throw new Error(data.error || 'API Fehler') + } + + return data +} + +export async function apiPost(action, body = {}) { + const fd = new FormData() + fd.append('action', action) + + Object.entries(body).forEach(([key, value]) => { + fd.append(key, value ?? '') + }) + + const res = await fetch(API_BASE, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + body: fd, + }) + + const text = await res.text() + let data + + try { + data = JSON.parse(text) + } catch { + throw new Error(text || 'API liefert kein JSON') + } + + if (!res.ok || !data.ok) { + throw new Error(data.error || 'API Fehler') + } + + return data +} + +export async function apiFormData(fd) { + const res = await fetch(API_BASE, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + body: fd, + }) + + const text = await res.text() + let data + + try { + data = JSON.parse(text) + } catch { + throw new Error(text || 'API liefert kein JSON') + } + + if (!res.ok || !data.ok) { + throw new Error(data.error || 'API Fehler') + } + + return data +} \ No newline at end of file diff --git a/visu-src/src/lib/visuUtils.js b/visu-src/src/lib/visuUtils.js new file mode 100644 index 0000000..fcf22c1 --- /dev/null +++ b/visu-src/src/lib/visuUtils.js @@ -0,0 +1,674 @@ +import { useEffect } from 'react' + +let tempIdCounter = -1 + +export function nextTempId() { + tempIdCounter -= 1 + return tempIdCounter +} + +export const ASSET_TYPE_LABELS = { + background: 'Hintergründe', + pump: 'Pumpen', + heat_generation: 'W\u00e4rmeerzeugung', + cold_generation: 'K\u00e4lteerzeugung', + heat_recovery: 'W\u00e4rmetauscher / WRG', + valve: 'Ventile', + fan: 'Ventilatoren', + sensor: 'Fühler', + text: 'Text / Label', + custom: 'Sonstige', +} + +export const DIN_COLORS = [ + { key: '', label: 'Frei wählen', value: '' }, + { key: 'heating_supply', label: 'Vorlauf Rot', value: '#dc2626' }, + { key: 'heating_return', label: 'Rücklauf Dunkelblau', value: '#1d4ed8' }, + { key: 'cold_water', label: 'Kaltwasser Blau', value: '#0ea5e9' }, + { key: 'warm_water', label: 'Warmwasser Orange', value: '#f97316' }, + { key: 'gas', label: 'Gas Gelb', value: '#eab308' }, + { key: 'air_supply', label: 'Zuluft Grün', value: '#22c55e' }, + { key: 'air_exhaust', label: 'Abluft Braun', value: '#92400e' }, + { key: 'neutral', label: 'Neutral Grau', value: '#94a3b8' }, + { key: 'black', label: 'Schwarz', value: '#020617' }, + { key: 'white', label: 'Weiß', value: '#ffffff' }, +] + +export const FONT_OPTIONS = [ + { value: 'Arial, sans-serif', label: 'Arial' }, + { value: '"Segoe UI", sans-serif', label: 'Segoe UI' }, + { value: 'Verdana, sans-serif', label: 'Verdana' }, + { value: 'Tahoma, sans-serif', label: 'Tahoma' }, + { value: 'Georgia, serif', label: 'Georgia' }, + { value: '"Times New Roman", serif', label: 'Times New Roman' }, + { value: '"Courier New", monospace', label: 'Courier New' }, + { value: 'ui-monospace, Consolas, monospace', label: 'Monospace' }, +] + +export const TOOL_PRESETS = { + sensor: { + element_type: 'value', + name: 'Fühler', + label: 'Fühler', + w: 190, + h: 86, + style_json: { + kind: 'sensor', + subtype: 'sensor', + color: '#f59e0b', + marker: 'T', + markerColor: '#f59e0b', + strokeColor: '#60a5fa', + strokeWidth: 3, + showBorder: true, + fillColor: 'transparent', + markerTextColor: '#0f172a', + markerSize: 34, + markerFontSize: 13, + labelColor: 'rgba(209, 213, 219, 0.78)', + labelFontSize: 12, + valueColor: '#ffffff', + valueFontSize: 20, + unitMode: 'auto', + decimals: 1, + }, + }, + setpoint: { + element_type: 'value', + name: 'Sollwert', + label: 'Sollwert', + w: 210, + h: 96, + style_json: { + kind: 'setpoint', + subtype: 'setpoint', + color: '#38bdf8', + marker: 'SP', + markerColor: '#38bdf8', + markerTextColor: '#0f172a', + markerSize: 34, + markerFontSize: 13, + labelColor: 'rgba(209, 213, 219, 0.78)', + labelFontSize: 12, + valueColor: '#ffffff', + valueFontSize: 20, + unitMode: 'auto', + decimals: 1, + strokeColor: '#60a5fa', + strokeWidth: 3, + showBorder: true, + fillColor: '#082f49', + fillMode: 'linear', + gradientColor: '#2563eb', + gradientAngle: 110, + gradientOrigin: 'center', + }, + }, + scale_bar: { + element_type: 'value', + name: 'Skalenbalken', + label: 'Skalenbalken', + w: 360, + h: 110, + style_json: { + kind: 'scale_bar', + orientation: 'horizontal', + min: 0, + max: 100, + majorStep: 20, + minorDivisions: 4, + labelMode: 'all', + labelFontSize: 12, + labelOffset: 0, + barColor: '#334155', + fillEnabled: false, + fillColor: '#38bdf8', + needleColor: '#f8fafc', + pointerStyle: 'triangle', + decimals: 1, + ranges: [], + point: null, + }, + }, + dial_gauge: { + element_type: 'value', + name: 'Zeigerinstrument', + label: 'Zeigerinstrument', + w: 260, + h: 210, + style_json: { + kind: 'dial_gauge', + min: 0, + max: 100, + majorStep: 20, + minorDivisions: 4, + labelMode: 'all', + labelFontSize: 12, + labelOffset: 0, + barColor: '#334155', + fillEnabled: false, + fillColor: '#38bdf8', + needleColor: '#f8fafc', + pointerStyle: 'needle', + decimals: 1, + ranges: [], + point: null, + }, + }, + trend_chart: { + element_type: 'value', + name: 'Trendgraph', + label: 'Trendgraph', + w: 560, + h: 320, + style_json: { + kind: 'trend_chart', + series: [], + range: '24h', + interval: 'auto', + aggregate: 'mean', + refreshSeconds: 60, + displayMode: 'line', + interpolation: 'linear', + connectNulls: true, + lineWidth: 3, + fillOpacity: 0.18, + showLegend: true, + backgroundColor: 'rgba(15, 23, 42, 0.46)', + borderColor: 'rgba(148, 163, 184, 0.42)', + radius: 14, + }, + }, + sankey_chart: { + element_type: 'value', + name: 'Sankey', + label: 'Sankey', + w: 620, + h: 340, + style_json: { + kind: 'sankey_chart', + nodes: [ + { id: 'sankey-node-1', label: 'Quelle', column: 0, order: 0, color: '#38bdf8' }, + { id: 'sankey-node-2', label: 'Verbraucher', column: 1, order: 0, color: '#22c55e' }, + ], + links: [ + { + id: 'sankey-link-1', + sourceId: 'sankey-node-1', + targetId: 'sankey-node-2', + label: 'Fluss 1', + sourceMode: 'static', + staticValue: '10', + color: '#38bdf8', + opacity: 0.46, + thicknessFactor: 1, + }, + ], + unit: '', + decimals: 1, + refreshSeconds: 30, + nodeWidth: 26, + nodeRadius: 10, + nodeGap: 14, + minLinkWidth: 4, + linkOpacity: 0.46, + curve: 0.42, + minNodeHeight: 24, + backgroundColor: 'rgba(15, 23, 42, 0.46)', + borderColor: 'rgba(148, 163, 184, 0.42)', + labelColor: '#e2e8f0', + radius: 14, + showValues: true, + }, + }, + text: { + element_type: 'text', + name: 'Text', + label: 'Text', + w: 190, + h: 60, + style_json: { + subtype: 'text', + color: '#ffffff', + fontSize: 22, + fontFamily: 'Arial, sans-serif', + textRules: [], + }, + }, + link: { + element_type: 'text', + name: 'Link', + label: 'Link', + w: 220, + h: 60, + style_json: { + subtype: 'link', + color: '#93c5fd', + fontSize: 22, + fontFamily: 'Arial, sans-serif', + textRules: [], + linkMode: 'page', + linkPageId: '', + linkUrl: '', + linkTarget: 'same_tab', + renderAsButton: false, + buttonShape: 'rounded', + buttonRadius: 16, + buttonFill: 'rgba(37, 99, 235, 0.78)', + buttonBorderColor: 'rgba(147, 197, 253, 0.72)', + }, + }, + menu: { + element_type: 'text', + name: 'Menü', + label: 'Menü', + w: 320, + h: 120, + style_json: { + subtype: 'menu', + color: '#93c5fd', + fontSize: 18, + fontFamily: 'Arial, sans-serif', + menuOrientation: 'vertical', + menuGap: 8, + menuButtonSize: 42, + linkTarget: 'same_tab', + renderAsButton: true, + buttonShape: 'rounded', + buttonRadius: 16, + buttonFill: 'rgba(37, 99, 235, 0.78)', + buttonBorderColor: 'rgba(147, 197, 253, 0.72)', + menuItems: [ + { id: 'menu-1', label: 'Link 1', linkMode: 'page', linkPageId: '', linkUrl: '', linkTarget: 'same_tab' }, + { id: 'menu-2', label: 'Link 2', linkMode: 'page', linkPageId: '', linkUrl: '', linkTarget: 'same_tab' }, + ], + }, + }, +} + +export const ASSET_TYPE_ICONS = { + background: '\uD83D\uDDBC\uFE0F', + pump: '\uD83D\uDCA7', + heat_generation: '\uD83D\uDD25', + cold_generation: '\u2744\uFE0F', + heat_recovery: '\u267B\uFE0F', + valve: '\u2699\uFE0F', + fan: '\uD83C\uDF00', + sensor: '\uD83C\uDF21\uFE0F', + text: '\uD83C\uDFF7\uFE0F', + custom: '\u25A6', +} + +export function decodeMaybeJson(value, fallback = null) { + if (!value) return fallback + if (typeof value === 'object') return value + + try { + return JSON.parse(value) + } catch { + return fallback + } +} + +export function normalizeState(state) { + return { + id: Number(state.id || 0), + asset_id: Number(state.asset_id || 0), + state_key: state.state_key || '', + label: state.label || '', + image_file: state.image_file || '', + sort_order: Number(state.sort_order || 100), + is_default: Boolean(Number(state.is_default || 0)), + } +} + +export function normalizeRule(rule) { + return { + id: rule.id ? Number(rule.id) : 0, + element_id: rule.element_id ? Number(rule.element_id) : 0, + asset_state_id: rule.asset_state_id ? Number(rule.asset_state_id) : 0, + variable_type: rule.variable_type || 'digital', + external_id: rule.external_id || '', + variable_id: rule.variable_id ? Number(rule.variable_id) : '', + point_key: rule.point_key || '', + point_label: rule.point_label || '', + operator: rule.operator || 'eq', + compare_value: rule.compare_value ?? '', + priority: Number(rule.priority || 100), + state_key: rule.state_key || '', + state_label: rule.state_label || '', + image_file: rule.image_file || '', + } +} + +export function normalizeElement(row) { + return { + id: Number(row.id || 0), + asset_id: row.asset_id ? Number(row.asset_id) : null, + asset_name: row.asset_name || '', + asset_type: row.asset_type || '', + asset_states: Array.isArray(row.asset_states) ? row.asset_states.map(normalizeState) : [], + element_type: row.element_type || 'shape', + name: row.name || '', + variable_id: row.variable_id ? Number(row.variable_id) : '', + label: row.label || row.name || '', + x: Number(row.x || 0), + y: Number(row.y || 0), + w: Number(row.w || 120), + h: Number(row.h || 120), + rotation: Number(row.rotation || 0), + z_index: Number(row.z_index || 10), + opacity: Number(row.opacity ?? 1), + state_rules: decodeMaybeJson(row.state_rules, null), + style_json: decodeMaybeJson(row.style_json, {}), + is_locked: Boolean(Number(row.is_locked || 0)), + rules: Array.isArray(row.rules) ? row.rules.map(normalizeRule) : [], + } +} + +export function normalizeAsset(asset) { + return { + id: Number(asset.id || 0), + project_id: asset.project_id ? Number(asset.project_id) : null, + name: asset.name || '', + description: asset.description || '', + asset_type: asset.asset_type || 'custom', + is_global: Boolean(Number(asset.is_global ?? 1)), + states: Array.isArray(asset.states) ? asset.states.map(normalizeState) : [], + } +} + +export function normalizePage(page) { + return { + ...page, + id: Number(page.id || 0), + project_id: Number(page.project_id || 0), + width: Number(page.width || 1920), + height: Number(page.height || 1080), + background_color: page.background_color || '#0f172a', + background_image: page.background_image || '', + grid_enabled: Boolean(Number(page.grid_enabled ?? 1)), + grid_size: Number(page.grid_size || 40), + snap_enabled: Boolean(Number(page.snap_enabled ?? 1)), + } +} + +export function sourceKeyFromRef(ref) { + return [ + ref?.type || ref?.variable_type || '', + ref?.external_id || '', + ref?.variable_id || '', + ref?.point_key || '', + ].join('|') +} + +export function sourceKeyVariantsFromRef(ref) { + if (!ref) return [] + + const type = ref?.type || ref?.variable_type || '' + const variants = [] + const addVariant = (nextType) => { + variants.push([ + nextType || '', + ref?.external_id || '', + ref?.variable_id || '', + ref?.point_key || '', + ].join('|')) + } + + if (ref.source_key) variants.push(ref.source_key) + addVariant(type) + + // Alarmwerte kommen in der API als normale digitale Werte aus getvalue. + // Gespeichert ist die Auswahl trotzdem oft als "alarm", deshalb brauchen + // alle Livewert-Regeln denselben Fallback. + if (type === 'alarm') addVariant('digital') + if (type === 'digital') addVariant('alarm') + + return [...new Set(variants.filter(Boolean))] +} + +function pointHasDisplayValue(point) { + if (!point) return false + + const hasValue = + point.value !== null + && point.value !== undefined + && String(point.value).trim() !== '' + + const hasFormattedValue = + point.value_formatted !== null + && point.value_formatted !== undefined + && String(point.value_formatted).trim() !== '' + + return hasValue || hasFormattedValue +} + +export function livePointFromRef(ref, liveValues = {}) { + if (!ref || !liveValues) return null + + if ((ref.type || ref.variable_type) === 'static') { + const value = ref.static_value ?? ref.value ?? ref.value_formatted ?? '' + + return { + ...ref, + type: 'static', + value, + value_formatted: String(value), + label: ref.point_label || ref.label || 'Fester Wert', + quality: 'static', + } + } + + let firstMatch = null + + for (const key of sourceKeyVariantsFromRef(ref)) { + const point = liveValues[key] + if (!point) continue + if (!firstMatch) firstMatch = point + + if (pointHasDisplayValue(point)) return point + } + + if (ref.variable_id && liveValues[String(ref.variable_id)]) { + const point = liveValues[String(ref.variable_id)] + if (pointHasDisplayValue(point)) return point + if (!firstMatch) firstMatch = point + } + + return firstMatch +} + +export function resolveTemplateRef(ref, style = {}) { + if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return ref + + const placeholderKey = + ref.templatePlaceholderKey || + ((ref.type === 'placeholder' || ref.variable_type === 'placeholder') ? ref.point_key : '') + + if (!placeholderKey) return ref + + const bindings = style?.templateBindings + if (!bindings || typeof bindings !== 'object' || Array.isArray(bindings)) return ref + + const binding = bindings[placeholderKey] + if (!binding) return ref + + return { + ...ref, + ...binding, + templatePlaceholderKey: placeholderKey, + } +} + +export function valueText(point) { + if (!point) return '' + return String(point.value ?? point.value_formatted ?? '').trim() +} + +export function compareRule(rule, point) { + const valRaw = valueText(point) + const cmpRaw = String(rule.compare_value ?? '').trim() + const valNum = Number(String(valRaw).replace(',', '.')) + const cmpNum = Number(String(cmpRaw).replace(',', '.')) + + switch (rule.operator) { + case 'truthy': + return ['1', 'true', 'on', 'ein', 'ja', 'yes'].includes(valRaw.toLowerCase()) + case 'falsy': + return ['0', 'false', 'off', 'aus', 'nein', 'no'].includes(valRaw.toLowerCase()) + case 'neq': + return valRaw !== cmpRaw + case 'gt': + return !Number.isNaN(valNum) && !Number.isNaN(cmpNum) && valNum > cmpNum + case 'gte': + return !Number.isNaN(valNum) && !Number.isNaN(cmpNum) && valNum >= cmpNum + case 'lt': + return !Number.isNaN(valNum) && !Number.isNaN(cmpNum) && valNum < cmpNum + case 'lte': + return !Number.isNaN(valNum) && !Number.isNaN(cmpNum) && valNum <= cmpNum + case 'contains': + return valRaw.toLowerCase().includes(cmpRaw.toLowerCase()) + case 'not_contains': + return !valRaw.toLowerCase().includes(cmpRaw.toLowerCase()) + case 'eq': + default: + return valRaw === cmpRaw + } +} + +export function resolveElementVisibility(element, liveValues = {}) { + const style = element?.style_json || {} + const rule = resolveTemplateRef(style.visibilityRule, style) + if (!rule?.enabled) return true + + const point = livePointFromRef(rule, liveValues) + + if (!point) return true + + return !compareRule(rule, point) +} + +export function snapValue(value, page) { + if (!page?.snap_enabled) return value + const size = Number(page.grid_size || 40) + if (size <= 1) return value + return Math.round(value / size) * size +} + +export function hexToRgb(hex) { + const clean = String(hex || '').replace('#', '') + if (!/^[0-9a-fA-F]{6}$/.test(clean)) return { r: 0, g: 0, b: 0 } + + return { + r: parseInt(clean.slice(0, 2), 16), + g: parseInt(clean.slice(2, 4), 16), + b: parseInt(clean.slice(4, 6), 16), + } +} + +export function rgbToHex(r, g, b) { + const clamp = (v) => Math.max(0, Math.min(255, Number(v || 0))) + return '#' + [clamp(r), clamp(g), clamp(b)] + .map((v) => v.toString(16).padStart(2, '0')) + .join('') +} + +export function cloneElementForPaste(el, page, offset = 30) { + const newId = nextTempId() + + return { + ...JSON.parse(JSON.stringify(el)), + id: newId, + x: snapValue(Number(el.x || 0) + offset, page), + y: snapValue(Number(el.y || 0) + offset, page), + z_index: Number(el.z_index || 10) + 1, + } +} + +export function useVisuRootHeight() { + useEffect(() => { + function update() { + const root = document.getElementById('root') + if (!root) return + + const top = root.getBoundingClientRect().top + const h = Math.max(320, window.innerHeight - top) + document.documentElement.style.setProperty('--visu-root-height', `${h}px`) + } + + update() + window.addEventListener('resize', update) + setTimeout(update, 50) + setTimeout(update, 300) + + return () => window.removeEventListener('resize', update) + }, []) +} + +export function groupPagesByProject(pages) { + const map = new Map() + + pages.forEach((page) => { + const key = String(page.project_id) + + if (!map.has(key)) { + map.set(key, { + project_id: page.project_id, + year: page.year, + project_number: page.project_number, + project_name: page.project_name, + customer_name: page.customer_name, + pages: [], + }) + } + + map.get(key).pages.push(page) + }) + + return Array.from(map.values()).sort((a, b) => { + const ay = Number(a.year || 0) + const by = Number(b.year || 0) + + if (ay !== by) return by - ay + + return String(a.project_number || '').localeCompare(String(b.project_number || ''), 'de', { + numeric: true, + sensitivity: 'base', + }) + }) +} + +export function formatLiveValue(point, decimals = 1, fallback = '…') { + if (!point) return fallback + + const rawDisplay = String(point.value_formatted ?? point.value ?? '').trim() + const rawNumber = String(point.value ?? point.value_formatted ?? '').replace(',', '.') + const match = rawNumber.match(/-?\d+(?:\.\d+)?/) + + if (!match) return rawDisplay || fallback + + const numeric = Number(match[0]) + if (!Number.isFinite(numeric)) return rawDisplay || fallback + + const requestedPrecision = Number(decimals ?? 1) + const precision = Number.isFinite(requestedPrecision) ? Math.max(0, Math.min(8, Math.round(requestedPrecision))) : 1 + const formatted = numeric.toLocaleString('de-DE', { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }) + + const suffixSource = String(point.value_formatted ?? '').trim() + const suffix = suffixSource.replace(/^-?\s*\d+(?:[.,]\d+)?\s*/, '').trim() + return suffix ? `${formatted} ${suffix}` : formatted +} + +export function valueCardBackground(style) { + const fillColor = style.fillColor || '#082f49' + const secondColor = style.gradientColor || '#2563eb' + + if (style.fillMode === 'solid') return fillColor + if (style.fillMode === 'radial') return `radial-gradient(circle, ${fillColor}, ${secondColor})` + + return `linear-gradient(${Number(style.gradientAngle ?? 110)}deg, ${fillColor}, ${secondColor})` +} diff --git a/visu-src/src/main.jsx b/visu-src/src/main.jsx new file mode 100644 index 0000000..2349a44 --- /dev/null +++ b/visu-src/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import './style.css' +import App from './App.jsx' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) \ No newline at end of file diff --git a/visu-src/src/pages/CsvExports.jsx b/visu-src/src/pages/CsvExports.jsx new file mode 100644 index 0000000..42f00e1 --- /dev/null +++ b/visu-src/src/pages/CsvExports.jsx @@ -0,0 +1,483 @@ +import { useEffect, useState } from 'react' +import ViewerShortcutButton from '../components/ViewerShortcutButton' +import { api, apiPost } from '../lib/visuApi' + +const EXPORT_KIND_OPTIONS = [ + { value: 'trend_csv', label: 'Trend CSV' }, + { value: 'lastvalues_csv', label: 'LastValues CSV' }, +] + +const SCHEDULE_KIND_OPTIONS = [ + { value: 'interval', label: 'Taeglich im Intervall' }, + { value: 'daily', label: 'Taeglich' }, + { value: 'weekly', label: 'Woechentlich' }, + { value: 'monthly', label: 'Monatlich' }, +] + +const WEEKDAY_OPTIONS = [ + { value: 1, label: 'Mo' }, + { value: 2, label: 'Di' }, + { value: 3, label: 'Mi' }, + { value: 4, label: 'Do' }, + { value: 5, label: 'Fr' }, + { value: 6, label: 'Sa' }, + { value: 7, label: 'So' }, +] + +function parseWeekdays(value) { + return String(value || '') + .split(',') + .map((item) => Number(item)) + .filter((item, index, list) => item >= 1 && item <= 7 && list.indexOf(item) === index) +} + +function weekdaysToString(weekdays) { + return [...weekdays].sort((a, b) => a - b).join(',') +} + +function formatDateTime(value) { + if (!value) return '-' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('de-DE') +} + +function formatBytes(value) { + const size = Number(value || 0) + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB` + return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + +function exportStatusLabel(status) { + const map = { + idle: 'Idle', + running: 'Laeuft', + success: 'OK', + error: 'Fehler', + queued: 'Queue', + deleted: 'Geloescht', + } + return map[status] || status || '-' +} + +function createEmptyExportJob(projectId = 0, selectionId = 0) { + return { + id: 0, + project_id: projectId, + selection_id: selectionId, + name: '', + export_kind: 'trend_csv', + csv_delimiter: ';', + enabled: true, + schedule_kind: 'daily', + interval_minutes: 60, + time_of_day: '06:00', + weekdays: [1, 2, 3, 4, 5], + month_day: 1, + target_dir: 'csv_exports', + filename_pattern: '{selection}_{yyyy}-{mm}-{dd}_{hh}-{ii}-{ss}.csv', + retention_days: 30, + next_run_at: '', + last_status: 'idle', + last_error: '', + } +} + +function mapExportJobToDraft(item) { + return { + id: Number(item.id || 0), + project_id: Number(item.project_id || 0), + selection_id: Number(item.selection_id || 0), + name: item.name || '', + export_kind: item.export_kind || 'trend_csv', + csv_delimiter: item.csv_delimiter || ';', + enabled: Number(item.enabled ?? 1) === 1, + schedule_kind: item.schedule_kind || 'daily', + interval_minutes: Number(item.interval_minutes || 60), + time_of_day: item.time_of_day || '00:00', + weekdays: parseWeekdays(item.weekdays), + month_day: Number(item.month_day || 1), + target_dir: item.target_dir || 'csv_exports', + filename_pattern: item.filename_pattern || '{selection}_{yyyy}-{mm}-{dd}_{hh}-{ii}-{ss}.csv', + retention_days: Number(item.retention_days || 30), + next_run_at: item.next_run_at || '', + last_status: item.last_status || 'idle', + last_error: item.last_error || '', + } +} + +export default function CsvExports() { + const [projects, setProjects] = useState([]) + const [savedSelections, setSavedSelections] = useState([]) + const [exportProjectId, setExportProjectId] = useState(0) + const [exportJobs, setExportJobs] = useState([]) + const [exportFiles, setExportFiles] = useState([]) + const [exportJobDraft, setExportJobDraft] = useState(createEmptyExportJob()) + const [loadingExports, setLoadingExports] = useState(false) + const [savingExportJob, setSavingExportJob] = useState(false) + const [error, setError] = useState('') + + async function loadProjects() { + const data = await api('projects') + const nextProjects = data.projects || [] + setProjects(nextProjects) + setExportProjectId((current) => current || Number(nextProjects[0]?.id || 0)) + } + + async function loadSavedSelections() { + const data = await api('monitoring_selections') + setSavedSelections(data.selections || []) + setExportJobDraft((current) => { + if (current.selection_id) return current + return { ...current, selection_id: Number(data.selections?.[0]?.id || 0) } + }) + } + + async function loadExportData(projectId) { + if (!projectId) { + setExportJobs([]) + setExportFiles([]) + return + } + + setLoadingExports(true) + + try { + const [jobsData, filesData] = await Promise.all([ + api('csv_export_jobs', { project_id: projectId }), + api('csv_export_files', { project_id: projectId }), + ]) + + setExportJobs(jobsData.jobs || []) + setExportFiles(filesData.files || []) + } catch (err) { + setError(err.message) + } finally { + setLoadingExports(false) + } + } + + useEffect(() => { + loadProjects().catch((err) => setError(err.message)) + loadSavedSelections().catch((err) => setError(err.message)) + }, []) + + useEffect(() => { + if (!exportProjectId) return + loadExportData(exportProjectId).catch((err) => setError(err.message)) + }, [exportProjectId]) + + useEffect(() => { + if (!exportProjectId) return undefined + + const timer = window.setInterval(() => { + loadExportData(exportProjectId).catch(() => {}) + }, 15000) + + return () => window.clearInterval(timer) + }, [exportProjectId]) + + useEffect(() => { + setExportJobDraft((current) => { + if (current.project_id === exportProjectId) return current + return { ...current, project_id: exportProjectId } + }) + }, [exportProjectId]) + + function resetExportJobDraft() { + setExportJobDraft(createEmptyExportJob(exportProjectId, Number(savedSelections[0]?.id || 0))) + } + + function loadExportJob(item) { + setExportJobDraft(mapExportJobToDraft(item)) + setExportProjectId(Number(item.project_id || exportProjectId || 0)) + } + + async function saveExportJob() { + if (!exportProjectId) { + setError('Bitte zuerst ein Projekt fuer den Exportjob waehlen.') + return + } + + if (!exportJobDraft.selection_id) { + setError('Bitte zuerst eine gespeicherte Selection fuer den Exportjob waehlen.') + return + } + + setSavingExportJob(true) + setError('') + + try { + await apiPost('csv_export_job_save', { + job_id: exportJobDraft.id || '', + project_id: exportProjectId, + selection_id: exportJobDraft.selection_id, + name: exportJobDraft.name, + export_kind: exportJobDraft.export_kind, + csv_delimiter: exportJobDraft.csv_delimiter, + enabled: exportJobDraft.enabled ? '1' : '', + schedule_kind: exportJobDraft.schedule_kind, + interval_minutes: exportJobDraft.interval_minutes, + time_of_day: exportJobDraft.time_of_day, + weekdays: weekdaysToString(exportJobDraft.weekdays), + month_day: exportJobDraft.month_day, + target_dir: exportJobDraft.target_dir, + filename_pattern: exportJobDraft.filename_pattern, + retention_days: exportJobDraft.retention_days, + }) + + await loadExportData(exportProjectId) + resetExportJobDraft() + } catch (err) { + setError(err.message) + } finally { + setSavingExportJob(false) + } + } + + async function deleteExportJob(job) { + if (!job?.id) return + setError('') + + try { + await apiPost('csv_export_job_delete', { job_id: job.id }) + await loadExportData(exportProjectId) + if (Number(exportJobDraft.id) === Number(job.id)) { + resetExportJobDraft() + } + } catch (err) { + setError(err.message) + } + } + + async function deleteExportFile(file) { + if (!file?.id) return + setError('') + + try { + await apiPost('csv_export_file_delete', { file_id: file.id }) + await loadExportData(exportProjectId) + } catch (err) { + setError(err.message) + } + } + + function toggleWeekday(day) { + setExportJobDraft((current) => ({ + ...current, + weekdays: current.weekdays.includes(day) + ? current.weekdays.filter((item) => item !== day) + : [...current.weekdays, day], + })) + } + + return ( +
+
+
+
+
CSV Exporte
+

Zyklische Exportjobs

+

Eigene Seite fuer Scheduler, CSV Pool und wiederkehrende Dateiexporte.

+
+ +
+ + {error &&
{error}
} + +
+
+
+
+
Scheduler
+

Exportjobs

+

Projekt waehlen, gespeicherte Selection zuweisen und den Laufplan hinterlegen.

+
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ {loadingExports &&
Exportjobs werden geladen...
} + {exportJobs.map((job) => ( +
+
+ {job.name} + {job.selection_name || `Selection #${job.selection_id}`} - {exportStatusLabel(job.last_status)} - Next: {formatDateTime(job.next_run_at)} +
+
+ + +
+
+ ))} + {!loadingExports && exportJobs.length === 0 &&
Noch keine Exportjobs fuer dieses Projekt vorhanden.
} +
+ +
+
+
+ + setExportJobDraft((current) => ({ ...current, name: e.target.value }))} placeholder="z.B. Tagesexport HKL" /> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + setExportJobDraft((current) => ({ ...current, interval_minutes: Number(e.target.value || 60) }))} + /> +
+
+ + setExportJobDraft((current) => ({ ...current, time_of_day: e.target.value }))} + /> +
+
+ + setExportJobDraft((current) => ({ ...current, month_day: Number(e.target.value || 1) }))} + /> +
+
+ + {exportJobDraft.schedule_kind === 'weekly' && ( +
+ {WEEKDAY_OPTIONS.map((option) => ( + + ))} +
+ )} + + + +
+ Status: {exportStatusLabel(exportJobDraft.last_status)} + Naechster Lauf: {formatDateTime(exportJobDraft.next_run_at)} + {exportJobDraft.last_error ? Letzter Fehler: {exportJobDraft.last_error} : null} +
+ +
+ + +
+
+
+ +
+
+
+
CSV Pool
+

Exportdateien

+

Vorhandene CSV-Dateien werden hier 30 Tage vorgehalten und danach automatisch geloescht.

+
+
+ +
+ {loadingExports &&
Dateipool wird geladen...
} + {exportFiles.map((file) => ( +
+
+ {file.file_name} + {exportStatusLabel(file.status)} - {formatDateTime(file.created_at)} - {formatBytes(file.file_size)} - {file.row_count || 0} Zeilen +
+
+ {file.status === 'success' && ( + Download + )} + +
+
+ ))} + {!loadingExports && exportFiles.length === 0 &&
Noch keine Exportdateien im Pool.
} +
+
+
+
+
+ ) +} diff --git a/visu-src/src/pages/Designer.jsx b/visu-src/src/pages/Designer.jsx new file mode 100644 index 0000000..b41802d --- /dev/null +++ b/visu-src/src/pages/Designer.jsx @@ -0,0 +1,5939 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { api, apiPost, apiFormData } from '../lib/visuApi' +import ShapeGraphic from '../components/ShapeGraphic' +import TextGraphic from '../components/TextGraphic' +import TrendChartGraphic from '../components/TrendChartGraphic' +import ValueInstrumentGraphic from '../components/ValueInstrumentGraphic' +import SankeyGraphic from '../components/SankeyGraphic' +import SankeyEditor from '../components/SankeyEditor' +import ViewerShortcutButton from '../components/ViewerShortcutButton' +import { refreshTemplateInstances } from '../lib/templateInstances' +import { + ASSET_TYPE_LABELS, + ASSET_TYPE_ICONS, + DIN_COLORS, + FONT_OPTIONS, + TOOL_PRESETS, + cloneElementForPaste, + compareRule, + formatLiveValue, + hexToRgb, + nextTempId, + groupPagesByProject, + livePointFromRef, + normalizeAsset, + normalizeElement, + normalizePage, + resolveElementVisibility, + resolveTemplateRef, + rgbToHex, + snapValue, + sourceKeyFromRef, + valueCardBackground, +} from '../lib/visuUtils' + +let cachedLinkPages = null +let cachedLinkPagesPromise = null + +function ProjectValueSelect({ projectId, value, onChange, onlyWritable = false }) { + const [values, setValues] = useState([]) + const [q, setQ] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!projectId) { + setValues([]) + return + } + + setLoading(true) + setError('') + + api('project_values', { project_id: projectId }) + .then((data) => setValues(data.values || [])) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, [projectId]) + + const filtered = values.filter((v) => { + if (onlyWritable && !v.writable) return false + + const hay = [ + v.type, + v.external_id, + v.isp_number, + v.label, + v.point_key, + v.variable_id, + v.value_formatted, + v.value, + ].filter(Boolean).join(' ').toLowerCase() + + return hay.includes(q.toLowerCase()) + }) + + function handleChange(sourceKey) { + const point = values.find((v) => v.source_key === sourceKey) + onChange(point || null) + } + + return ( +
+ + + setQ(e.target.value)} + placeholder="z.B. Temperatur, Druck, Sollwert…" + className="form-input" + /> + + + + {loading &&
Werte werden geladen…
} + {error &&
{error}
} +
+ ) +} + +function ColorEditor({ label, value, onChange }) { + const rgb = hexToRgb(value) + const pickerValue = /^#[0-9a-fA-F]{6}$/.test(value || '') ? value : '#000000' + + function applyDin(e) { + const picked = DIN_COLORS.find((c) => c.key === e.target.value) + if (picked?.value) onChange(picked.value) + } + + return ( +
+ + +
+ onChange(e.target.value)} /> + + onChange(e.target.value)} + placeholder="#ffffff" + /> +
+ +
+ onChange(rgbToHex(e.target.value, rgb.g, rgb.b))} /> + onChange(rgbToHex(rgb.r, e.target.value, rgb.b))} /> + onChange(rgbToHex(rgb.r, rgb.g, e.target.value))} /> +
+ + +
+ ) +} + +function ColorPairEditor({ strokeColor, fillColor, onStroke, onFill }) { + return ( +
+ + +
+ + +
+ + +
+ ) +} + +function SensorColorEditor({ style, updateStyle, disabled = false }) { + const strokeColor = style.strokeColor || style.color || '#60a5fa' + const markerColor = style.markerColor || style.color || '#f59e0b' + const fillColor = style.fillColor || 'transparent' + + return ( +
+ updateStyle('strokeColor', value)} /> + +
+ + + + + + +
+ + updateStyle('markerColor', value)} /> + updateStyle('fillColor', value)} /> +
+ ) +} + +function PageSettingsPanel({ page, registerSave, onSaved, onDirty, onPatch, onSaveAll, disabled = false }) { + const formRef = useRef(null) + + const [backgroundColor, setBackgroundColor] = useState(page?.background_color || '#0f172a') + const [gridEnabled, setGridEnabled] = useState(Boolean(page?.grid_enabled)) + const [snapEnabled, setSnapEnabled] = useState(Boolean(page?.snap_enabled)) + const [gridSize, setGridSize] = useState(Number(page?.grid_size || 40)) + const [width, setWidth] = useState(Number(page?.width || 1920)) + const [height, setHeight] = useState(Number(page?.height || 1080)) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + setBackgroundColor(page?.background_color || '#0f172a') + setGridEnabled(Boolean(page?.grid_enabled)) + setSnapEnabled(Boolean(page?.snap_enabled)) + setGridSize(Number(page?.grid_size || 40)) + setWidth(Number(page?.width || 1920)) + setHeight(Number(page?.height || 1080)) + }, [page?.id, page?.background_color, page?.grid_enabled, page?.snap_enabled, page?.grid_size, page?.width, page?.height]) + + async function saveSettings({ silent = false } = {}) { + if (!page?.id || !formRef.current) return true + + setError('') + setSaving(true) + + const fd = new FormData(formRef.current) + fd.append('action', page.is_template ? 'template_settings_save' : 'page_settings_save') + fd.append(page.is_template ? 'template_id' : 'page_id', page.id) + if (page.is_template) { + fd.set('name', page.name || 'Template') + fd.set('description', page.description || '') + fd.set('placeholders_json', JSON.stringify(page.template_placeholders || [])) + } + fd.set('background_color', backgroundColor) + fd.set('grid_enabled', gridEnabled ? '1' : '') + fd.set('snap_enabled', snapEnabled ? '1' : '') + fd.set('grid_size', String(gridSize)) + fd.set('width', String(width)) + fd.set('height', String(height)) + + try { + await apiFormData(fd) + if (!silent) await onSaved?.() + return { + background_color: backgroundColor, + grid_enabled: gridEnabled, + snap_enabled: snapEnabled, + grid_size: gridSize, + width, + height, + } + } catch (err) { + setError(err.message) + return false + } finally { + setSaving(false) + } + } + + useEffect(() => { + registerSave?.(() => saveSettings({ silent: true })) + }, [page?.id, backgroundColor, gridEnabled, snapEnabled, gridSize, width, height]) + + async function submit(e) { + e.preventDefault() + + if (onSaveAll) { + await onSaveAll() + return + } + + const ok = await saveSettings({ silent: false }) + if (ok) await onSaved?.() + } + + function updateSetting(setter, key, value) { + setter(value) + onPatch?.({ [key]: value }) + onDirty?.() + } + + return ( +
+ {'Zeichenfl\u00e4che'} +
+ +
+
+ + updateSetting(setWidth, 'width', Number(e.target.value || 1920))} /> +
+ +
+ + updateSetting(setHeight, 'height', Number(e.target.value || 1080))} /> +
+ +
+ + updateSetting(setBackgroundColor, 'background_color', e.target.value)} /> +
+ +
+ + updateSetting(setGridSize, 'grid_size', Number(e.target.value || 40))} /> +
+ + + + +
+ +
+ + onDirty?.()} /> +
+ + {page?.background_image && ( + + )} + + + + {error &&
{error}
} +
+
+ ) +} + +function ImageLibraryPicker({ open, projectId, value, onSelect, onClose }) { + const [images, setImages] = useState([]) + const [loading, setLoading] = useState(false) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState('') + + async function loadImages() { + setLoading(true) + setError('') + + try { + const data = await api('image_library', { project_id: projectId || '' }) + setImages(data.images || []) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (open) loadImages() + }, [open, projectId]) + + async function uploadFiles(fileList) { + const files = Array.from(fileList || []) + if (!files.length) return + + const fd = new FormData() + fd.append('action', 'image_library_upload') + fd.append('project_id', projectId || '') + files.forEach((file) => fd.append('images[]', file)) + + setUploading(true) + setError('') + + try { + await apiFormData(fd) + await loadImages() + } catch (err) { + setError(err.message) + } finally { + setUploading(false) + } + } + + async function deleteImage(image) { + if (!image?.id || Number(image.usage_count || 0) > 0) return + + try { + await apiPost('image_library_delete', { image_id: image.id }) + await loadImages() + } catch (err) { + setError(err.message) + } + } + + if (!open) return null + + return createPortal( +
{ if (e.target === e.currentTarget) onClose?.() }}> +
+
+
+

Bilderbibliothek

+

Bild auswählen

+
+ +
+ +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault() + uploadFiles(e.dataTransfer.files) + }} + > + {uploading ? 'Upload laeuft...' : 'Bilder hier ablegen'} + PNG, JPG, WEBP oder SVG. Mehrere Dateien sind erlaubt. + uploadFiles(e.target.files)} + /> +
+ + {error &&
{error}
} + {loading &&
Bilder werden geladen...
} + +
+ {images.map((image) => { + const used = Number(image.usage_count || 0) > 0 + const selected = value && value === image.file_url + + return ( +
+ +
+ {image.original_name || 'Bild'} + {used ? `${image.usage_count}x verwendet` : 'Nicht verwendet'} +
+ +
+ ) + })} +
+
+
, + document.body, + ) +} + +function AssetStateForm({ assetId, projectId, state, isFirstState = false, onDone }) { + const [label, setLabel] = useState('') + const [stateKey, setStateKey] = useState('') + const [sortOrder, setSortOrder] = useState(100) + const [isDefault, setIsDefault] = useState(false) + const [libraryOpen, setLibraryOpen] = useState(false) + const [libraryImage, setLibraryImage] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (state) { + setLabel(state.label || '') + setStateKey(state.state_key || '') + setSortOrder(state.sort_order || 100) + setIsDefault(Boolean(state.is_default)) + } else { + setLabel('') + setStateKey('') + setSortOrder(100) + setIsDefault(isFirstState) + } + + setLibraryImage('') + setError('') + }, [state, isFirstState]) + + async function submit(e) { + e.preventDefault() + setError('') + setSaving(true) + + const form = e.currentTarget + const fd = new FormData(form) + + fd.append('action', state ? 'asset_state_update' : 'asset_state_create') + fd.append('asset_id', assetId) + + if (state) fd.append('state_id', state.id) + + try { + await apiFormData(fd) + form.reset() + setLibraryImage('') + if (!state) { + setLabel('') + setStateKey('') + setSortOrder(100) + setIsDefault(false) + } + await onDone?.({ created: !state }) + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + return ( +
+
{state ? 'Zustand bearbeiten' : 'Zustand hinzufügen'}
+ + setLabel(e.target.value)} placeholder="Name" required /> + setStateKey(e.target.value)} placeholder="Key optional" /> + +
+ setSortOrder(Number(e.target.value || 100))} /> + + +
+ + {isFirstState &&
Der erste Zustand wird automatisch als Default verwendet.
} + + + +
+ {(libraryImage || state?.image_file) && ( +
+ +
+ )} + +
+ + {libraryImage && ( + + )} +
+
+ + +
Optional. Ohne Bild kann der Zustand trotzdem für Regeln verwendet werden. Neue Uploads landen automatisch in der Bibliothek.
+ + + + {error &&
{error}
} + + setLibraryOpen(false)} + /> + + ) +} + +function AssetStatesEditor({ asset, onDone }) { + const [editingState, setEditingState] = useState(null) + const [notice, setNotice] = useState('') + + useEffect(() => { + if (!notice) return undefined + const timer = window.setTimeout(() => setNotice(''), 3200) + return () => window.clearTimeout(timer) + }, [notice]) + + async function deleteState(stateId) { + if (!confirm('Zustand wirklich löschen?')) return + + try { + await apiPost('asset_state_delete', { state_id: stateId }) + await onDone?.() + } catch (err) { + alert(err.message) + } + } + + return ( +
+
Zustände
+ + {notice &&
{notice}
} + +
+ {(asset.states || []).map((state) => ( +
+
+ {state.image_file ? : Ohne Bild} +
+ +
+ {state.label} + {state.is_default ? 'Default · ' : ''}{state.state_key} +
+ + + +
+ ))} +
+ + { + setEditingState(null) + if (created) setNotice('Zustand eingefügt. Er kann später jederzeit bearbeitet werden.') + await onDone?.() + }} + /> +
+ ) +} + +function AssetCreateModal({ open, projectId, editing, onClose, onDone }) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [assetType, setAssetType] = useState('pump') + const [isGlobal, setIsGlobal] = useState(true) + const [savedAsset, setSavedAsset] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!open) return + + if (editing) { + setName(editing.name || '') + setDescription(editing.description || '') + setAssetType(editing.asset_type || 'custom') + setIsGlobal(Boolean(editing.is_global)) + setSavedAsset(editing) + } else { + setName('') + setDescription('') + setAssetType('pump') + setIsGlobal(true) + setSavedAsset(null) + } + + setError('') + }, [open, editing]) + + if (!open) return null + + async function saveAsset(e) { + e.preventDefault() + setError('') + setSaving(true) + + try { + const data = await apiPost(editing ? 'asset_update' : 'asset_create', { + asset_id: editing?.id || '', + project_id: projectId || '', + name, + description, + asset_type: assetType, + is_global: isGlobal ? '1' : '0', + }) + + const nextAsset = { + ...(editing || {}), + id: editing?.id || data.asset_id, + name, + description, + asset_type: assetType, + is_global: isGlobal, + states: editing?.states || [], + } + + setSavedAsset(nextAsset) + await onDone?.() + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()}> +
+
+
Symbolverwaltung
+

{editing ? 'Symbol bearbeiten' : 'Symbol erstellen'}

+
+ + +
+ +
+
+
+ + setName(e.target.value)} placeholder="z.B. Pumpe Grundfos" required /> +
+ +
+ + +
+ + + +
+ + setDescription(e.target.value)} placeholder="Optional" /> +
+
+ +
+ + +
+ + {error &&
{error}
} +
+ + {savedAsset && ( + { + const nextAssets = await onDone?.() + const refreshed = Array.isArray(nextAssets) + ? nextAssets.find((asset) => Number(asset.id) === Number(savedAsset.id)) + : null + if (refreshed) setSavedAsset(refreshed) + }} + /> + )} +
+
+ ) +} + +function AssetManager({ projectId, onPickAsset }) { + const [assets, setAssets] = useState([]) + const [q, setQ] = useState('') + const [error, setError] = useState('') + const [modalOpen, setModalOpen] = useState(false) + const [editingAsset, setEditingAsset] = useState(null) + const [assetMenu, setAssetMenu] = useState(null) + + async function loadAssets() { + setError('') + + try { + const data = await api('assets', { project_id: projectId || '' }) + const nextAssets = (data.assets || []).map(normalizeAsset) + setAssets(nextAssets) + return nextAssets + } catch (err) { + setError(err.message) + setAssets([]) + return [] + } + } + + useEffect(() => { + loadAssets() + }, [projectId]) + + useEffect(() => { + if (!assetMenu) return undefined + + function closeAssetMenu() { + setAssetMenu(null) + } + + window.addEventListener('pointerdown', closeAssetMenu) + window.addEventListener('scroll', closeAssetMenu, true) + + return () => { + window.removeEventListener('pointerdown', closeAssetMenu) + window.removeEventListener('scroll', closeAssetMenu, true) + } + }, [assetMenu]) + + const filtered = assets.filter((a) => { + const hay = [a.name, a.asset_type, a.description].filter(Boolean).join(' ').toLowerCase() + return hay.includes(q.toLowerCase()) + }) + + const grouped = filtered.reduce((acc, asset) => { + const key = asset.asset_type || 'custom' + if (!acc[key]) acc[key] = [] + acc[key].push(asset) + return acc + }, {}) + + const groupKeys = Object.keys(grouped).sort((a, b) => { + const la = ASSET_TYPE_LABELS[a] || a + const lb = ASSET_TYPE_LABELS[b] || b + return la.localeCompare(lb, 'de') + }) + + async function deleteAsset(assetId) { + if (!confirm('Symbol wirklich löschen? Alle Zustände werden ebenfalls gelöscht.')) return + + try { + await apiPost('asset_delete', { asset_id: assetId }) + await loadAssets() + setAssetMenu(null) + } catch (err) { + alert(err.message) + } + } + + function openAssetMenu(e, asset) { + e.preventDefault() + e.stopPropagation() + const position = clampContextMenuPosition(e.clientX, e.clientY, { itemCount: 2 }) + setAssetMenu({ + x: position.x, + y: position.y, + asset, + }) + } + + return ( +
+
+
+ Symbole + gruppiert nach Art +
+ +
+ setQ(e.target.value)} + placeholder="Symbol suchen…" + /> + + +
+
+ + {error &&
{error}
} + +
+ {groupKeys.map((groupKey) => ( +
+
+ {ASSET_TYPE_LABELS[groupKey] || groupKey} + {grouped[groupKey].length} +
+ +
+ {grouped[groupKey].map((asset) => { + const preview = asset.states.find((s) => s.is_default)?.image_file || asset.states[0]?.image_file || '' + + return ( +
openAssetMenu(e, asset)} + > + +
+ ) + })} +
+
+ ))} +
+ + {assetMenu && createPortal( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + + +
, + document.body, + )} + + { + setModalOpen(false) + setEditingAsset(null) + }} + onDone={loadAssets} + /> +
+ ) +} + +function AttachedValues({ element, liveValues }) { + const values = element.style_json?.attachedValues || [] + const style = element.style_json || {} + if (!Array.isArray(values) || values.length === 0) return null + + return ( + <> + {values.slice(0, 3).map((item, index) => { + const point = livePointFromRef(resolveTemplateRef(item, style), liveValues) + const txt = formatLiveValue(point, item.decimals ?? 1) + const fontSize = Number(item.fontSize || 13) + + return ( +
+ + {item.label || point?.raw_label || `Wert ${index + 1}`} + + {txt} +
+ ) + })} + + ) +} + +function resolveAssetImage(element, liveValues, assets = []) { + const style = element.style_json || {} + const rules = [...(element.rules || [])].sort((a, b) => Number(a.priority || 100) - Number(b.priority || 100)) + + for (const rule of rules) { + const resolvedRule = resolveTemplateRef(rule, style) + const point = livePointFromRef(resolvedRule, liveValues) + + if (point && compareRule(resolvedRule, point)) { + return { image: resolvedRule.image_file || '', hasState: true } + } + } + + const elementDefaultState = + element.asset_states?.find((s) => s.is_default) || + element.asset_states?.[0] || + null + + if (elementDefaultState) { + return { image: elementDefaultState.image_file || '', hasState: true } + } + + const asset = assets.find((a) => Number(a.id) === Number(element.asset_id)) + const assetDefaultState = + asset?.states?.find((s) => s.is_default) || + asset?.states?.[0] || + null + + if (assetDefaultState) { + return { image: assetDefaultState.image_file || '', hasState: true } + } + + if (element.style_json?.default_image) { + return { image: element.style_json.default_image, hasState: true } + } + + return { image: '', hasState: false } +} + +function ResizeHandles({ onResizeStart }) { + const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] + + return ( + <> + {handles.map((handle) => ( + onResizeStart(e, handle)} /> + ))} + + ) +} + +function ElementView({ + element, + selected, + liveValues = {}, + assets = [], + pointEditMode = false, + lockedAsTemplate = false, + onPointerDown, + onDoubleClick, + onResizeStart, + onPointPointerDown, + onContextMenu, +}) { + const style = element.style_json || {} + const color = style.color || '#60a5fa' + const transform = `translate(${element.x}px, ${element.y}px) rotate(${element.rotation || 0}deg)` + + if (!resolveElementVisibility(element, liveValues)) return null + + return ( +
onPointerDown(e, element.id)} + onDoubleClick={(e) => { + e.preventDefault() + e.stopPropagation() + onDoubleClick?.(e, element.id) + }} + onContextMenu={(e) => onContextMenu?.(e, element.id)} + > + {element.element_type === 'asset' && ( + <> + {(() => { + const assetRender = resolveAssetImage(element, liveValues, assets) + + if (assetRender.image) { + return {element.label + } + + return assetRender.hasState ? null :
Kein Zustand
+ })()} + + + + )} + + {element.element_type === 'text' && ( + + )} + + {element.element_type === 'value' && style.kind === 'trend_chart' && ( + + )} + + {element.element_type === 'value' && style.kind === 'sankey_chart' && ( + + )} + + {element.element_type === 'value' && ['scale_bar', 'dial_gauge'].includes(style.kind) && ( + + )} + + {element.element_type === 'value' && !['scale_bar', 'dial_gauge', 'trend_chart', 'sankey_chart'].includes(style.kind) && ( +
+
+ {style.marker || (style.kind === 'setpoint' ? 'SP' : 'T')} +
+ +
+
{element.label || (style.kind === 'setpoint' ? 'Sollwert' : 'Fühler')}
+
+ {formatLiveValue({ value: 23.4 }, style.decimals ?? 1)} +
+
+
+ )} + + {element.element_type === 'shape' && ( + + )} + + {selected && onResizeStart && !pointEditMode && !lockedAsTemplate && ( + onResizeStart(e, element.id, handle)} /> + )} +
+ ) +} + +function ContextMenu({ + menu, + onClose, + onCopy, + onPaste, + onDelete, + onEditPoints, + onEditTemplateBindings, + onBringFront, + onSendBack, + onZPlus, + onZMinus, +}) { + if (!menu) return null + + return ( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + {menu.elementId && ( + <> + + + + + + + + {menu.isTemplateGroup && ( + + )} + + {menu.canEditPoints && !menu.isTemplateGroup && ( + + )} + + + + )} + + {!menu.elementId && ( + + )} +
+ ) +} + +function StateRuleEditor({ page, selected, assets, onChange }) { + if (!selected || selected.element_type !== 'asset') return null + + const asset = assets.find((a) => Number(a.id) === Number(selected.asset_id)) + const statesById = new Map() + ;[...(selected.asset_states || []), ...(asset?.states || [])].forEach((state) => { + if (state?.id) statesById.set(Number(state.id), state) + }) + const states = Array.from(statesById.values()) + .sort((a, b) => Number(b.is_default || 0) - Number(a.is_default || 0) || Number(a.sort_order || 100) - Number(b.sort_order || 100)) + + function updateRule(index, patch) { + const nextRules = [...(selected.rules || [])] + nextRules[index] = { ...nextRules[index], ...patch } + onChange({ ...selected, rules: nextRules }) + } + + function addRule() { + const state = states[0] + + if (!state) { + alert('Das Symbol hat noch keine Zustände.') + return + } + + onChange({ + ...selected, + rules: [ + ...(selected.rules || []), + { + asset_state_id: state.id, + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + operator: 'eq', + compare_value: '1', + priority: 100, + image_file: state.image_file, + state_label: state.label, + }, + ], + }) + } + + function deleteRule(index) { + const nextRules = [...(selected.rules || [])] + nextRules.splice(index, 1) + onChange({ ...selected, rules: nextRules }) + } + + return ( +
+
Zustandsregeln
+ + {(selected.rules || []).map((rule, index) => ( +
+ + + { + if (!point) { + updateRule(index, { + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + }) + return + } + + updateRule(index, { + variable_type: point.type, + external_id: point.external_id, + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || '', + }) + }} + /> + +
+ + + updateRule(index, { compare_value: e.target.value })} placeholder="Wert" /> + updateRule(index, { priority: Number(e.target.value || 100) })} /> +
+ +
+ {rule.point_label || 'Kein Wert gewählt'} + +
+
+ ))} + + +
+ ) +} + + +function ShapeColorRuleEditor({ page, selected, onChange, disabled = false }) { + if (!selected || selected.element_type !== 'shape') return null + + const style = selected.style_json || {} + const colorRules = Array.isArray(style.colorRules) ? style.colorRules : [] + + function setRules(nextRules) { + onChange({ + ...selected, + style_json: { + ...style, + colorRules: nextRules, + }, + }) + } + + function updateRule(index, patch) { + const nextRules = [...colorRules] + nextRules[index] = { ...nextRules[index], ...patch } + setRules(nextRules) + } + + function addRule() { + setRules([ + ...colorRules, + { + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + operator: 'eq', + compare_value: '1', + priority: 100, + fillColor: style.fillColor || '#60a5fa', + gradientColor: style.gradientColor || style.fillColor || '#1d4ed8', + }, + ]) + } + + function deleteRule(index) { + const nextRules = [...colorRules] + nextRules.splice(index, 1) + setRules(nextRules) + } + + return ( +
+
Farbregeln
+
Die erste passende Regel ersetzt die Füllfarbe.
+ + {colorRules.map((rule, index) => ( +
+ updateRule(index, { fillColor: value })} + /> + + {(style.fillMode === 'linear' || style.fillMode === 'radial') && ( + updateRule(index, { gradientColor: value })} + /> + )} + + { + if (!point) { + updateRule(index, { + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + }) + return + } + + updateRule(index, { + variable_type: point.type, + external_id: point.external_id, + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || '', + }) + }} + /> + +
+ + + updateRule(index, { compare_value: e.target.value })} placeholder="Wert" /> + updateRule(index, { priority: Number(e.target.value || 100) })} /> +
+ +
+ {rule.point_label || 'Kein Wert gewählt'} + +
+
+ ))} + + +
+ ) +} + +function clampContextMenuPosition(x, y, options = {}) { + const width = 190 + const itemCount = Number(options.itemCount || 0) || (options.elementId + ? 6 + (options.canEditPoints ? 1 : 0) + : 1) + const height = 14 + itemCount * 30 + const margin = 8 + let nextX = x + let nextY = y + + if (nextX + width + margin > window.innerWidth) { + nextX = x - width + } + + if (nextY + height + margin > window.innerHeight) { + nextY = y - height + } + + return { + x: Math.max(margin, Math.min(nextX, window.innerWidth - width - margin)), + y: Math.max(margin, Math.min(nextY, window.innerHeight - height - margin)), + } +} + +function createMenuItem(label = 'Link') { + return { + id: `menu-${Math.abs(nextTempId())}`, + label, + linkMode: 'page', + linkPageId: '', + linkUrl: '', + linkTarget: 'same_tab', + } +} + +function loadLinkPages() { + if (cachedLinkPages) return Promise.resolve(cachedLinkPages) + if (cachedLinkPagesPromise) return cachedLinkPagesPromise + + cachedLinkPagesPromise = api('pages') + .then((data) => { + cachedLinkPages = data.pages || [] + return cachedLinkPages + }) + .finally(() => { + cachedLinkPagesPromise = null + }) + + return cachedLinkPagesPromise +} + +function normalizeMenuItems(items) { + if (!Array.isArray(items)) return [] + return items.map((item, index) => ({ + id: item.id || `menu-${index + 1}`, + label: item.label || `Link ${index + 1}`, + linkMode: item.linkMode || 'page', + linkPageId: item.linkPageId || '', + linkUrl: item.linkUrl || '', + linkTarget: item.linkTarget || 'same_tab', + })) +} + +function PageLinkSelect({ value, onChange, disabled = false }) { + const [pages, setPages] = useState([]) + const [error, setError] = useState('') + + useEffect(() => { + loadLinkPages() + .then((data) => setPages(data || [])) + .catch((err) => setError(err.message)) + }, []) + + const groups = groupPagesByProject(pages) + + return ( +
+ + + {error &&
{error}
} +
+ ) +} + +function LinkTargetFields({ value, onChange, disabled = false }) { + const linkMode = value.linkMode || 'page' + const linkTarget = value.linkTarget || 'same_tab' + + function patch(nextPatch) { + onChange({ + ...value, + ...nextPatch, + }) + } + + return ( + <> +
+ + +
+ + {linkMode === 'page' && ( + patch({ linkPageId: nextValue })} + disabled={disabled} + /> + )} + + {linkMode === 'external' && ( +
+ + patch({ linkUrl: e.target.value })} + placeholder="https://example.com" + /> +
+ )} + + {linkMode !== 'none' && ( +
+ + +
+ )} + + ) +} + +function VisibilityRuleEditor({ page, selected, onChange, disabled = false }) { + if (!selected || !['asset', 'shape', 'text'].includes(selected.element_type)) return null + + const style = selected.style_json || {} + const rule = style.visibilityRule || {} + const enabled = Boolean(rule.enabled) + + function patch(nextPatch) { + onChange({ + ...selected, + style_json: { + ...style, + visibilityRule: { + enabled: true, + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + operator: 'eq', + compare_value: '1', + ...rule, + ...nextPatch, + }, + }, + }) + } + + function setEnabled(nextEnabled) { + if (nextEnabled) { + patch({ enabled: true }) + return + } + + onChange({ + ...selected, + style_json: { + ...style, + visibilityRule: { + ...rule, + enabled: false, + }, + }, + }) + } + + return ( +
+ + + {enabled && ( +
+ { + if (!point) { + patch({ + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + }) + return + } + + patch({ + variable_type: point.type, + external_id: point.external_id, + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || '', + }) + }} + /> + +
+ + + patch({ compare_value: e.target.value })} + placeholder="Wert" + /> + + patch({ priority: Number(e.target.value || 100) })} + title="Priorität" + /> +
+ +
Wenn diese Bedingung zutrifft, wird das Element im Viewer ausgeblendet.
+
+ )} +
+ ) +} + +function TextRuleEditor({ page, selected, onChange, disabled = false }) { + if (!selected || selected.element_type !== 'text') return null + + const style = selected.style_json || {} + const textRules = Array.isArray(style.textRules) ? style.textRules : [] + + function setRules(nextRules) { + onChange({ + ...selected, + style_json: { + ...style, + textRules: nextRules, + }, + }) + } + + function updateRule(index, patch) { + const nextRules = [...textRules] + nextRules[index] = { ...nextRules[index], ...patch } + setRules(nextRules) + } + + function addRule() { + setRules([ + ...textRules, + { + text: selected.label || 'Text', + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + operator: 'eq', + compare_value: '1', + priority: 100, + }, + ]) + } + + function deleteRule(index) { + const nextRules = [...textRules] + nextRules.splice(index, 1) + setRules(nextRules) + } + + return ( +
+
Textzustände
+
Die erste passende Regel ersetzt den Standardtext.
+ + {textRules.map((rule, index) => ( +
+
+ + updateRule(index, { text: e.target.value })} /> +
+ + { + if (!point) { + updateRule(index, { + variable_type: 'digital', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + }) + return + } + + updateRule(index, { + variable_type: point.type, + external_id: point.external_id, + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || '', + }) + }} + /> + +
+ + + updateRule(index, { compare_value: e.target.value })} placeholder="Wert" /> + updateRule(index, { priority: Number(e.target.value || 100) })} /> +
+ +
+ {rule.point_label || 'Kein Wert gewählt'} + +
+
+ ))} + + +
+ ) +} + +const GRADIENT_ORIGIN_OPTIONS = [ + { key: 'top_left', label: 'Links oben' }, + { key: 'top_center', label: 'Oben' }, + { key: 'top_right', label: 'Rechts oben' }, + { key: 'middle_left', label: 'Links' }, + { key: 'center', label: 'Mitte' }, + { key: 'middle_right', label: 'Rechts' }, + { key: 'bottom_left', label: 'Links unten' }, + { key: 'bottom_center', label: 'Unten' }, + { key: 'bottom_right', label: 'Rechts unten' }, +] + +const LINE_ARROW_TYPE_OPTIONS = [ + { value: 'triangle', label: 'Dreieck' }, + { value: 'diamond', label: 'Raute' }, + { value: 'chevron', label: 'Chevron' }, + { value: 'kite', label: 'Kite' }, +] + +function GradientOriginPicker({ value, onChange, disabled = false }) { + return ( +
+ +
+ {GRADIENT_ORIGIN_OPTIONS.map((option) => ( + + ))} +
+
+ ) +} + +function ArrowRotationButtons({ value = 0, onChange, disabled = false }) { + return ( +
+ {[-90, 0, 90].map((angle) => ( + + ))} +
+ ) +} + +function LineFlowEditor({ page, selected, onChange, disabled = false }) { + const style = selected?.style_json || {} + const legacyGap = Number(style.arrowGap ?? 0) + const linkedGaps = style.arrowGapLinked !== false + const startGapValue = Number(style.startArrowGap ?? legacyGap) + const endGapValue = Number(style.endArrowGap ?? legacyGap) + const [startGapDraft, setStartGapDraft] = useState(String(startGapValue)) + const [endGapDraft, setEndGapDraft] = useState(String(endGapValue)) + + useEffect(() => { + setStartGapDraft(String(startGapValue)) + setEndGapDraft(String(endGapValue)) + }, [selected?.id, startGapValue, endGapValue]) + + if (!selected || selected.element_type !== 'shape' || selected.style_json?.subtype !== 'polyline') return null + + function updateStyle(patch) { + onChange({ + ...selected, + style_json: { + ...style, + ...patch, + }, + }) + } + + function applyGapDraft(side, raw) { + if (!/^-?\d*$/.test(raw)) return + + if (side === 'start') setStartGapDraft(raw) + if (side === 'end') setEndGapDraft(raw) + + if (raw === '' || raw === '-') return + + const value = Math.max(-120, Math.min(120, Number(raw))) + + if (linkedGaps) { + setStartGapDraft(String(value)) + setEndGapDraft(String(value)) + updateStyle({ + arrowGapLinked: true, + arrowGap: value, + startArrowGap: value, + endArrowGap: value, + }) + return + } + + updateStyle(side === 'start' + ? { startArrowGap: value } + : { endArrowGap: value }) + } + + function finalizeGapDraft(side) { + const raw = side === 'start' ? startGapDraft : endGapDraft + const fallback = side === 'start' ? startGapValue : endGapValue + + if (raw === '' || raw === '-' || Number.isNaN(Number(raw))) { + if (side === 'start') setStartGapDraft(String(fallback)) + if (side === 'end') setEndGapDraft(String(fallback)) + return + } + + applyGapDraft(side, raw) + } + + function toggleGapLink() { + const nextLinked = !linkedGaps + + if (nextLinked) { + const shared = Math.max(-120, Math.min(120, Number(startGapDraft === '-' || startGapDraft === '' ? startGapValue : startGapDraft))) + setStartGapDraft(String(shared)) + setEndGapDraft(String(shared)) + updateStyle({ + arrowGapLinked: true, + arrowGap: shared, + startArrowGap: shared, + endArrowGap: shared, + }) + return + } + + updateStyle({ + arrowGapLinked: false, + startArrowGap: startGapValue, + endArrowGap: endGapValue, + }) + } + + return ( +
+
Linienfluss und Pfeile
+ +
+
+ + +
+
+ + +
+
+ +
+ + updateStyle({ arrowSize: Math.max(8, Math.min(120, Number(e.target.value || 20))) })} + /> +
+ +
+ +
+ applyGapDraft('start', e.target.value)} + onBlur={() => finalizeGapDraft('start')} + placeholder="-120 bis 120" + /> + + applyGapDraft('end', e.target.value)} + onBlur={() => finalizeGapDraft('end')} + placeholder="-120 bis 120" + /> +
+
+ +
+
+
Pfeil Anfang
+ +
+ + +
+ +
+ + updateStyle({ startArrowRotation: angle })} + /> +
+
+ +
+
Pfeil Ende
+ +
+ + +
+ +
+ + updateStyle({ endArrowRotation: angle })} + /> +
+
+
+ + + + {style.flowEnabled && ( + <> + updateStyle({ + flowPoint: point ? { + type: point.type, + external_id: point.external_id, + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || '', + } : null, + })} + /> + +
+
+ + updateStyle({ flowMin: Number(e.target.value || 0) })} /> +
+
+ + updateStyle({ flowMax: Number(e.target.value || 100) })} /> +
+
+ + updateStyle({ flowSlowSeconds: Number(e.target.value || 5) })} /> +
+
+ + updateStyle({ flowFastSeconds: Number(e.target.value || 0.7) })} /> +
+
+ + updateStyle({ flowColor: value })} /> + + + + + + )} +
+ ) +} + +function AttachedValueEditor({ page, selected, onChange }) { + if (!selected || selected.element_type !== 'asset') return null + + const attached = Array.isArray(selected.style_json?.attachedValues) + ? selected.style_json.attachedValues + : [] + + function setAttached(next) { + onChange({ + ...selected, + style_json: { + ...selected.style_json, + attachedValues: next.slice(0, 3), + }, + }) + } + + function updateItem(index, patch) { + const next = [...attached] + next[index] = { ...next[index], ...patch } + setAttached(next) + } + + function addItem() { + if (attached.length >= 3) { + alert('Maximal 3 Werte pro Symbol.') + return + } + + setAttached([ + ...attached, + { + label: `Wert ${attached.length + 1}`, + x: 50, + y: 105, + fontSize: 13, + decimals: 1, + type: '', + external_id: '', + variable_id: '', + point_key: '', + }, + ]) + } + + function deleteItem(index) { + const next = [...attached] + next.splice(index, 1) + setAttached(next) + } + + return ( +
+
Angeheftete Werte
+ + {attached.map((item, index) => ( +
+ updateItem(index, { label: e.target.value })} + placeholder="Label" + /> + + { + if (!point) { + updateItem(index, { + type: '', + external_id: '', + variable_id: '', + point_key: '', + point_label: '', + }) + return + } + + updateItem(index, { + type: point.type, + external_id: point.external_id, + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || '', + label: item.label || point.raw_label || point.label || `Wert ${index + 1}`, + }) + }} + /> + +
+
+ + updateItem(index, { x: Number(e.target.value || 0) })} + /> +
+ +
+ + updateItem(index, { y: Number(e.target.value || 0) })} + /> +
+ +
+ + updateItem(index, { fontSize: Number(e.target.value || 13) })} + /> +
+ +
+ + updateItem(index, { decimals: Math.max(0, Math.min(8, Number(e.target.value || 0))) })} + /> +
+
+ + +
+ ))} + + +
+ ) +} + +function InstrumentEditor({ selected, onChange, disabled = false }) { + const style = selected?.style_json || {} + + if (!selected || selected.element_type !== 'value' || !['scale_bar', 'dial_gauge'].includes(style.kind)) return null + + const ranges = Array.isArray(style.ranges) ? style.ranges : [] + + function updateStyle(patch) { + onChange({ + ...selected, + style_json: { + ...style, + ...patch, + }, + }) + } + + function updateRange(index, patch) { + const nextRanges = [...ranges] + nextRanges[index] = { ...nextRanges[index], ...patch } + updateStyle({ ranges: nextRanges }) + } + + function addRange() { + updateStyle({ + ranges: [ + ...ranges, + { + from: Number(style.min ?? 0), + to: Number(style.max ?? 100), + color: '#22c55e', + }, + ], + }) + } + + function removeRange(index) { + const nextRanges = [...ranges] + nextRanges.splice(index, 1) + updateStyle({ ranges: nextRanges }) + } + + return ( +
+ {style.kind === 'scale_bar' && ( +
+ + +
+ )} + + + + {style.fillEnabled && ( + updateStyle({ fillColor: value })} /> + )} + +
+
+ + updateStyle({ min: Number(e.target.value || 0) })} /> +
+
+ + updateStyle({ max: Number(e.target.value || 100) })} /> +
+
+ + updateStyle({ majorStep: Math.max(0.001, Number(e.target.value || 20)) })} /> +
+
+ + updateStyle({ minorDivisions: Math.max(0, Number(e.target.value || 0)) })} /> +
+
+ + updateStyle({ decimals: Math.max(0, Math.min(8, Number(e.target.value || 0))) })} /> +
+
+ +
+
+ + +
+
+ + updateStyle({ labelFontSize: Number(e.target.value || 12) })} /> +
+
+ +
+ + updateStyle({ labelOffset: Number(e.target.value) })} /> +
+ +
+ + {style.kind === 'scale_bar' ? ( + + ) : ( + + )} +
+ +
+ updateStyle({ barColor: value })} /> + updateStyle({ needleColor: value })} /> +
+ +
+
Farbbereiche der Skala
+ + {ranges.map((range, index) => ( +
+ updateRange(index, { from: Number(e.target.value || 0) })} aria-label="Von" /> + updateRange(index, { to: Number(e.target.value || 0) })} aria-label="Bis" /> + updateRange(index, { color: e.target.value })} aria-label="Farbe" /> + +
+ ))} + + +
+
+ ) +} + +function trendPointKey(point) { + if (!point) return '' + return [ + 'influx', + point.database || '', + point.measurement || '', + point.tag_key || '', + point.tag_value || '', + ].join('|') +} + +function trendPointMeasurement(point) { + return String(point?.measurement || '').trim() +} + +function TrendChartEditor({ selected, page, onChange, disabled = false }) { + const style = selected?.style_json || {} + const series = Array.isArray(style.series) ? style.series : [] + const [databases, setDatabases] = useState([]) + const [database, setDatabase] = useState(series[0]?.database || '') + const [points, setPoints] = useState([]) + const [q, setQ] = useState('') + const [measurementFilter, setMeasurementFilter] = useState(series[0]?.measurement || '') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + let alive = true + setLoading(true) + setError('') + + api('trend_sources', { database }) + .then((data) => { + if (!alive) return + setDatabases(data.databases || []) + setDatabase((current) => current || data.selected_database || data.databases?.[0] || '') + setPoints(data.points || []) + }) + .catch((err) => { + if (alive) setError(err.message) + }) + .finally(() => { + if (alive) setLoading(false) + }) + + return () => { + alive = false + } + }, [database]) + + useEffect(() => { + if (!measurementFilter) return + + if (!points.some((point) => trendPointMeasurement(point) === measurementFilter)) { + setMeasurementFilter('') + } + }, [measurementFilter, points]) + + if (!selected || selected.element_type !== 'value' || style.kind !== 'trend_chart') return null + + function updateStyle(patch) { + onChange({ + ...selected, + style_json: { + ...style, + ...patch, + }, + }) + } + + function updateSeries(index, patch) { + const next = [...series] + next[index] = { ...next[index], ...patch } + updateStyle({ series: next }) + } + + function addSeries(point = null) { + const p = point || filteredPoints[0] + const palette = ['#38bdf8', '#f97316', '#22c55e', '#a78bfa', '#f43f5e', '#eab308'] + + if (!p) { + updateStyle({ + series: [ + ...series, + { + id: `trend-${Date.now()}`, + label: `Trend ${series.length + 1}`, + color: palette[series.length % palette.length], + lineWidth: Number(style.lineWidth ?? 3), + fillEnabled: false, + fillOpacity: Number(style.fillOpacity ?? 0.18), + }, + ], + }) + return + } + + updateStyle({ + series: [ + ...series, + { + id: `trend-${Date.now()}`, + database: p.database, + measurement: p.measurement, + tag_key: p.tag_key, + tag_value: p.tag_value, + label: p.tag_value || p.label || `Trend ${series.length + 1}`, + color: palette[series.length % palette.length], + lineWidth: Number(style.lineWidth ?? 3), + fillEnabled: false, + fillOpacity: Number(style.fillOpacity ?? 0.18), + }, + ], + }) + } + + function removeSeries(index) { + updateStyle({ series: series.filter((_, itemIndex) => itemIndex !== index) }) + } + + function pickPoint(index, key) { + const point = points.find((item) => trendPointKey(item) === key) + if (!point) return + + updateSeries(index, { + database: point.database, + measurement: point.measurement, + tag_key: point.tag_key, + tag_value: point.tag_value, + label: series[index]?.label || point.tag_value || point.label, + }) + } + + const filteredPoints = points.filter((point) => { + const hay = `${point.measurement} ${point.tag_key} ${point.tag_value} ${point.label}`.toLowerCase() + const matchesSearch = !q.trim() || hay.includes(q.trim().toLowerCase()) + const matchesMeasurement = !measurementFilter || trendPointMeasurement(point) === measurementFilter + return matchesSearch && matchesMeasurement + }) + const measurements = Array.from(new Set(points.map(trendPointMeasurement).filter(Boolean))).sort((a, b) => ( + a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' }) + )) + + return ( +
+
+
+ + +
+ +
+ + setQ(e.target.value)} placeholder="Measurement, Tag, Wert..." /> +
+
+ +
+ + +
+ + {error &&
{error}
} + {loading &&
Influx-Datenpunkte werden geladen...
} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + updateStyle({ refreshSeconds: Math.max(0, Number(e.target.value || 0)) })} /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ + + + + +
+
Datenreihen
+ + {series.map((item, index) => { + const selectedKey = trendPointKey(item) + const selectedKnown = points.some((point) => trendPointKey(point) === selectedKey) + + return ( +
+
+ + +
+ +
+
+ + updateSeries(index, { label: e.target.value })} /> +
+
+ + updateSeries(index, { color: e.target.value })} /> +
+
+ + +
+
+ + updateSeries(index, { unit: e.target.value })} placeholder="z.B. °C, bar, kW" /> +
+
+ + updateSeries(index, { lineWidth: Math.max(1, Number(e.target.value || 1)) })} /> +
+ +
+ + {item.fillEnabled && ( +
+ + updateSeries(index, { fillOpacity: Number(e.target.value) })} /> +
+ )} + +
+ Reihe {index + 1} + +
+
+ ) + })} + + {series.length === 0 &&
Noch keine Datenreihe im Graph.
} + +
+ +
+ updateStyle({ backgroundColor: value })} /> + updateStyle({ borderColor: value })} /> +
+ +
+ +
+ updateStyle({ radius: Number(e.target.value || 0) })} /> + updateStyle({ radius: Number(e.target.value || 0) })} /> +
+
+
+ ) +} + +function PropertySection({ title, children, open = false }) { + return ( +
+ {title} +
{children}
+
+ ) +} + +function PropertiesPanel({ + page, + selected, + assets, + pointEditElementId, + onChange, + onDelete, + onBringFront, + onSendBack, + onZPlus, + onZMinus, + onStartPointEdit, + onFinishPointEdit, + disabled = false, +}) { + if (!selected) { + return ( + + ) + } + + const style = selected.style_json || {} + const isShape = selected.element_type === 'shape' + const isPolyline = isShape && style.subtype === 'polyline' + const isInstrument = selected.element_type === 'value' && ['scale_bar', 'dial_gauge'].includes(style.kind) + const isTrendChart = selected.element_type === 'value' && style.kind === 'trend_chart' + const isSankeyChart = selected.element_type === 'value' && style.kind === 'sankey_chart' + const isPointEditing = pointEditElementId === selected.id + + function updateField(key, value) { + onChange({ ...selected, [key]: value }) + } + + function updateStyle(key, value) { + onChange({ ...selected, style_json: { ...style, [key]: value } }) + } + + function updateStylePatch(patch) { + onChange({ ...selected, style_json: { ...style, ...patch } }) + } + + function changeTextSubtype(nextSubtype) { + const patch = { subtype: nextSubtype } + + if (nextSubtype === 'link') { + patch.textRules = [] + patch.linkMode = style.linkMode || 'page' + patch.linkPageId = style.linkPageId || '' + patch.linkUrl = style.linkUrl || '' + patch.linkTarget = style.linkTarget || 'same_tab' + patch.renderAsButton = Boolean(style.renderAsButton) + patch.buttonShape = style.buttonShape || 'rounded' + patch.buttonRadius = Number(style.buttonRadius ?? 16) + patch.buttonFill = style.buttonFill || 'rgba(37, 99, 235, 0.78)' + patch.buttonBorderColor = style.buttonBorderColor || 'rgba(147, 197, 253, 0.72)' + } + + if (nextSubtype === 'menu') { + patch.menuOrientation = style.menuOrientation || 'vertical' + patch.menuGap = Number(style.menuGap ?? 8) + patch.menuButtonSize = Number(style.menuButtonSize ?? 42) + patch.renderAsButton = style.renderAsButton ?? true + patch.buttonShape = style.buttonShape || 'rounded' + patch.buttonRadius = Number(style.buttonRadius ?? 16) + patch.buttonFill = style.buttonFill || 'rgba(37, 99, 235, 0.78)' + patch.buttonBorderColor = style.buttonBorderColor || 'rgba(147, 197, 253, 0.72)' + patch.menuItems = normalizeMenuItems(style.menuItems).length + ? normalizeMenuItems(style.menuItems) + : [createMenuItem('Link 1'), createMenuItem('Link 2')] + } + + updateStylePatch(patch) + } + + const textSubtype = style.subtype || 'text' + const menuItems = normalizeMenuItems(style.menuItems) + + function updateMenuItem(index, patch) { + const nextItems = [...menuItems] + nextItems[index] = { + ...nextItems[index], + ...patch, + } + updateStyle('menuItems', nextItems) + } + + function addMenuItem() { + updateStyle('menuItems', [...menuItems, createMenuItem(`Link ${menuItems.length + 1}`)]) + } + + function removeMenuItem(index) { + const nextItems = menuItems.filter((_, itemIndex) => itemIndex !== index) + updateStyle('menuItems', nextItems.length ? nextItems : [createMenuItem('Link 1')]) + } + + function setAspectLocked(locked) { + const ratio = selected.w > 0 && selected.h > 0 ? selected.w / selected.h : 1 + + onChange({ + ...selected, + style_json: { + ...style, + aspectLocked: locked, + aspectRatio: ratio, + }, + }) + } + + function updateWidth(nextW) { + const w = Math.max(1, Number(nextW || 1)) + + if (style.aspectLocked) { + const ratio = Number(style.aspectRatio || selected.w / selected.h || 1) + onChange({ ...selected, w, h: Math.max(1, Math.round(w / ratio)) }) + return + } + + updateField('w', w) + } + + function updateHeight(nextH) { + const h = Math.max(1, Number(nextH || 1)) + + if (style.aspectLocked) { + const ratio = Number(style.aspectRatio || selected.w / selected.h || 1) + onChange({ ...selected, h, w: Math.max(1, Math.round(h * ratio)) }) + return + } + + updateField('h', h) + } + + return ( + + ) +} + +function PlaceholderValueSelect({ placeholders = [], value, onChange }) { + const items = Array.isArray(placeholders) ? placeholders : [] + + function handleChange(sourceKey) { + const key = String(sourceKey || '').split('|').pop() + const placeholder = items.find((item) => item.key === key) + + if (!placeholder) { + onChange(null) + return + } + + onChange({ + type: 'placeholder', + variable_type: 'placeholder', + external_id: '', + variable_id: '', + point_key: placeholder.key, + point_label: placeholder.label || placeholder.key, + label: placeholder.label || placeholder.key, + source_key: `placeholder|||${placeholder.key}`, + }) + } + + return ( +
+ + + {items.length === 0 &&
Noch keine Platzhalter im Template.
} +
+ ) +} + +function TemplatePlaceholderPanel({ page, onChange, onDirty, disabled = false }) { + const [label, setLabel] = useState('') + + if (!page?.is_template) return null + + const placeholders = Array.isArray(page.template_placeholders) ? page.template_placeholders : [] + + function normalizeKey(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + } + + function updatePlaceholders(next) { + onChange({ ...page, template_placeholders: next }) + onDirty?.() + } + + function addPlaceholder() { + const baseLabel = label.trim() + const baseKey = normalizeKey(baseLabel) || `platzhalter_${placeholders.length + 1}` + let key = baseKey + let i = 2 + + while (placeholders.some((item) => item.key === key)) { + key = `${baseKey}_${i}` + i += 1 + } + + updatePlaceholders([...placeholders, { key, label: baseLabel || key, type: 'analog' }]) + setLabel('') + } + + function updatePlaceholder(index, patch) { + updatePlaceholders(placeholders.map((item, itemIndex) => ( + itemIndex === index ? { ...item, ...patch } : item + ))) + } + + function removePlaceholder(index) { + updatePlaceholders(placeholders.filter((_, itemIndex) => itemIndex !== index)) + } + + return ( +
+ Platzhalter +
+
+ + setLabel(e.target.value)} + placeholder="z.B. Vorlauf Temperatur" + /> +
+ + + {placeholders.map((item, index) => ( +
+
+ + updatePlaceholder(index, { label: e.target.value })} + /> +
+
+ + +
+ +
+ ))} +
+
+ ) +} + +function ValueSourceSelect({ page, value, onChange, onlyWritable = false }) { + if (page?.is_template) { + return ( + + ) + } + + return ( + + ) +} + +function staticTemplatePoint(value, key = '') { + const staticValue = String(value ?? '') + + return { + type: 'static', + variable_type: 'static', + external_id: '', + variable_id: '', + point_key: '', + point_label: `Fester Wert ${staticValue}`, + label: `Fester Wert ${staticValue}`, + static_value: staticValue, + value: staticValue, + value_formatted: staticValue, + templatePlaceholderKey: key, + source_key: `static||||${staticValue}`, + } +} + +function TemplateBindingField({ page, placeholderKey, value, onChange }) { + const isStatic = (value?.type || value?.variable_type) === 'static' + const [mode, setMode] = useState(isStatic ? 'static' : 'point') + + useEffect(() => { + setMode(isStatic ? 'static' : 'point') + }, [isStatic]) + + function changeMode(nextMode) { + setMode(nextMode) + if (nextMode === 'static') { + onChange(staticTemplatePoint(value?.static_value ?? value?.value ?? '', placeholderKey)) + } + } + + return ( +
+
+ + +
+ + {mode === 'static' ? ( +
+ + onChange(staticTemplatePoint(e.target.value, placeholderKey))} + placeholder="z.B. 1 oder 0" + /> +
+ ) : ( + + )} +
+ ) +} + +function pointToTemplateMapping(point, templatePlaceholderKey = '') { + if (!point) return null + + if ((point.type || point.variable_type) === 'static') { + const value = point.static_value ?? point.value ?? '' + + return { + type: 'static', + variable_type: 'static', + external_id: '', + variable_id: '', + point_key: '', + point_label: point.point_label || point.label || `Fester Wert ${value}`, + label: point.label || point.point_label || `Fester Wert ${value}`, + static_value: value, + value, + value_formatted: String(value), + templatePlaceholderKey, + } + } + + return { + type: point.type || point.variable_type || '', + variable_type: point.type || point.variable_type || '', + external_id: point.external_id || '', + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.point_label || point.label || '', + label: point.label || point.point_label || '', + templatePlaceholderKey, + } +} + +function normalizeTemplateBindings(mapping = {}) { + const bindings = {} + + Object.entries(mapping || {}).forEach(([key, point]) => { + const normalized = pointToTemplateMapping(point, key) + if (normalized) bindings[key] = normalized + }) + + return bindings +} + +function isPlaceholderRef(value) { + return Boolean( + value + && typeof value === 'object' + && !Array.isArray(value) + && (value.variable_type === 'placeholder' || value.type === 'placeholder') + && value.point_key + ) +} + +function mapTemplatePlaceholders(value, mapping) { + if (Array.isArray(value)) { + return value.map((item) => mapTemplatePlaceholders(item, mapping)) + } + + if (!value || typeof value !== 'object') return value + + const next = {} + Object.entries(value).forEach(([key, item]) => { + next[key] = mapTemplatePlaceholders(item, mapping) + }) + + if (isPlaceholderRef(value)) { + const mapped = pointToTemplateMapping(mapping[value.point_key], value.point_key) + if (mapped) return { ...next, ...mapped } + return { ...next, templatePlaceholderKey: value.point_key } + } + + return next +} + +function mapTemplateBindingRefs(value, mapping) { + if (Array.isArray(value)) return value.map((item) => mapTemplateBindingRefs(item, mapping)) + if (!value || typeof value !== 'object') return value + + const next = {} + Object.entries(value).forEach(([key, item]) => { + next[key] = mapTemplateBindingRefs(item, mapping) + }) + + const placeholderKey = value.templatePlaceholderKey + if (placeholderKey && mapping[placeholderKey]) { + return { + ...next, + ...pointToTemplateMapping(mapping[placeholderKey], placeholderKey), + } + } + + return next +} + +function collectTemplateBindingRefs(value, out = {}) { + if (Array.isArray(value)) { + value.forEach((item) => collectTemplateBindingRefs(item, out)) + return out + } + + if (!value || typeof value !== 'object') return out + + const bindings = value.style_json?.templateBindings + if (bindings && typeof bindings === 'object' && !Array.isArray(bindings)) { + Object.entries(bindings).forEach(([key, point]) => { + if (point && !out[key]) out[key] = pointToTemplateMapping(point, key) + }) + } + + if (value.templatePlaceholderKey && !out[value.templatePlaceholderKey]) { + out[value.templatePlaceholderKey] = pointToTemplateMapping(value, value.templatePlaceholderKey) + } + + Object.values(value).forEach((item) => collectTemplateBindingRefs(item, out)) + return out +} + +function stripTemplateRuntimeMeta(style = {}) { + const { + templateGroupId, + templateSourceId, + templateElementId, + templateName, + templatePlaceholders, + templateBindings, + ...clean + } = style || {} + + return clean +} + +function ensureUniqueTemplateElementIds(items) { + const seen = new Set() + + return (items || []).map((item) => { + const normalized = normalizeElement(item) + let nextId = nextTempId() + while (seen.has(nextId)) nextId = nextTempId() + seen.add(nextId) + + return { + ...normalized, + id: nextId, + style_json: stripTemplateRuntimeMeta(normalized.style_json), + } + }) +} + +function TemplateInsertModal({ open, page, onClose, onInsert }) { + const [templates, setTemplates] = useState([]) + const [selectedTemplateId, setSelectedTemplateId] = useState('') + const [templateData, setTemplateData] = useState(null) + const [mapping, setMapping] = useState({}) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!open) return + + setError('') + setLoading(true) + api('templates') + .then((data) => { + const items = data.templates || [] + setTemplates(items) + setSelectedTemplateId((current) => current || String(items[0]?.id || '')) + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, [open]) + + useEffect(() => { + if (!open || !selectedTemplateId) { + setTemplateData(null) + setMapping({}) + return + } + + setError('') + setLoading(true) + api('template', { id: selectedTemplateId }) + .then((data) => { + setTemplateData(data) + setMapping({}) + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, [open, selectedTemplateId]) + + if (!open) return null + + const placeholders = templateData?.placeholders || [] + const elementsToInsert = templateData?.elements || [] + + function updateMapping(key, point) { + setMapping((old) => ({ + ...old, + [key]: point, + })) + } + + function insert() { + onInsert(elementsToInsert, mapping, { + id: templateData?.template?.id || selectedTemplateId, + name: templateData?.template?.name || 'Template', + placeholders, + }) + onClose() + } + + return createPortal( +
+
e.stopPropagation()}> +
+
+
Template
+

Template laden

+

Platzhalter werden beim Einfuegen mit echten Projektwerten verknuepft.

+
+ +
+ + {error &&
{error}
} + +
+
+ + +
+ + {loading &&
Template wird geladen...
} + + {templateData && ( + <> +
+ {elementsToInsert.length} Element(e), {placeholders.length} Platzhalter +
+ + {placeholders.map((placeholder) => ( +
+
+ {placeholder.label || placeholder.key} +
{placeholder.key}
+
+ updateMapping(placeholder.key, point)} + /> +
+ ))} + + {placeholders.length === 0 && ( +
Dieses Template hat keine Platzhalter.
+ )} + + )} +
+ +
+ + +
+
+
, + document.body + ) +} + +function TemplateBindingModal({ open, page, groupElements = [], onClose, onApply }) { + const meta = groupElements[0]?.style_json || {} + const placeholders = Array.isArray(meta.templatePlaceholders) ? meta.templatePlaceholders : [] + const [mapping, setMapping] = useState({}) + + useEffect(() => { + if (!open) return + + const collected = {} + groupElements.forEach((element) => collectTemplateBindingRefs(element, collected)) + setMapping(collected) + }, [open, groupElements]) + + if (!open) return null + + function updateMapping(key, point) { + setMapping((old) => ({ ...old, [key]: point })) + } + + function apply() { + onApply(mapping) + onClose() + } + + return createPortal( +
+
e.stopPropagation()}> +
+
+
Template
+

Platzhalter ändern

+

{meta.templateName || 'Geladenes Template'}

+
+ +
+ +
+ {placeholders.map((placeholder) => ( +
+
+ {placeholder.label || placeholder.key} +
{placeholder.key}
+
+ updateMapping(placeholder.key, point)} + /> +
+ ))} + + {placeholders.length === 0 && ( +
Dieses Template hat keine gespeicherten Platzhalter-Infos.
+ )} +
+ +
+ + +
+
+
, + document.body + ) +} + +function elementDisplayType(element) { + if (element.element_type === 'asset') return 'Symbol' + if (element.element_type === 'value') { + const kind = element.style_json?.kind + if (kind === 'sensor') return 'Fühler / Wert' + if (kind === 'setpoint') return 'Sollwert' + if (kind === 'scale_bar') return 'Skalenbalken' + if (kind === 'dial_gauge') return 'Zeigerinstrument' + if (kind === 'trend_chart') return 'Trendgraph' + if (kind === 'sankey_chart') return 'Sankey' + return 'Wert' + } + + if (element.element_type === 'text') { + if (element.style_json?.textType === 'link') return 'Link' + if (element.style_json?.textType === 'menu') return 'Menü' + return 'Text' + } + + if (element.element_type === 'shape') { + const subtype = element.style_json?.subtype + if (subtype === 'polyline') return 'Linienzug' + if (subtype === 'polygon') return 'Polygon' + if (subtype === 'circle') return 'Kreis' + if (subtype === 'ellipse') return 'Ellipse' + return 'Rechteck' + } + + return element.element_type || 'Element' +} + +function elementDisplayName(element) { + return element.name || element.label || element.asset_name || elementDisplayType(element) +} + +function boundsForElements(items) { + const safeItems = items.filter(Boolean) + + if (safeItems.length === 0) { + return { x: 0, y: 0, w: 0, h: 0, minZ: 0, maxZ: 0 } + } + + const minX = Math.min(...safeItems.map((item) => Number(item.x || 0))) + const minY = Math.min(...safeItems.map((item) => Number(item.y || 0))) + const maxX = Math.max(...safeItems.map((item) => Number(item.x || 0) + Number(item.w || 0))) + const maxY = Math.max(...safeItems.map((item) => Number(item.y || 0) + Number(item.h || 0))) + const zValues = safeItems.map((item) => Number(item.z_index || 10)) + + return { + x: minX, + y: minY, + w: Math.max(1, maxX - minX), + h: Math.max(1, maxY - minY), + minZ: Math.min(...zValues), + maxZ: Math.max(...zValues), + } +} + +function elementListEntries(elements) { + const entries = [] + const emittedGroups = new Set() + + elements.forEach((element) => { + const templateGroupId = element.style_json?.templateGroupId || '' + + if (!templateGroupId) { + entries.push({ + id: element.id, + ids: [element.id], + type: elementDisplayType(element), + name: elementDisplayName(element), + bounds: boundsForElements([element]), + z: Number(element.z_index || 10), + isTemplateGroup: false, + }) + return + } + + if (emittedGroups.has(templateGroupId)) return + emittedGroups.add(templateGroupId) + + const groupItems = elements.filter((item) => item.style_json?.templateGroupId === templateGroupId) + const first = groupItems[0] || element + const bounds = boundsForElements(groupItems) + + entries.push({ + id: first.id, + ids: groupItems.map((item) => item.id), + type: 'Template', + name: first.style_json?.templateName || first.name || 'Template', + bounds, + z: bounds.maxZ, + isTemplateGroup: true, + count: groupItems.length, + }) + }) + + return entries +} + +function ElementListPanel({ elements, selectedIds, onSelect, disabled = false }) { + const sorted = elementListEntries(elements).sort((a, b) => Number(b.z || 0) - Number(a.z || 0)) + + return ( +
+ Elementliste + +
+ {sorted.length === 0 &&
Noch keine Elemente auf der Zeichenfläche.
} + + {sorted.map((entry) => { + const selected = entry.ids.every((id) => selectedIds.includes(id)) + + return ( + + ) + })} +
+
+ ) +} + +export default function Designer({ pageId = 0, templateId = 0, templateMode = false }) { + const canvasRef = useRef(null) + const canvasScrollRef = useRef(null) + const dragRef = useRef(null) + const pageSettingsSaveRef = useRef(null) + const clipboardRef = useRef(null) + const historyRef = useRef({ past: [], future: [] }) + const dirtyRef = useRef(false) + const saveLayoutRef = useRef(null) + + const [page, setPage] = useState(null) + const [elements, setElements] = useState([]) + const [assets, setAssets] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [selectedIds, setSelectedIds] = useState([]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(Boolean(templateMode ? templateId : pageId)) + const [saving, setSaving] = useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [autoSaveEnabled, setAutoSaveEnabled] = useState(true) + const [zoom, setZoom] = useState(0.6) + const [contextMenu, setContextMenu] = useState(null) + const [templatePickerOpen, setTemplatePickerOpen] = useState(false) + const [templateBindingGroupId, setTemplateBindingGroupId] = useState('') + const [drawMode, setDrawMode] = useState(null) + const [draftPoints, setDraftPoints] = useState([]) + const [pointEditElementId, setPointEditElementId] = useState(null) + const [selectionBox, setSelectionBox] = useState(null) + const [isPanning, setIsPanning] = useState(false) + const [, setHistoryRevision] = useState(0) + + const selected = elements.find((el) => el.id === selectedId) || null + const selectedTemplateGroupId = !templateMode ? selected?.style_json?.templateGroupId || '' : '' + const selectedTemplateGroupElements = selectedTemplateGroupId + ? elements.filter((el) => el.style_json?.templateGroupId === selectedTemplateGroupId) + : [] + const bindingGroupElements = templateBindingGroupId + ? elements.filter((el) => el.style_json?.templateGroupId === templateBindingGroupId) + : [] + const selectedTemplateGroupBoxes = !templateMode + ? [...new Set(selectedIds + .map((id) => elements.find((el) => el.id === id)?.style_json?.templateGroupId || '') + .filter(Boolean))] + .map((templateGroupId) => { + const groupItems = elements.filter((el) => el.style_json?.templateGroupId === templateGroupId) + const first = groupItems[0] || null + const bounds = boundsForElements(groupItems) + + return { + templateGroupId, + name: first?.style_json?.templateName || first?.name || 'Template', + bounds, + } + }) + : [] + const isPointEditing = pointEditElementId !== null + const canUndo = historyRef.current.past.length > 0 + const canRedo = historyRef.current.future.length > 0 + + function setSelection(ids, primaryId = ids[0] || null) { + const uniqueIds = [...new Set(ids.filter((id) => id !== null && id !== undefined))] + setSelectedIds(uniqueIds) + setSelectedId(primaryId && uniqueIds.includes(primaryId) ? primaryId : uniqueIds[0] || null) + } + + function clearSelection() { + setSelectedIds([]) + setSelectedId(null) + } + + function selectedClipboardElements(anchorId = null) { + const baseIds = selectedIds.length ? selectedIds : (anchorId ? selectionIdsForElement(anchorId) : []) + const ids = new Set(baseIds) + const items = elements.filter((item) => ids.has(item.id)) + return items.map((item) => JSON.parse(JSON.stringify(item))) + } + + function markDirty() { + dirtyRef.current = true + setHasUnsavedChanges(true) + } + + function markClean() { + dirtyRef.current = false + setHasUnsavedChanges(false) + } + + useEffect(() => { + const activeId = templateMode ? templateId : pageId + + if (!activeId) { + setLoading(false) + return + } + + markClean() + historyRef.current = { past: [], future: [] } + setHistoryRevision((value) => value + 1) + loadPage() + }, [pageId, templateId, templateMode]) + + useEffect(() => { + if (!page?.project_id && !page?.is_template) return + loadAssets() + }, [page?.project_id, page?.is_template]) + + useEffect(() => { + function onKey(e) { + const activeTag = document.activeElement?.tagName?.toLowerCase() + const isTyping = ['input', 'textarea', 'select'].includes(activeTag) + + if (isTyping) return + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { + e.preventDefault() + copySelection() + } + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { + e.preventDefault() + if (clipboardRef.current) pasteElement() + } + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') { + e.preventDefault() + duplicateSelection() + } + + if (e.key === 'Delete' || e.key === 'Del') { + if (selected && !isPointEditing) { + e.preventDefault() + deleteElement(selected.id) + } + } + + if (e.key === 'Escape') { + setContextMenu(null) + setDrawMode(null) + setDraftPoints([]) + setPointEditElementId(null) + } + } + + window.addEventListener('keydown', onKey) + + return () => window.removeEventListener('keydown', onKey) + }, [selected, page, elements, isPointEditing]) + + useEffect(() => { + return () => { + window.removeEventListener('pointermove', pointerMove) + window.removeEventListener('pointerup', pointerUp) + } + }, []) + + useEffect(() => { + function warnBeforeUnload(e) { + if (!dirtyRef.current) return + e.preventDefault() + e.returnValue = '' + } + + window.addEventListener('beforeunload', warnBeforeUnload) + return () => window.removeEventListener('beforeunload', warnBeforeUnload) + }, []) + + useEffect(() => { + if (!autoSaveEnabled) return undefined + + const timer = window.setInterval(() => { + if (dirtyRef.current && !saving && !isPointEditing) { + saveLayoutRef.current?.() + } + }, 5 * 60 * 1000) + + return () => window.clearInterval(timer) + }, [autoSaveEnabled, saving, isPointEditing]) + + async function loadPage() { + setLoading(true) + + try { + const data = templateMode + ? await api('template', { id: templateId }) + : await api('page', { id: pageId }) + const nextPage = templateMode + ? { + ...normalizePage(data.template || {}), + id: Number(templateId), + project_id: 0, + is_template: true, + template_placeholders: data.placeholders || [], + } + : normalizePage(data.page || {}) + const loadedElements = templateMode + ? ensureUniqueTemplateElementIds(data.elements || []) + : (data.elements || []).map(normalizeElement) + const nextElements = templateMode + ? loadedElements + : await refreshTemplateInstances(loadedElements, (id) => api('template', { id })) + + setPage(nextPage) + setElements(nextElements) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + function loadAssets() { + api('assets', { project_id: page?.project_id || '' }) + .then((data) => setAssets((data.assets || []).map(normalizeAsset))) + .catch(() => setAssets([])) + } + + function registerPageSettingsSave(fn) { + pageSettingsSaveRef.current = fn + } + + function getCanvasPointFromEvent(e) { + const rect = canvasRef.current?.getBoundingClientRect() + if (!rect) return { x: 0, y: 0 } + + return { + x: snapValue((e.clientX - rect.left) / zoom, page), + y: snapValue((e.clientY - rect.top) / zoom, page), + } + } + + function cloneElements(items) { + return JSON.parse(JSON.stringify(items)) + } + + function rememberSnapshot(snapshot) { + historyRef.current.past.push(cloneElements(snapshot)) + historyRef.current.past = historyRef.current.past.slice(-80) + historyRef.current.future = [] + setHistoryRevision((value) => value + 1) + markDirty() + } + + function rememberElements() { + rememberSnapshot(elements) + } + + function undoElements() { + const previous = historyRef.current.past.pop() + if (!previous) return + + historyRef.current.future.push(cloneElements(elements)) + setElements(previous) + setSelectedIds((current) => current.filter((id) => previous.some((item) => item.id === id))) + setSelectedId((current) => (previous.some((item) => item.id === current) ? current : null)) + setHistoryRevision((value) => value + 1) + markDirty() + } + + function redoElements() { + const next = historyRef.current.future.pop() + if (!next) return + + historyRef.current.past.push(cloneElements(elements)) + setElements(next) + setSelectedIds((current) => current.filter((id) => next.some((item) => item.id === id))) + setSelectedId((current) => (next.some((item) => item.id === current) ? current : null)) + setHistoryRevision((value) => value + 1) + markDirty() + } + + function addElement(toolKey) { + if (isPointEditing) return + + const preset = TOOL_PRESETS[toolKey] + if (!preset) return + + const tempId = nextTempId() + const nextZ = elements.length ? Math.max(...elements.map((e) => Number(e.z_index || 10))) + 1 : 10 + + const newElement = { + id: tempId, + asset_id: null, + asset_name: '', + asset_type: '', + asset_states: [], + element_type: preset.element_type, + name: preset.name, + variable_id: '', + label: preset.label, + x: snapValue(80 + elements.length * 14, page), + y: snapValue(80 + elements.length * 14, page), + w: preset.w, + h: preset.h, + rotation: 0, + z_index: nextZ, + opacity: 1, + state_rules: null, + style_json: JSON.parse(JSON.stringify(preset.style_json || {})), + is_locked: false, + rules: [], + } + + rememberElements() + setElements((prev) => [...prev, newElement]) + setSelection([tempId]) + } + + function addShape(subtype) { + if (isPointEditing) return + + const tempId = nextTempId() + const nextZ = elements.length ? Math.max(...elements.map((e) => Number(e.z_index || 10))) + 1 : 10 + const size = subtype === 'ellipse' ? { w: 180, h: 120 } : { w: 160, h: 100 } + + const newElement = { + id: tempId, + asset_id: null, + asset_name: '', + asset_type: '', + asset_states: [], + element_type: 'shape', + name: subtype === 'ellipse' ? 'Ellipse' : subtype === 'circle' ? 'Kreis' : 'Rechteck', + variable_id: '', + label: '', + x: snapValue(100 + elements.length * 14, page), + y: snapValue(100 + elements.length * 14, page), + w: subtype === 'circle' ? 120 : size.w, + h: subtype === 'circle' ? 120 : size.h, + rotation: 0, + z_index: nextZ, + opacity: 1, + state_rules: null, + style_json: { + subtype, + strokeColor: '#60a5fa', + fillColor: 'transparent', + fillMode: 'solid', + gradientColor: '#1d4ed8', + gradientAngle: 0, + gradientOrigin: 'center', + gradientOriginX: 50, + gradientOriginY: 50, + colorRules: [], + strokeWidth: 4, + radius: subtype === 'rect' ? 10 : 0, + aspectLocked: subtype === 'circle', + aspectRatio: 1, + }, + is_locked: false, + rules: [], + } + + rememberElements() + setElements((prev) => [...prev, newElement]) + setSelection([tempId]) + } + + function startPolyline() { + if (isPointEditing) return + + setDrawMode('polyline') + setDraftPoints([]) + clearSelection() + setPointEditElementId(null) + } + + function startPolygon() { + if (isPointEditing) return + + setDrawMode('polygon') + setDraftPoints([]) + clearSelection() + setPointEditElementId(null) + } + + function addAssetElement(asset) { + if (isPointEditing) return + + const tempId = nextTempId() + const nextZ = elements.length ? Math.max(...elements.map((e) => Number(e.z_index || 10))) + 1 : 10 + const defaultState = asset.states.find((s) => s.is_default) || asset.states[0] + + const newElement = { + id: tempId, + asset_id: Number(asset.id), + asset_name: asset.name, + asset_type: asset.asset_type, + asset_states: asset.states || [], + element_type: 'asset', + name: asset.name, + variable_id: '', + label: asset.name, + x: snapValue(120, page), + y: snapValue(120, page), + w: 140, + h: 140, + rotation: 0, + z_index: nextZ, + opacity: 1, + state_rules: null, + style_json: { + subtype: 'asset_symbol', + default_image: defaultState?.image_file || '', + attachedValues: [], + aspectLocked: true, + aspectRatio: 1, + }, + is_locked: false, + rules: [], + } + + rememberElements() + setElements((prev) => [...prev, newElement]) + setSelection([tempId]) + } + + function updateElement(id, patchOrFn) { + rememberElements() + setElements((prev) => prev.map((el) => { + if (el.id !== id) return el + return typeof patchOrFn === 'function' ? patchOrFn(el) : { ...el, ...patchOrFn } + })) + } + + function updateSelected(next) { + if (!templateMode && next?.style_json?.templateGroupId) return + + rememberElements() + setElements((prev) => prev.map((el) => (el.id === next.id ? next : el))) + } + + function templateGroupIdForElement(id) { + if (templateMode) return '' + return elements.find((item) => item.id === id)?.style_json?.templateGroupId || '' + } + + function selectionIdsForElement(id) { + const templateGroupId = templateGroupIdForElement(id) + if (!templateGroupId) return [id] + return elements + .filter((item) => item.style_json?.templateGroupId === templateGroupId) + .map((item) => item.id) + } + + function toggleSelection(id) { + const ids = selectionIdsForElement(id) + const allSelected = ids.every((itemId) => selectedIds.includes(itemId)) + + if (allSelected) { + const next = selectedIds.filter((itemId) => !ids.includes(itemId)) + setSelection(next) + return + } + + setSelection([...selectedIds, ...ids], id) + } + + function selectElementFromList(id, additive = false) { + if (isPointEditing) return + if (additive) { + toggleSelection(id) + return + } + + setSelection(selectionIdsForElement(id), id) + } + + function deleteElement(id) { + if (isPointEditing) return + + const deleteIds = selectedIds.includes(id) && selectedIds.length > 1 + ? selectedIds + : selectionIdsForElement(id) + rememberElements() + setElements((prev) => prev.filter((el) => !deleteIds.includes(el.id))) + if (deleteIds.includes(selectedId)) clearSelection() + if (pointEditElementId === id) setPointEditElementId(null) + } + + function updateTemplateGroupZ(id, mode) { + const templateGroupId = templateGroupIdForElement(id) + if (!templateGroupId) return false + + const groupItems = elements + .filter((item) => item.style_json?.templateGroupId === templateGroupId) + .sort((a, b) => Number(a.z_index || 10) - Number(b.z_index || 10)) + + if (!groupItems.length) return false + + rememberElements() + setElements((prev) => { + const maxZ = prev.length ? Math.max(...prev.map((item) => Number(item.z_index || 10))) : 10 + const minZ = prev.length ? Math.min(...prev.map((item) => Number(item.z_index || 10))) : 10 + const zMap = new Map() + + groupItems.forEach((item, index) => { + if (mode === 'front') zMap.set(item.id, maxZ + index + 1) + if (mode === 'back') zMap.set(item.id, minZ - groupItems.length + index) + if (mode === 'plus') zMap.set(item.id, Number(item.z_index || 10) + 1) + if (mode === 'minus') zMap.set(item.id, Number(item.z_index || 10) - 1) + }) + + return prev.map((item) => zMap.has(item.id) ? { ...item, z_index: zMap.get(item.id) } : item) + }) + + return true + } + + function bringFront(id) { + if (updateTemplateGroupZ(id, 'front')) return + const maxZ = elements.length ? Math.max(...elements.map((e) => Number(e.z_index || 10))) : 10 + updateElement(id, { z_index: maxZ + 1 }) + } + + function sendBack(id) { + if (updateTemplateGroupZ(id, 'back')) return + const minZ = elements.length ? Math.min(...elements.map((e) => Number(e.z_index || 10))) : 10 + updateElement(id, { z_index: minZ - 1 }) + } + + function zPlus(id) { + if (updateTemplateGroupZ(id, 'plus')) return + updateElement(id, (el) => ({ ...el, z_index: Number(el.z_index || 10) + 1 })) + } + + function zMinus(id) { + if (updateTemplateGroupZ(id, 'minus')) return + updateElement(id, (el) => ({ ...el, z_index: Number(el.z_index || 10) - 1 })) + } + + function copySelection(anchorId = null) { + const items = selectedClipboardElements(anchorId) + if (!items.length) return + + clipboardRef.current = { + type: 'elements', + items, + } + } + + function getRawCanvasPointFromEvent(e) { + const rect = canvasRef.current?.getBoundingClientRect() + if (!rect) return { x: 0, y: 0 } + + return { + x: (e.clientX - rect.left) / zoom, + y: (e.clientY - rect.top) / zoom, + } + } + + function copyElement(id) { + copySelection(id) + } + + function pasteElement(canvasPoint = null) { + if (isPointEditing) return + if (!clipboardRef.current) return + + const rawItems = clipboardRef.current?.type === 'elements' + ? clipboardRef.current.items + : [clipboardRef.current] + + if (!Array.isArray(rawItems) || rawItems.length === 0) return + + const minX = Math.min(...rawItems.map((item) => Number(item.x || 0))) + const minY = Math.min(...rawItems.map((item) => Number(item.y || 0))) + const offsetX = canvasPoint ? Number(canvasPoint.x || 0) - minX : 30 + const offsetY = canvasPoint ? Number(canvasPoint.y || 0) - minY : 30 + const maxZ = elements.length ? Math.max(...elements.map((item) => Number(item.z_index || 10))) : 10 + const groupIdMap = new Map() + + const clonedItems = rawItems.map((item, index) => { + const cloned = cloneElementForPaste(item, page, 0) + const oldGroupId = item.style_json?.templateGroupId + let nextStyle = cloned.style_json || {} + + if (oldGroupId) { + if (!groupIdMap.has(oldGroupId)) { + groupIdMap.set(oldGroupId, `template-group-${Date.now()}-${Math.abs(nextTempId())}-${groupIdMap.size}`) + } + + nextStyle = { + ...nextStyle, + templateGroupId: groupIdMap.get(oldGroupId), + } + } + + return { + ...cloned, + x: snapValue(Number(item.x || 0) + offsetX, page), + y: snapValue(Number(item.y || 0) + offsetY, page), + z_index: maxZ + index + 1, + style_json: nextStyle, + } + }) + + if (!clonedItems.length) return + + rememberElements() + setElements((prev) => [...prev, ...clonedItems]) + setSelection(clonedItems.map((item) => item.id), clonedItems[0].id) + } + + function duplicateSelection() { + copySelection() + pasteElement() + } + + function insertTemplateElements(templateElements, mapping, templateMeta = {}) { + if (templateMode || isPointEditing) return + if (!Array.isArray(templateElements) || templateElements.length === 0) return + + const nextZ = elements.length ? Math.max(...elements.map((item) => Number(item.z_index || 10))) + 1 : 10 + const templateGroupId = `template-group-${Date.now()}-${Math.abs(nextTempId())}` + const templateBindings = normalizeTemplateBindings(mapping) + const imported = templateElements.map((item, index) => { + const mapped = mapTemplatePlaceholders(item, mapping) + return normalizeElement({ + ...mapped, + id: nextTempId(), + x: snapValue(Number(mapped.x || 0), page), + y: snapValue(Number(mapped.y || 0), page), + z_index: nextZ + index, + style_json: { + ...(mapped.style_json || {}), + templateGroupId, + templateSourceId: Number(templateMeta.id || 0), + templateElementId: String(item.style_json?.templateElementUid || item.id || mapped.id || index), + templateName: templateMeta.name || 'Template', + templatePlaceholders: templateMeta.placeholders || [], + templateBindings, + }, + }) + }) + + rememberElements() + setElements((prev) => [...prev, ...imported]) + setSelection(imported.map((item) => item.id), imported[0]?.id || null) + } + + function pointerDown(e, id) { + if (e.button === 1) { + startCanvasPan(e) + return + } + + if (e.button !== 0) return + + e.preventDefault() + e.stopPropagation() + + const el = elements.find((item) => item.id === id) + if (!el || el.is_locked) return + if (pointEditElementId === id) return + if (isPointEditing) return + + const clickedSelectionIds = selectionIdsForElement(id) + + if (e.ctrlKey || e.metaKey) { + toggleSelection(id) + return + } + + const activeSelectionIds = selectedIds.includes(id) + ? selectedIds + : clickedSelectionIds + const groupItems = activeSelectionIds + .map((itemId) => elements.find((item) => item.id === itemId)) + .filter((item) => item && !item.is_locked) + + if (!selectedIds.includes(id)) { + setSelection(clickedSelectionIds, id) + } else { + setSelectedId(id) + } + + dragRef.current = { + mode: groupItems.length > 1 ? 'move-group' : 'move', + id, + groupStarts: groupItems.length > 1 + ? Object.fromEntries(groupItems.map((item) => [item.id, { x: item.x, y: item.y }])) + : null, + groupBounds: groupItems.length > 1 ? boundsForElements(groupItems) : null, + startClientX: e.clientX, + startClientY: e.clientY, + startX: el.x, + startY: el.y, + startW: el.w, + startH: el.h, + before: cloneElements(elements), + changed: false, + } + + window.addEventListener('pointermove', pointerMove) + window.addEventListener('pointerup', pointerUp) + } + + function startCanvasPan(e) { + const scrollEl = canvasScrollRef.current + if (!scrollEl) return + + e.preventDefault() + e.stopPropagation() + + setIsPanning(true) + dragRef.current = { + mode: 'pan', + startClientX: e.clientX, + startClientY: e.clientY, + scrollLeft: scrollEl.scrollLeft, + scrollTop: scrollEl.scrollTop, + changed: false, + } + + window.addEventListener('pointermove', pointerMove) + window.addEventListener('pointerup', pointerUp) + } + + function resizeStart(e, id, handle) { + e.preventDefault() + e.stopPropagation() + + const el = elements.find((item) => item.id === id) + if (!el || el.is_locked) return + if (isPointEditing) return + if (!templateMode && el.style_json?.templateGroupId) return + + setSelection([id]) + + dragRef.current = { + mode: 'resize', + handle, + id, + startClientX: e.clientX, + startClientY: e.clientY, + startX: el.x, + startY: el.y, + startW: el.w, + startH: el.h, + aspectRatio: el.w > 0 && el.h > 0 ? el.w / el.h : 1, + aspectLocked: Boolean(el.style_json?.aspectLocked), + before: cloneElements(elements), + changed: false, + } + + window.addEventListener('pointermove', pointerMove) + window.addEventListener('pointerup', pointerUp) + } + + function handleElementDoubleClick(e, id) { + const templateGroupId = templateGroupIdForElement(id) + if (!templateGroupId) return + + setSelection(selectionIdsForElement(id), id) + setTemplateBindingGroupId(templateGroupId) + } + + function applyTemplateBindings(mapping) { + if (!templateBindingGroupId) return + + const bindings = normalizeTemplateBindings(mapping) + + rememberElements() + setElements((prev) => prev.map((el) => { + if (el.style_json?.templateGroupId !== templateBindingGroupId) return el + + const mapped = mapTemplateBindingRefs(el, bindings) + + return { + ...mapped, + style_json: { + ...(mapped.style_json || {}), + templateBindings: bindings, + }, + } + })) + } + + function pointPointerDown(e, index) { + e.preventDefault() + e.stopPropagation() + + const el = elements.find((item) => item.id === pointEditElementId) + if (!el) return + + const points = Array.isArray(el.style_json?.points) ? el.style_json.points : [] + + dragRef.current = { + mode: 'point', + id: el.id, + pointIndex: index, + startClientX: e.clientX, + startClientY: e.clientY, + startPoint: points[index], + before: cloneElements(elements), + changed: false, + } + + window.addEventListener('pointermove', pointerMove) + window.addEventListener('pointerup', pointerUp) + } + + function pointerMove(e) { + const drag = dragRef.current + if (!drag) return + + drag.changed = true + + if (drag.mode === 'pan') { + const scrollEl = canvasScrollRef.current + if (!scrollEl) return + + scrollEl.scrollLeft = drag.scrollLeft - (e.clientX - drag.startClientX) + scrollEl.scrollTop = drag.scrollTop - (e.clientY - drag.startClientY) + return + } + + if (drag.mode === 'marquee') { + const current = getRawCanvasPointFromEvent(e) + const x = Math.min(drag.startPoint.x, current.x) + const y = Math.min(drag.startPoint.y, current.y) + const w = Math.abs(current.x - drag.startPoint.x) + const h = Math.abs(current.y - drag.startPoint.y) + const box = { x, y, w, h } + const hitIds = [] + + elements.forEach((el) => { + if (el.is_locked) return + const intersects = Number(el.x || 0) < box.x + box.w && + Number(el.x || 0) + Number(el.w || 0) > box.x && + Number(el.y || 0) < box.y + box.h && + Number(el.y || 0) + Number(el.h || 0) > box.y + + if (intersects) hitIds.push(...selectionIdsForElement(el.id)) + }) + + const nextIds = drag.additive + ? [...new Set([...(drag.baseSelection || []), ...hitIds])] + : [...new Set(hitIds)] + + setSelectionBox(box) + setSelection(nextIds, nextIds[0] || null) + return + } + + const dx = (e.clientX - drag.startClientX) / zoom + const dy = (e.clientY - drag.startClientY) / zoom + const groupDx = drag.mode === 'move-group' && drag.groupBounds + ? snapValue(Math.round(drag.groupBounds.x + dx), page) - drag.groupBounds.x + : dx + const groupDy = drag.mode === 'move-group' && drag.groupBounds + ? snapValue(Math.round(drag.groupBounds.y + dy), page) - drag.groupBounds.y + : dy + + setElements((prev) => prev.map((el) => { + if (drag.mode === 'move-group') { + const start = drag.groupStarts?.[el.id] + if (!start) return el + + return { + ...el, + x: Math.round(start.x + groupDx), + y: Math.round(start.y + groupDy), + } + } + + if (el.id !== drag.id) return el + + if (drag.mode === 'move') { + return { + ...el, + x: snapValue(Math.round(drag.startX + dx), page), + y: snapValue(Math.round(drag.startY + dy), page), + } + } + + if (drag.mode === 'point') { + const points = Array.isArray(el.style_json?.points) ? [...el.style_json.points] : [] + const nextPoint = { + x: snapValue(Math.round((drag.startPoint?.x || 0) + dx), page), + y: snapValue(Math.round((drag.startPoint?.y || 0) + dy), page), + } + + points[drag.pointIndex] = nextPoint + + return { + ...el, + style_json: { + ...el.style_json, + points, + }, + } + } + + const handle = drag.handle || '' + let x = drag.startX + let y = drag.startY + let w = drag.startW + let h = drag.startH + + if (handle.includes('e')) w = drag.startW + dx + if (handle.includes('s')) h = drag.startH + dy + + if (handle.includes('w')) { + x = drag.startX + dx + w = drag.startW - dx + } + + if (handle.includes('n')) { + y = drag.startY + dy + h = drag.startH - dy + } + + if (w < 10) { + w = 10 + x = el.x + } + + if (h < 10) { + h = 10 + y = el.y + } + + if (drag.aspectLocked) { + if (Math.abs(dx) > Math.abs(dy)) { + h = w / drag.aspectRatio + } else { + w = h * drag.aspectRatio + } + } + + return { + ...el, + x: snapValue(Math.round(x), page), + y: snapValue(Math.round(y), page), + w: Math.max(10, snapValue(Math.round(w), page) || 10), + h: Math.max(10, snapValue(Math.round(h), page) || 10), + } + })) + } + + function pointerUp() { + const drag = dragRef.current + if (drag?.changed && drag.before) { + rememberSnapshot(drag.before) + } + + dragRef.current = null + setIsPanning(false) + setSelectionBox(null) + window.removeEventListener('pointermove', pointerMove) + window.removeEventListener('pointerup', pointerUp) + } + + function handleCanvasPointerDown(e) { + if (e.button === 1) { + startCanvasPan(e) + return + } + + if (e.button !== 0) return + e.preventDefault() + + if (drawMode === 'polyline' || drawMode === 'polygon') { + const pt = getCanvasPointFromEvent(e) + setDraftPoints((prev) => [...prev, pt]) + return + } + + if (!isPointEditing) { + const startPoint = getRawCanvasPointFromEvent(e) + dragRef.current = { + mode: 'marquee', + startClientX: e.clientX, + startClientY: e.clientY, + startPoint, + baseSelection: e.ctrlKey || e.metaKey ? selectedIds : [], + additive: e.ctrlKey || e.metaKey, + changed: false, + } + + setSelectionBox({ x: startPoint.x, y: startPoint.y, w: 0, h: 0 }) + if (!e.ctrlKey && !e.metaKey) clearSelection() + setContextMenu(null) + window.addEventListener('pointermove', pointerMove) + window.addEventListener('pointerup', pointerUp) + } + } + + function handleCanvasClick(e) { + if (!isPointEditing) { + if (!dragRef.current && !e.ctrlKey && !e.metaKey) clearSelection() + setContextMenu(null) + } + } + + function finishPolyline(e) { + if (drawMode !== 'polyline' && drawMode !== 'polygon') return + e.preventDefault() + + const isPolygon = drawMode === 'polygon' + if (draftPoints.length < (isPolygon ? 3 : 2)) { + setDrawMode(null) + setDraftPoints([]) + return + } + + const minX = Math.min(...draftPoints.map((p) => p.x)) + const minY = Math.min(...draftPoints.map((p) => p.y)) + const maxX = Math.max(...draftPoints.map((p) => p.x)) + const maxY = Math.max(...draftPoints.map((p) => p.y)) + + const localPoints = draftPoints.map((p) => ({ + x: p.x - minX, + y: p.y - minY, + })) + + const tempId = nextTempId() + const nextZ = elements.length ? Math.max(...elements.map((item) => Number(item.z_index || 10))) + 1 : 10 + + const newElement = { + id: tempId, + asset_id: null, + asset_name: '', + asset_type: '', + asset_states: [], + element_type: 'shape', + name: isPolygon ? 'Polygon' : 'Linienzug', + variable_id: '', + label: '', + x: minX, + y: minY, + w: Math.max(10, maxX - minX), + h: Math.max(10, maxY - minY), + rotation: 0, + z_index: nextZ, + opacity: 1, + state_rules: null, + style_json: { + subtype: isPolygon ? 'polygon' : 'polyline', + points: localPoints, + strokeColor: '#60a5fa', + fillColor: isPolygon ? '#1d4ed8' : 'transparent', + fillMode: 'solid', + gradientColor: '#0ea5e9', + gradientAngle: 0, + gradientOrigin: 'center', + gradientOriginX: 50, + gradientOriginY: 50, + colorRules: [], + strokeWidth: 6, + fillClosed: isPolygon, + startArrow: 'none', + endArrow: 'none', + arrowSize: 20, + arrowGap: 0, + arrowGapLinked: true, + startArrowGap: 0, + endArrowGap: 0, + startArrowType: 'triangle', + endArrowType: 'triangle', + startArrowRotation: 0, + endArrowRotation: 0, + flowEnabled: false, + flowPoint: null, + flowMin: 0, + flowMax: 100, + flowSlowSeconds: 5, + flowFastSeconds: 0.7, + flowDirection: 'forward', + flowInvertSpeed: false, + flowColor: '#e0f2fe', + }, + is_locked: false, + rules: [], + } + + rememberElements() + setElements((prev) => [...prev, newElement]) + setSelection([tempId]) + setDrawMode(null) + setDraftPoints([]) + } + + function handleCanvasWheel(e) { + e.preventDefault() + const delta = e.deltaY > 0 ? -0.05 : 0.05 + + setZoom((old) => Math.max(0.15, Math.min(2.5, Number((old + delta).toFixed(2))))) + } + + function handleElementContext(e, id) { + e.preventDefault() + e.stopPropagation() + + const el = elements.find((item) => item.id === id) + const templateGroupId = templateGroupIdForElement(id) + const position = clampContextMenuPosition(e.clientX, e.clientY, { + elementId: id, + canEditPoints: !templateGroupId && el?.element_type === 'shape' && ['polyline', 'polygon'].includes(el?.style_json?.subtype), + boundsRect: canvasRef.current?.getBoundingClientRect(), + }) + + setSelection(templateGroupId ? elements.filter((item) => item.style_json?.templateGroupId === templateGroupId).map((item) => item.id) : [id], id) + setContextMenu({ + x: position.x, + y: position.y, + elementId: id, + isTemplateGroup: Boolean(templateGroupId), + templateGroupId, + canEditPoints: !templateGroupId && el?.element_type === 'shape' && ['polyline', 'polygon'].includes(el?.style_json?.subtype), + }) + } + + function handleCanvasContext(e) { + e.preventDefault() + const position = clampContextMenuPosition(e.clientX, e.clientY, { + boundsRect: canvasRef.current?.getBoundingClientRect(), + }) + + setContextMenu({ + x: position.x, + y: position.y, + elementId: null, + canvasPoint: getCanvasPointFromEvent(e), + }) + } + + async function saveLayout() { + if ((templateMode ? !templateId : !pageId) || isPointEditing) return + + setSaving(true) + setError('') + + try { + let effectivePage = page + + if (pageSettingsSaveRef.current) { + const savedSettings = await pageSettingsSaveRef.current() + if (!savedSettings) throw new Error('Zeichenflächen-Einstellungen konnten nicht gespeichert werden.') + if (typeof savedSettings === 'object') { + effectivePage = { ...page, ...savedSettings } + setPage(effectivePage) + } + } + + const payload = elements.map((el) => { + let styleJson = templateMode ? stripTemplateRuntimeMeta(el.style_json) : el.style_json + + if (templateMode) { + styleJson = { + ...(styleJson || {}), + templateElementUid: styleJson?.templateElementUid || `template-el-${Date.now()}-${Math.abs(nextTempId())}`, + } + } + + return { + ...el, + id: templateMode ? el.id : (el.id > 0 ? el.id : 0), + variable_id: el.variable_id || null, + style_json: styleJson, + } + }) + + if (templateMode) { + await apiPost('template_save', { + template_id: templateId, + name: effectivePage?.name || 'Template', + description: effectivePage?.description || '', + width: effectivePage?.width || 1920, + height: effectivePage?.height || 1080, + background_color: effectivePage?.background_color || '#0f172a', + grid_enabled: effectivePage?.grid_enabled ? '1' : '', + snap_enabled: effectivePage?.snap_enabled ? '1' : '', + grid_size: effectivePage?.grid_size || 40, + placeholders_json: JSON.stringify(effectivePage?.template_placeholders || []), + elements_json: JSON.stringify(payload), + }) + } else { + await apiPost('elements_save', { + page_id: pageId, + elements_json: JSON.stringify(payload), + }) + } + + await loadPage() + markClean() + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + saveLayoutRef.current = saveLayout + + if (!templateMode && !pageId) { + return ( +
+
+
+
+
Designer
+

Keine Seite gewählt

+
+ + +
+
+
+ ) + } + + return ( +
+
+ {isPointEditing && ( +
+ {selected?.style_json?.subtype === 'polygon' ? 'Polygon bearbeiten' : 'Linienzug bearbeiten'} + Punkte verschieben und anschließend bestätigen. + +
+ )} + +
+
+
{templateMode ? 'Template Designer' : 'Designer'}
+

{page?.name || (templateMode ? `Template #${templateId}` : `Seite #${pageId}`)}

+ {page && !templateMode &&

{page.year} · {page.project_number} · {page.project_name}

} + {page && templateMode &&

{page.description || 'Template mit Platzhaltern'}

} +
+ +
+ {drawMode && ( + + )} + + {!templateMode && ( + + )} + + + + + + {hasUnsavedChanges && Nicht gespeichert} + + {templateMode && } + + Dashboard + + + + Verwaltung + + + + Templates + + + Datenpunkte + + + Virtuelle Datenpunkte + + + CSV Exporte + + + {!templateMode && ( + <> + + Anzeigen + + + + Designer + + + )} +
+
+ + {loading &&
Visualisierung wird geladen…
} + {error &&
{error}
} + +
+ + +
+
+ {elements.length} Element(e) + +
+ + + + +
+
+ +
e.preventDefault()} + > +
+
+ {elements.map((el) => ( + + ))} + + {selectedTemplateGroupBoxes.map((group) => ( +
+ {group.name} +
+ ))} + + {(drawMode === 'polyline' || drawMode === 'polygon') && draftPoints.length > 0 && ( + + {drawMode === 'polygon' && draftPoints.length > 2 && ( + `${p.x},${p.y}`).join(' ')} + fill="rgba(37, 99, 235, 0.25)" + stroke="none" + /> + )} + `${p.x},${p.y}`).join(' ')} + fill="none" + stroke="#93c5fd" + strokeWidth="5" + strokeLinecap="round" + strokeLinejoin="round" + /> + + {draftPoints.map((p, idx) => ( + + ))} + + )} + + {selectionBox && ( +
+ )} +
+
+
+ + +
+ + setPointEditElementId(id)} + onFinishPointEdit={() => setPointEditElementId(null)} + /> +
+ + setContextMenu(null)} + onCopy={copyElement} + onPaste={pasteElement} + onDelete={deleteElement} + onEditPoints={(id) => setPointEditElementId(id)} + onEditTemplateBindings={(groupId) => setTemplateBindingGroupId(groupId)} + onBringFront={bringFront} + onSendBack={sendBack} + onZPlus={zPlus} + onZMinus={zMinus} + /> + + {!templateMode && ( + setTemplatePickerOpen(false)} + onInsert={insertTemplateElements} + /> + )} + + {!templateMode && ( + setTemplateBindingGroupId('')} + onApply={applyTemplateBindings} + /> + )} +
+
+ ) +} diff --git a/visu-src/src/pages/MonitoringData.jsx b/visu-src/src/pages/MonitoringData.jsx new file mode 100644 index 0000000..24f7e84 --- /dev/null +++ b/visu-src/src/pages/MonitoringData.jsx @@ -0,0 +1,426 @@ +import { useEffect, useMemo, useState } from 'react' +import ViewerShortcutButton from '../components/ViewerShortcutButton' +import { api, apiPost } from '../lib/visuApi' + +const RANGE_OPTIONS = [ + { value: '15m', label: '15 Min' }, + { value: '1h', label: '1 h' }, + { value: '6h', label: '6 h' }, + { value: '12h', label: '12 h' }, + { value: '24h', label: '24 h' }, + { value: '7d', label: '7 Tage' }, + { value: '30d', label: '30 Tage' }, + { value: '90d', label: '90 Tage' }, + { value: '180d', label: '6 Monate' }, +] + +const INTERVAL_OPTIONS = ['auto', '10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h', '6h', '12h', '1d'] + +const AGGREGATES = [ + { value: 'mean', label: 'Mittelwert' }, + { value: 'last', label: 'Letzter Wert' }, + { value: 'min', label: 'Minimum' }, + { value: 'max', label: 'Maximum' }, +] + +function pointKey(point) { + return ['influx', point.database || '', point.measurement || '', point.tag_key || '', point.tag_value || ''].join('|') +} + +function pointMeasurement(point) { + return String(point?.measurement || '').trim() +} + +function csvEscape(value, delimiter = ',') { + const text = String(value ?? '') + return /["\r\n]/.test(text) || text.includes(delimiter) + ? `"${text.replace(/"/g, '""')}"` + : text +} + +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +function chunks(items, size = 8) { + const out = [] + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)) + return out +} + +function lastNonNull(series) { + const row = [...(series?.values || [])].reverse().find((item) => item.value !== null && item.value !== undefined && item.value !== '') + return row ? { value: row.value, time: row.time } : { value: '', time: '' } +} + +export default function MonitoringData() { + const [databases, setDatabases] = useState([]) + const [database, setDatabase] = useState('') + const [points, setPoints] = useState([]) + const [selection, setSelection] = useState([]) + const [savedSelections, setSavedSelections] = useState([]) + const [activeSelectionId, setActiveSelectionId] = useState(0) + const [selectionName, setSelectionName] = useState('Neue Selection') + const [q, setQ] = useState('') + const [measurementFilter, setMeasurementFilter] = useState('') + const [range, setRange] = useState('24h') + const [interval, setIntervalValue] = useState('auto') + const [aggregate, setAggregate] = useState('last') + const [delimiter, setDelimiter] = useState(';') + const [lastValues, setLastValues] = useState({}) + const [loading, setLoading] = useState(false) + const [savingSelection, setSavingSelection] = useState(false) + const [error, setError] = useState('') + + async function loadSavedSelections() { + const data = await api('monitoring_selections') + setSavedSelections(data.selections || []) + } + + useEffect(() => { + loadSavedSelections().catch((err) => setError(err.message)) + }, []) + + useEffect(() => { + let alive = true + setLoading(true) + setError('') + + api('trend_sources', { database }) + .then((data) => { + if (!alive) return + setDatabases(data.databases || []) + setDatabase((current) => current || data.selected_database || data.databases?.[0] || '') + setPoints(data.points || []) + }) + .catch((err) => { + if (alive) setError(err.message) + }) + .finally(() => { + if (alive) setLoading(false) + }) + + return () => { + alive = false + } + }, [database]) + + useEffect(() => { + if (!measurementFilter) return + + if (!points.some((point) => pointMeasurement(point) === measurementFilter)) { + setMeasurementFilter('') + } + }, [measurementFilter, points]) + + useEffect(() => { + let alive = true + + async function loadLastValues() { + if (!selection.length) { + setLastValues({}) + return + } + + const next = {} + + for (const group of chunks(selection, 8)) { + const res = await apiPost('trend_data', { + series_json: JSON.stringify(group.map((point, index) => ({ + ...point, + id: pointKey(point) || `selection-${index}`, + label: point.tag_value || point.label, + }))), + range, + interval: 'auto', + aggregate: 'last', + }) + + ;(res.data?.series || []).forEach((serie) => { + next[pointKey(serie)] = lastNonNull(serie) + }) + } + + if (alive) setLastValues(next) + } + + loadLastValues().catch((err) => { + if (alive) setError(err.message) + }) + + return () => { + alive = false + } + }, [selection, range]) + + const measurements = useMemo(() => ( + Array.from(new Set(points.map(pointMeasurement).filter(Boolean))).sort((a, b) => ( + a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' }) + )) + ), [points]) + + const filteredPoints = useMemo(() => { + const needle = q.trim().toLowerCase() + return points.filter((point) => { + const hay = `${point.measurement} ${point.tag_key} ${point.tag_value} ${point.label}`.toLowerCase() + const matchesSearch = !needle || hay.includes(needle) + const matchesMeasurement = !measurementFilter || pointMeasurement(point) === measurementFilter + return matchesSearch && matchesMeasurement + }) + }, [points, q, measurementFilter]) + + const selectionKeys = new Set(selection.map(pointKey)) + + function addPoint(point) { + if (selectionKeys.has(pointKey(point))) return + setSelection((old) => [...old, point]) + } + + function removePoint(key) { + setSelection((old) => old.filter((point) => pointKey(point) !== key)) + } + + async function saveCurrentSelection() { + const name = selectionName.trim() || `Selection ${savedSelections.length + 1}` + setError('') + setSavingSelection(true) + + try { + const data = await apiPost('monitoring_selection_save', { + selection_id: activeSelectionId || '', + name, + database_name: database, + range_value: range, + interval_value: interval, + aggregate_value: aggregate, + csv_delimiter: delimiter, + points_json: JSON.stringify(selection), + }) + + setActiveSelectionId(data.selection_id || activeSelectionId) + setSelectionName(name) + await loadSavedSelections() + } catch (err) { + setError(err.message) + } finally { + setSavingSelection(false) + } + } + + function loadSelection(item) { + setSelection(item.points || []) + setSelectionName(item.name || 'Selection') + setActiveSelectionId(Number(item.id || 0)) + setDatabase(item.database_name || database) + setRange(item.range_value || '24h') + setIntervalValue(item.interval_value || 'auto') + setAggregate(item.aggregate_value || 'last') + setDelimiter(item.csv_delimiter || item.delimiter_value || ';') + } + + async function deleteSelection(item) { + if (!item?.id) return + setError('') + + try { + await apiPost('monitoring_selection_delete', { selection_id: item.id }) + if (Number(item.id) === Number(activeSelectionId)) setActiveSelectionId(0) + await loadSavedSelections() + } catch (err) { + setError(err.message) + } + } + + function exportLastValuesCsv() { + const header = ['Datenpunkt', 'Measurement', 'Tag', 'Wert', 'Zeit', 'Datenbank'] + const lines = [ + header.map((cell) => csvEscape(cell, delimiter)).join(delimiter), + ...selection.map((point) => { + const last = lastValues[pointKey(point)] || {} + return [point.tag_value, point.measurement, point.tag_key, last.value ?? '', last.time ?? '', point.database] + .map((cell) => csvEscape(cell, delimiter)).join(delimiter) + }), + ] + downloadBlob(new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), `${selectionName || 'monitoring_selection'}_lastvalues.csv`) + } + + async function exportTrendData(kind = 'csv') { + const rows = new Map() + const headers = ['Zeit', ...selection.map((point) => point.tag_value || point.label)] + + for (const group of chunks(selection, 8)) { + const res = await apiPost('trend_data', { + series_json: JSON.stringify(group.map((point) => ({ + ...point, + id: pointKey(point), + label: point.tag_value || point.label, + }))), + range, + interval, + aggregate, + }) + + ;(res.data?.series || []).forEach((serie) => { + ;(serie.values || []).forEach((row) => { + if (!rows.has(row.time)) rows.set(row.time, { Zeit: row.time }) + rows.get(row.time)[serie.label] = row.value ?? '' + }) + }) + } + + const sortedRows = Array.from(rows.values()).sort((a, b) => String(a.Zeit).localeCompare(String(b.Zeit))) + + if (kind === 'xls') { + const html = [ + '', + `${headers.map((cell) => ``).join('')}`, + ...sortedRows.map((row) => `${headers.map((cell) => ``).join('')}`), + '
${String(cell).replace(/[<>&]/g, '')}
${String(row[cell] ?? '').replace(/[<>&]/g, '')}
', + ].join('') + downloadBlob(new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' }), `${selectionName || 'monitoring_selection'}_trend.xls`) + return + } + + const lines = [ + headers.map((cell) => csvEscape(cell, delimiter)).join(delimiter), + ...sortedRows.map((row) => headers.map((cell) => csvEscape(row[cell] ?? '', delimiter)).join(delimiter)), + ] + downloadBlob(new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), `${selectionName || 'monitoring_selection'}_trend.csv`) + } + + return ( +
+
+
+
+
Monitoring
+

Datenpunkt-Uebersicht

+

Datenpunkte auswaehlen, Selections speichern und manuelle CSV- oder Excel-Exporte ziehen.

+
+ +
+ + {error &&
{error}
} + +
+
+
+
+ + +
+
+ + +
+
+
+ + setQ(e.target.value)} placeholder="Measurement, Tag, Datenpunkt..." /> +
+ {loading &&
Datenpunkte werden geladen...
} +
+ {filteredPoints.map((point) => ( + + ))} +
+
+ +
+
+
+ + setSelectionName(e.target.value)} /> +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + Zyklische Exporte +
+ +
+ {selection.map((point) => { + const last = lastValues[pointKey(point)] || {} + return ( +
+
+ {point.tag_value} + {last.value !== '' && last.value !== undefined ? last.value : '...'} {last.time ? `- ${new Date(last.time).toLocaleString('de-DE')}` : ''} +
+ +
+ ) + })} +
+ +
+
Gespeicherte Selections
+ {savedSelections.map((item) => ( +
+ + +
+ ))} +
+
+
+
+
+ ) +} diff --git a/visu-src/src/pages/Overview.jsx b/visu-src/src/pages/Overview.jsx new file mode 100644 index 0000000..43564f8 --- /dev/null +++ b/visu-src/src/pages/Overview.jsx @@ -0,0 +1,1309 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import ValueInstrumentGraphic from '../components/ValueInstrumentGraphic' +import { api, apiPost } from '../lib/visuApi' +import { + TOOL_PRESETS, + decodeMaybeJson, + formatLiveValue, + groupPagesByProject, + livePointFromRef, + sourceKeyFromRef, +} from '../lib/visuUtils' + +const DASHBOARD_COLS = 4 +const DASHBOARD_ROW_HEIGHT = 124 +const DASHBOARD_GAP = 12 + +const WIDGET_TYPES = [ + { key: 'simple_value', label: 'Einfacher Wert' }, + { key: 'value_table', label: 'Wertetabelle' }, + { key: 'scale_bar', label: 'Werteskala' }, + { key: 'dial_gauge', label: 'Zeigerinstrument' }, +] + +const LABEL_MODE_OPTIONS = [ + { value: 'all', label: 'Alle Hauptwerte' }, + { value: 'ends', label: 'Nur Anfang und Ende' }, + { value: 'none', label: 'Keine Zahlen' }, +] + +const SCALE_POINTER_OPTIONS = [ + { value: 'triangle', label: 'Dreieck' }, + { value: 'line', label: 'Markierungslinie' }, + { value: 'diamond', label: 'Raute' }, +] + +const DIAL_POINTER_OPTIONS = [ + { value: 'needle', label: 'Standardzeiger' }, + { value: 'slim', label: 'Dünner Zeiger' }, + { value: 'wedge', label: 'Breiter Zeiger' }, +] + +function clonePointRef(point) { + if (!point) return null + + return { + type: point.type || '', + external_id: point.external_id || '', + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || point.point_label || '', + } +} + +function defaultWidgetConfig(type) { + if (type === 'scale_bar') { + return { + ...JSON.parse(JSON.stringify(TOOL_PRESETS.scale_bar.style_json)), + point: null, + } + } + + if (type === 'dial_gauge') { + return { + ...JSON.parse(JSON.stringify(TOOL_PRESETS.dial_gauge.style_json)), + point: null, + } + } + + if (type === 'value_table') { + return { + rows: [], + } + } + + return { + point: null, + decimals: 1, + } +} + +function normalizeWidget(row) { + const type = row.widget_type || 'simple_value' + const base = defaultWidgetConfig(type) + const config = decodeMaybeJson(row.config_json, {}) || {} + + return { + id: Number(row.id || 0), + widget_type: type, + title: row.title || '', + project_id: Number(row.project_id || 0), + grid_x: Math.max(0, Number(row.grid_x || 0)), + grid_y: Math.max(0, Number(row.grid_y || 0)), + grid_w: Math.max(1, Math.min(4, Number(row.grid_w || 1))), + grid_h: Math.max(1, Math.min(4, Number(row.grid_h || 1))), + sort_order: Number(row.sort_order || 100), + config: { + ...base, + ...config, + }, + } +} + +function toValueMap(values) { + const map = {} + const hasDisplayValue = (point) => ( + point + && ( + (point.value !== null && point.value !== undefined && String(point.value).trim() !== '') + || (point.value_formatted !== null && point.value_formatted !== undefined && String(point.value_formatted).trim() !== '') + ) + ) + + ;(values || []).forEach((point) => { + if (!point?.source_key) return + map[point.source_key] = point + if (point.variable_id) { + const key = String(point.variable_id) + if (!map[key] || !hasDisplayValue(map[key]) || hasDisplayValue(point)) { + map[key] = point + } + } + }) + + return map +} + +function pointForRef(projectValues, projectId, ref) { + if (!projectId || !ref) return null + const live = projectValues[projectId] || {} + return livePointFromRef(ref, live) +} + +function widgetTypeLabel(type) { + return WIDGET_TYPES.find((item) => item.key === type)?.label || type +} + +function areasOverlap(a, b) { + return !( + a.grid_x + a.grid_w <= b.grid_x || + b.grid_x + b.grid_w <= a.grid_x || + a.grid_y + a.grid_h <= b.grid_y || + b.grid_y + b.grid_h <= a.grid_y + ) +} + +function canPlace(widget, widgets, excludeId = 0) { + if (widget.grid_x < 0 || widget.grid_y < 0) return false + if (widget.grid_w < 1 || widget.grid_h < 1) return false + if (widget.grid_x + widget.grid_w > DASHBOARD_COLS) return false + + return !widgets.some((other) => Number(other.id) !== Number(excludeId) && areasOverlap(widget, other)) +} + +function findFirstSpot(widget, widgets, excludeId = 0, startY = 0) { + for (let row = Math.max(0, startY); row < 240; row += 1) { + for (let col = 0; col <= DASHBOARD_COLS - widget.grid_w; col += 1) { + const candidate = { ...widget, grid_x: col, grid_y: row } + if (canPlace(candidate, widgets, excludeId)) return candidate + } + } + + return { + ...widget, + grid_x: 0, + grid_y: Math.max(0, startY), + } +} + +function placeWidget(widget, widgets, excludeId = 0) { + const safe = { + ...widget, + grid_w: Math.max(1, Math.min(4, Number(widget.grid_w || 1))), + grid_h: Math.max(1, Math.min(4, Number(widget.grid_h || 1))), + grid_x: Math.max(0, Math.min(DASHBOARD_COLS - Math.max(1, Math.min(4, Number(widget.grid_w || 1))), Number(widget.grid_x || 0))), + grid_y: Math.max(0, Number(widget.grid_y || 0)), + } + + if (canPlace(safe, widgets, excludeId)) return safe + return findFirstSpot(safe, widgets, excludeId, safe.grid_y) +} + +function ProjectSelect({ value, onChange }) { + const [projects, setProjects] = useState([]) + const [q, setQ] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + api('projects') + .then((data) => setProjects(data.projects || [])) + .catch((err) => setError(err.message)) + }, []) + + const filtered = projects.filter((p) => { + const hay = [p.year, p.project_number, p.name, p.customer_name].filter(Boolean).join(' ').toLowerCase() + return hay.includes(q.toLowerCase()) + }) + + return ( +
+ + + setQ(e.target.value)} + placeholder="Projekt suchen…" + className="form-input" + /> + + + + {error &&
{error}
} +
+ ) +} + +function ProjectValueSelect({ projectId, value, onChange }) { + const [values, setValues] = useState([]) + const [q, setQ] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!projectId) { + setValues([]) + return + } + + setLoading(true) + setError('') + + api('project_values', { project_id: projectId }) + .then((data) => setValues(data.values || [])) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, [projectId]) + + const filtered = values.filter((item) => { + const hay = [ + item.type, + item.label, + item.point_key, + item.variable_id, + item.value_formatted, + ].filter(Boolean).join(' ').toLowerCase() + return hay.includes(q.toLowerCase()) + }) + + function handleChange(sourceKey) { + const point = values.find((entry) => entry.source_key === sourceKey) + onChange(point ? clonePointRef(point) : null) + } + + return ( +
+ + setQ(e.target.value)} + placeholder="Wert suchen…" + className="form-input" + disabled={!projectId} + /> + + + + {loading &&
Werte werden geladen…
} + {error &&
{error}
} +
+ ) +} + +function ColorField({ label, value, onChange }) { + const safe = /^#[0-9a-fA-F]{6}$/.test(value || '') ? value : '#000000' + + return ( +
+ +
+ onChange(e.target.value)} /> + onChange(e.target.value)} /> +
+
+ ) +} + +function RangeEditor({ ranges, onChange }) { + function updateRange(index, patch) { + const next = [...ranges] + next[index] = { ...next[index], ...patch } + onChange(next) + } + + return ( +
+
Farbbereiche
+ + {(ranges || []).map((range, index) => ( +
+ updateRange(index, { from: Number(e.target.value || 0) })} placeholder="Von" /> + updateRange(index, { to: Number(e.target.value || 0) })} placeholder="Bis" /> + updateRange(index, { color: e.target.value })} /> + +
+ ))} + + +
+ ) +} + +function DashboardWidgetModal({ open, draft, onClose, onSave }) { + const [form, setForm] = useState(null) + + useEffect(() => { + setForm(draft ? JSON.parse(JSON.stringify(draft)) : null) + }, [draft]) + + if (!open || !form) return null + + function updateField(key, value) { + setForm((old) => ({ ...old, [key]: value })) + } + + function updateConfig(patch) { + setForm((old) => ({ + ...old, + config: { + ...old.config, + ...patch, + }, + })) + } + + function updateTableRow(index, patch) { + const next = [...(form.config.rows || [])] + next[index] = { ...next[index], ...patch } + updateConfig({ rows: next }) + } + + return ( +
+
e.stopPropagation()}> +
+
+
Dashboard
+

{form.id ? 'Widget bearbeiten' : 'Widget hinzufügen'}

+
+ + +
+ +
+
+ + +
+ +
+ + updateField('title', e.target.value)} placeholder="Optionaler Titel" /> +
+ + updateField('project_id', value)} /> + + {form.widget_type === 'simple_value' && ( + <> + updateConfig({ point })} + /> + +
+ + updateConfig({ decimals: Math.max(0, Math.min(8, Number(e.target.value || 1))) })} + /> +
+ + )} + + {form.widget_type === 'value_table' && ( +
+
Tabellenwerte
+ + {(form.config.rows || []).map((row, index) => ( +
+ updateTableRow(index, { label: e.target.value })} + placeholder="Label" + /> + + updateTableRow(index, { point })} + /> + + updateTableRow(index, { decimals: Math.max(0, Math.min(8, Number(e.target.value || 1))) })} + placeholder="Nachkommastellen" + /> + + +
+ ))} + + +
+ )} + + {['scale_bar', 'dial_gauge'].includes(form.widget_type) && ( + <> + updateConfig({ point })} + /> + + {form.widget_type === 'scale_bar' && ( +
+ + +
+ )} + +
+
+ + updateConfig({ min: Number(e.target.value || 0) })} /> +
+
+ + updateConfig({ max: Number(e.target.value || 100) })} /> +
+
+ + updateConfig({ majorStep: Number(e.target.value || 20) })} /> +
+
+ + updateConfig({ minorDivisions: Number(e.target.value || 4) })} /> +
+
+ + updateConfig({ decimals: Math.max(0, Math.min(8, Number(e.target.value || 1))) })} /> +
+
+ + +
+
+ + updateConfig({ labelFontSize: Number(e.target.value || 12) })} /> +
+
+ + updateConfig({ labelOffset: Number(e.target.value || 0) })} /> +
+
+ +
+ + +
+ + updateConfig({ barColor: value })} /> + updateConfig({ needleColor: value })} /> + + + + {form.config.fillEnabled && ( + updateConfig({ fillColor: value })} /> + )} + + updateConfig({ ranges })} /> + + )} + +
+ + +
+
+
+
+ ) +} + +function CreatePageForm({ onCreated, compact = false }) { + const [projectId, setProjectId] = useState(0) + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [width, setWidth] = useState(1920) + const [height, setHeight] = useState(1080) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + async function submit(e) { + e.preventDefault() + setError('') + + if (!projectId) return setError('Bitte Projekt auswählen.') + if (!name.trim()) return setError('Bitte Namen eintragen.') + + setSaving(true) + + try { + const data = await apiPost('page_create', { project_id: projectId, name, description, width, height }) + onCreated?.(data.page_id) + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + return ( +
+
+
+
Neue Seite
+

Visualisierung anlegen

+
+ + SE-Cloud Vis +
+ +
+ + +
+ + setName(e.target.value)} placeholder="z.B. Heizzentrale" /> +
+ +
+ + setWidth(Number(e.target.value || 1920))} /> +
+ +
+ + setHeight(Number(e.target.value || 1080))} /> +
+ +
+ + setDescription(e.target.value)} placeholder="Optional" /> +
+ +
+ +
+
+ + {error &&
{error}
} +
+ ) +} + +function CreatePageModal({ open, onClose, onCreated }) { + if (!open) return null + + return ( +
+
e.stopPropagation()}> +
+
+
Neue Seite
+

Visualisierung anlegen

+
+ + +
+ + { + onClose() + onCreated?.(pageId) + }} compact /> +
+
+ ) +} + +function OverviewProjectTree({ pages, canDesign, canEdit, onDelete }) { + const [q, setQ] = useState('') + const [openProjects, setOpenProjects] = useState({}) + + const filteredPages = useMemo(() => { + const needle = q.trim().toLowerCase() + if (!needle) return pages + + return pages.filter((page) => { + const hay = [ + page.name, + page.description, + page.year, + page.project_number, + page.project_name, + page.customer_name, + ].filter(Boolean).join(' ').toLowerCase() + + return hay.includes(needle) + }) + }, [pages, q]) + + const groups = useMemo(() => groupPagesByProject(filteredPages), [filteredPages]) + + useEffect(() => { + if (groups.length === 0) return + if (Object.keys(openProjects).length > 0) return + setOpenProjects({ [groups[0].project_id]: true }) + }, [groups, openProjects]) + + function toggleProject(projectId) { + setOpenProjects((old) => ({ + ...old, + [projectId]: !old[projectId], + })) + } + + return ( + + ) +} + +function DashboardWidgetContent({ widget, projectValues }) { + if (widget.widget_type === 'simple_value') { + const point = pointForRef(projectValues, widget.project_id, widget.config.point) + return ( +
+
{widget.config.point?.point_label || widget.title || 'Wert'}
+
{formatLiveValue(point, widget.config.decimals ?? 1)}
+
+ ) + } + + if (widget.widget_type === 'value_table') { + const rows = Array.isArray(widget.config.rows) ? widget.config.rows : [] + + return ( +
+ {rows.length === 0 &&
Noch keine Werte verknüpft.
} + {rows.map((row, index) => { + const point = pointForRef(projectValues, widget.project_id, row.point) + return ( +
+ {row.label || row.point?.point_label || `Wert ${index + 1}`} + {formatLiveValue(point, row.decimals ?? 1)} +
+ ) + })} +
+ ) + } + + const style_json = { + ...widget.config, + kind: widget.widget_type === 'dial_gauge' ? 'dial_gauge' : 'scale_bar', + } + + return ( +
+ +
+ ) +} + +function OverviewModeTabs({ mode, pageCount = 0, widgetCount = 0 }) { + return ( +
+ Dashboard + Verwaltung + Templates + Datenpunkte + Virtuelle Datenpunkte + CSV Exporte + {mode === 'dashboard' ? `${widgetCount} Widget(s)` : `${pageCount} Seite(n)`} +
+ ) +} + +function ProjectPageGroups({ pages, canDesign, canEdit, onDelete }) { + const grouped = useMemo(() => groupPagesByProject(pages), [pages]) + + return ( +
+ {grouped.map((project) => ( +
+
+
+
+ {project.year} · {project.project_number} · {project.project_name} +
+ + {project.customer_name && ( +
{project.customer_name}
+ )} +
+ + {project.pages.length} Bild(er) +
+ +
+ {project.pages.map((page) => ( +
+
{page.name}
+
{page.width}×{page.height}
+ + {page.description &&
{page.description}
} + +
+ Anzeigen + {canDesign && ✏️ Bearbeiten} + {canEdit && ( + + )} +
+
+ ))} +
+
+ ))} +
+ ) +} + +export default function Overview({ mode = 'dashboard' }) { + const gridRef = useRef(null) + const isDashboard = mode !== 'manage' + + const [pages, setPages] = useState([]) + const [widgets, setWidgets] = useState([]) + const [projectValues, setProjectValues] = useState({}) + const [canEdit, setCanEdit] = useState(false) + const [canDesign, setCanDesign] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [dashboardError, setDashboardError] = useState('') + const [modalDraft, setModalDraft] = useState(null) + const [dragState, setDragState] = useState(null) + const [createModalOpen, setCreateModalOpen] = useState(false) + + function openCreated(pageId) { + window.location.href = `/visu/?mode=designer&page_id=${pageId}` + } + + async function loadPages() { + const data = await api('pages') + setPages(data.pages || []) + setCanEdit(Boolean(data.can_edit)) + setCanDesign(Boolean(data.can_design)) + } + + async function loadWidgets() { + const data = await api('dashboard_widgets') + setWidgets((data.widgets || []).map(normalizeWidget)) + } + + useEffect(() => { + setLoading(true) + setError('') + + const loader = isDashboard + ? Promise.all([loadPages(), loadWidgets()]) + : Promise.all([loadPages()]) + + loader + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, [isDashboard]) + + const usedProjectIds = useMemo(() => { + const ids = new Set() + widgets.forEach((widget) => { + if (widget.project_id > 0) ids.add(widget.project_id) + }) + return Array.from(ids) + }, [widgets]) + + const projectLabels = useMemo(() => { + const map = {} + + pages.forEach((page) => { + if (!page.project_id || map[page.project_id]) return + + map[page.project_id] = [ + page.year, + page.project_number, + page.project_name, + ].filter(Boolean).join(' · ') + }) + + return map + }, [pages]) + + const groupedPages = useMemo(() => groupPagesByProject(pages), [pages]) + + useEffect(() => { + if (!isDashboard) { + setProjectValues({}) + return + } + + if (usedProjectIds.length === 0) { + setProjectValues({}) + return + } + + let stop = false + + async function loop() { + try { + const entries = await Promise.all( + usedProjectIds.map(async (projectId) => { + const data = await api('project_values', { project_id: projectId }) + return [projectId, toValueMap(data.values || [])] + }), + ) + + if (!stop) { + setProjectValues(Object.fromEntries(entries)) + } + } catch (err) { + if (!stop) setDashboardError(err.message) + } + } + + setDashboardError('') + loop() + const timer = window.setInterval(loop, 5000) + + return () => { + stop = true + window.clearInterval(timer) + } + }, [isDashboard, usedProjectIds]) + + useEffect(() => { + if (!dragState) return undefined + + function metrics() { + const rect = gridRef.current?.getBoundingClientRect() + if (!rect) return null + + const cellWidth = (rect.width - DASHBOARD_GAP * (DASHBOARD_COLS - 1)) / DASHBOARD_COLS + return { + rect, + cellWidth, + fullWidth: cellWidth + DASHBOARD_GAP, + fullHeight: DASHBOARD_ROW_HEIGHT + DASHBOARD_GAP, + } + } + + function onMove(e) { + const m = metrics() + if (!m) return + + if (dragState.mode === 'move') { + const relX = e.clientX - m.rect.left + const relY = e.clientY - m.rect.top + const grid_x = Math.max(0, Math.min(DASHBOARD_COLS - dragState.preview.grid_w, Math.round(relX / m.fullWidth))) + const grid_y = Math.max(0, Math.round(relY / m.fullHeight)) + setDragState((old) => ({ ...old, preview: { ...old.preview, grid_x, grid_y } })) + return + } + + const diffX = e.clientX - dragState.startX + const diffY = e.clientY - dragState.startY + const grid_w = Math.max(1, Math.min(4, dragState.startW + Math.round(diffX / m.fullWidth))) + const grid_h = Math.max(1, Math.min(4, dragState.startH + Math.round(diffY / m.fullHeight))) + const grid_x = Math.max(0, Math.min(DASHBOARD_COLS - grid_w, dragState.preview.grid_x)) + + setDragState((old) => ({ + ...old, + preview: { + ...old.preview, + grid_w, + grid_h, + grid_x, + }, + })) + } + + async function onUp() { + const target = widgets.find((widget) => Number(widget.id) === Number(dragState.widgetId)) + if (!target) { + setDragState(null) + return + } + + const placed = placeWidget( + { + ...target, + ...dragState.preview, + }, + widgets, + target.id, + ) + + const next = widgets.map((widget) => ( + Number(widget.id) === Number(target.id) + ? { ...widget, ...placed } + : widget + )) + + setWidgets(next) + setDragState(null) + + try { + await saveWidget({ ...target, ...placed }) + } catch { + await loadWidgets() + } + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp, { once: true }) + + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + }, [dragState, widgets]) + + async function saveWidget(widget) { + const payload = { + widget_id: widget.id || '', + widget_type: widget.widget_type, + title: widget.title || '', + project_id: widget.project_id || '', + grid_x: widget.grid_x, + grid_y: widget.grid_y, + grid_w: widget.grid_w, + grid_h: widget.grid_h, + sort_order: widget.sort_order ?? 100, + config_json: JSON.stringify(widget.config || {}), + } + + const data = await apiPost('dashboard_widget_save', payload) + const saved = { ...widget, id: Number(data.widget_id || widget.id || 0) } + + setWidgets((old) => { + const exists = old.some((entry) => Number(entry.id) === Number(saved.id)) + if (exists) { + return old.map((entry) => (Number(entry.id) === Number(saved.id) ? saved : entry)) + } + return [...old, saved] + }) + + return saved + } + + async function deletePage(pageId) { + if (!confirm('Visualisierung wirklich deaktivieren?')) return + + try { + await apiPost('page_delete', { page_id: pageId }) + await loadPages() + } catch (err) { + alert(err.message) + } + } + + async function deleteWidget(widgetId) { + if (!confirm('Widget wirklich löschen?')) return + + try { + await apiPost('dashboard_widget_delete', { widget_id: widgetId }) + setWidgets((old) => old.filter((widget) => Number(widget.id) !== Number(widgetId))) + } catch (err) { + alert(err.message) + } + } + + function newWidgetDraft(type) { + const base = type === 'simple_value' + ? { grid_w: 1, grid_h: 1 } + : type === 'value_table' + ? { grid_w: 2, grid_h: 2 } + : type === 'dial_gauge' + ? { grid_w: 2, grid_h: 2 } + : { grid_w: 2, grid_h: 2 } + + return { + id: 0, + widget_type: type, + title: '', + project_id: 0, + grid_x: 0, + grid_y: 0, + grid_w: base.grid_w, + grid_h: base.grid_h, + sort_order: widgets.length * 10 + 100, + config: defaultWidgetConfig(type), + } + } + + async function saveFromModal(form) { + try { + const placed = placeWidget(form, widgets, form.id) + await saveWidget(placed) + setModalDraft(null) + } catch (err) { + alert(err.message) + } + } + + function widgetAtRender(widget) { + if (!dragState || Number(dragState.widgetId) !== Number(widget.id)) return widget + return { ...widget, ...dragState.preview } + } + + if (!isDashboard) { + return ( +
+
+
+
+
SE-Cloud Vis
+

Verwaltung

+
+ +
+ {canEdit && ( + + )} + +
+
+ + {error &&
{error}
} + + {!error && ( +
+ +
+ )} +
+ + setCreateModalOpen(false)} onCreated={openCreated} /> +
+ ) + } + + return ( +
+
+
+
+
SE-Cloud Vis
+

Dashboard

+
+ + +
+ + {error &&
{error}
} + + {!error && ( +
+ + +
+
+
+
Dashboard
+
Widgets lassen sich per Ziehen verschieben und unten rechts in der Größe ändern.
+
+ +
+ {WIDGET_TYPES.map((type) => ( + + ))} +
+
+ + {dashboardError &&
{dashboardError}
} + +
+ {widgets.length === 0 && ( +
+ Noch keine Dashboard-Widgets angelegt. +
+ )} + + {widgets.map((widget) => { + const layout = widgetAtRender(widget) + + return ( +
+
{ + if (e.button !== 0) return + if (e.target.closest('button, a')) return + setDragState({ + mode: 'move', + widgetId: widget.id, + preview: { + grid_x: layout.grid_x, + grid_y: layout.grid_y, + grid_w: layout.grid_w, + grid_h: layout.grid_h, + }, + }) + }}> +
+ {widget.title || widgetTypeLabel(widget.widget_type)} + {projectLabels[widget.project_id] || 'Kein Projekt gewählt'} +
+ +
+ + +
+
+ +
+ +
+ + +
+ ) + })} +
+
+
+ )} +
+ + setModalDraft(null)} + onSave={saveFromModal} + /> +
+ ) +} diff --git a/visu-src/src/pages/TemplateDesigner.jsx b/visu-src/src/pages/TemplateDesigner.jsx new file mode 100644 index 0000000..dc934db --- /dev/null +++ b/visu-src/src/pages/TemplateDesigner.jsx @@ -0,0 +1,172 @@ +import { useEffect, useRef, useState } from 'react' +import ViewerShortcutButton from '../components/ViewerShortcutButton' +import { api, apiPost } from '../lib/visuApi' +import Designer from './Designer' + +export default function TemplateDesigner({ templateId = 0 }) { + const fileRef = useRef(null) + const [templates, setTemplates] = useState([]) + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function loadTemplates() { + setLoading(true) + setError('') + + try { + const data = await api('templates') + setTemplates(data.templates || []) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (templateId > 0) return + loadTemplates() + }, [templateId]) + + if (templateId > 0) { + return + } + + async function createTemplate(e) { + e.preventDefault() + setError('') + + try { + const data = await apiPost('template_save', { + template_id: 0, + name: name.trim() || 'Neues Template', + description, + width: 1920, + height: 1080, + background_color: '#0f172a', + grid_enabled: '1', + snap_enabled: '1', + grid_size: 40, + placeholders_json: '[]', + elements_json: '[]', + }) + window.location.href = `/visu/?mode=templates&template_id=${data.template_id}` + } catch (err) { + setError(err.message) + } + } + + async function deleteTemplate(id) { + if (!confirm('Template wirklich löschen?')) return + + try { + await apiPost('template_delete', { template_id: id }) + await loadTemplates() + } catch (err) { + setError(err.message) + } + } + + async function exportTemplate(id) { + try { + const data = await api('template', { id }) + const payload = { + version: 1, + template: data.template, + placeholders: data.placeholders || [], + elements: data.elements || [], + } + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${data.template?.name || 'visu-template'}.json` + a.click() + URL.revokeObjectURL(url) + } catch (err) { + setError(err.message) + } + } + + async function importTemplate(file) { + if (!file) return + + try { + const text = await file.text() + await apiPost('template_import', { import_json: text }) + await loadTemplates() + fileRef.current.value = '' + } catch (err) { + setError(err.message) + } + } + + return ( +
+
+
+
+
Templates
+

Template-Verwaltung

+

Vorlagen mit Platzhaltern für wiederkehrende Visualisierungen.

+
+ + +
+ +
+
+
+ + setName(e.target.value)} placeholder="z.B. Heizkreis Standard" /> +
+
+ + setDescription(e.target.value)} placeholder="Optional" /> +
+ + + importTemplate(e.target.files?.[0])} + /> +
+ + {error &&
{error}
} + {loading &&
Templates werden geladen…
} + +
+ {templates.map((template) => ( +
+
{template.name}
+
+ {template.width}×{template.height} + {template.placeholder_count ? ` · ${template.placeholder_count} Platzhalter` : ''} +
+ {template.description &&
{template.description}
} +
+ Bearbeiten + + +
+
+ ))} +
+
+
+
+ ) +} diff --git a/visu-src/src/pages/Viewer.jsx b/visu-src/src/pages/Viewer.jsx new file mode 100644 index 0000000..2172d99 --- /dev/null +++ b/visu-src/src/pages/Viewer.jsx @@ -0,0 +1,1261 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { api, apiPost } from '../lib/visuApi' +import ShapeGraphic from '../components/ShapeGraphic' +import TextGraphic from '../components/TextGraphic' +import TrendChartGraphic from '../components/TrendChartGraphic' +import ValueInstrumentGraphic from '../components/ValueInstrumentGraphic' +import SankeyGraphic from '../components/SankeyGraphic' +import { rememberViewerPage } from '../components/ViewerShortcutButton' +import { refreshTemplateInstances } from '../lib/templateInstances' +import { + compareRule, + formatLiveValue, + livePointFromRef, + normalizeElement, + normalizePage, + resolveElementVisibility, + resolveTemplateRef, + sourceKeyFromRef, + groupPagesByProject, + valueCardBackground, +} from '../lib/visuUtils' + +const VIEWER_TOOLTIP_SESSION_KEY = 'visu.viewer.tooltips.enabled' +const VIEWER_TOOLTIP_POSITION_SESSION_KEY = 'visu.viewer.tooltips.position' +const VIEWER_TOOLTIP_WIDTH = 300 +const VIEWER_TOOLTIP_HEIGHT = 220 +const VIEWER_TOOLTIP_GAP = 10 +const VIEWER_TREND_RANGE_SESSION_KEY = 'visu.viewer.trend.range' +const VIEWER_TREND_RANGES = [ + { value: '15m', label: '15 Min' }, + { value: '1h', label: '1 h' }, + { value: '6h', label: '6 h' }, + { value: '12h', label: '12 h' }, + { value: '24h', label: '24 h' }, + { value: '7d', label: '7 Tage' }, + { value: '30d', label: '30 Tage' }, + { value: '90d', label: '90 Tage' }, + { value: '180d', label: '6 Monate' }, +] + +function clampNumber(value, min, max) { + return Math.max(min, Math.min(max, value)) +} + +function resolveAssetImage(element, liveValues) { + const style = element.style_json || {} + const rules = [...(element.rules || [])].sort((a, b) => Number(a.priority || 100) - Number(b.priority || 100)) + + for (const rule of rules) { + const resolvedRule = resolveTemplateRef(rule, style) + const point = livePointFromRef(resolvedRule, liveValues) + + if (point && compareRule(resolvedRule, point)) { + return { image: resolvedRule.image_file || '', hasState: true } + } + } + + const defaultState = + element.asset_states?.find((s) => s.is_default) || + element.asset_states?.[0] || + null + + if (defaultState) { + return { image: defaultState.image_file || '', hasState: true } + } + + if (element.style_json?.default_image) { + return { image: element.style_json.default_image, hasState: true } + } + + return { image: '', hasState: false } +} + +function tooltipEnabledDefault() { + try { + return sessionStorage.getItem(VIEWER_TOOLTIP_SESSION_KEY) !== '0' + } catch { + return true + } +} + +function tooltipPositionDefault() { + try { + return sessionStorage.getItem(VIEWER_TOOLTIP_POSITION_SESSION_KEY) || 'cursor' + } catch { + return 'cursor' + } +} + +function trendRangeDefault() { + try { + return sessionStorage.getItem(VIEWER_TREND_RANGE_SESSION_KEY) || '24h' + } catch { + return '24h' + } +} + +function formatAge(ageSeconds) { + const age = Number(ageSeconds) + if (!Number.isFinite(age) || age < 0) return 'Unbekannt' + if (age < 60) return `vor ${Math.round(age)} s` + if (age < 3600) return `vor ${Math.round(age / 60)} min` + if (age < 86400) return `vor ${Math.round(age / 3600)} h` + return `vor ${Math.round(age / 86400)} d` +} + +function unitForValue(point, style = {}) { + if (style.unitMode === 'custom') return String(style.unit || '').trim() + if (style.unitMode === 'hidden') return '' + return String(point?.unit || '').trim() +} + +function formatElementValue(point, style = {}, fallback) { + const value = formatLiveValue(point, style.decimals ?? 1, fallback) + const unit = unitForValue(point, style) + if (!unit || value.includes(unit)) return value + return `${value} ${unit}` +} + +function uniqueLiveValueRows(liveValues) { + const seen = new Set() + return Object.values(liveValues || {}).filter((point) => { + if (!point || typeof point !== 'object') return false + const key = point.source_key || [point.type, point.external_id, point.variable_id, point.point_key].join('|') + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function csvEscape(value, delimiter = ',') { + const text = String(value ?? '') + return /["\r\n]/.test(text) || text.includes(delimiter) + ? `"${text.replace(/"/g, '""')}"` + : text +} + +function collectTooltipItemsFromRefs(refs, liveValues, style = {}) { + const seen = new Set() + const items = [] + + for (const rawRef of refs) { + const ref = resolveTemplateRef(rawRef, style) + if (!ref) continue + const key = sourceKeyFromRef(ref) + if (!key || seen.has(key)) continue + seen.add(key) + + const point = livePointFromRef(ref, liveValues) + + items.push({ + key, + label: point?.label || ref.point_label || ref.label || 'Datenpunkt', + value: point ? formatLiveValue(point, 1) : 'Keine Live-Daten', + updated: formatAge(point?.age_s), + quality: point?.quality ?? 'Unbekannt', + unit: point?.unit || '', + pointKey: point?.point_key || ref.point_key || '', + type: point?.type || ref.variable_type || '', + group: point?.group || '', + source: point?.external_id || ref.external_id || '', + point, + }) + } + + return items +} + +function tooltipItemsForElement(element, liveValues) { + const style = element.style_json || {} + + if (element.element_type === 'asset') { + return collectTooltipItemsFromRefs(element.rules || [], liveValues, style) + } + + if (element.element_type === 'shape') { + const refs = [ + ...(Array.isArray(style.colorRules) ? style.colorRules : []), + style.flowPoint || null, + ] + return collectTooltipItemsFromRefs(refs, liveValues, style) + } + + if (element.element_type === 'text') { + return collectTooltipItemsFromRefs(style.textRules || [], liveValues, style) + } + + if (element.element_type === 'value') { + const refs = [style.point || null] + if (!style.point && element.variable_id) { + refs.push({ + variable_id: element.variable_id, + point_label: element.label || element.name || 'Datenpunkt', + }) + } + return collectTooltipItemsFromRefs(refs, liveValues, style) + } + + return [] +} + +function ViewerTooltip({ tooltip }) { + if (!tooltip?.items?.length) return null + + return createPortal( +
+ {tooltip.title &&
{tooltip.title}
} + +
+ {tooltip.items.map((item) => ( +
+ {item.label} + {item.value} + {item.pointKey && Datenpunkt: {item.pointKey}} + {item.type && Typ: {item.type}} + {item.group && Gruppe: {item.group}} + Zuletzt aktualisiert: {item.updated} + Qualität: {item.quality ?? 'Unbekannt'} +
+ ))} +
+
, + document.body + ) +} + +function AttachedValues({ element, liveValues, tooltipsEnabled, onShowTooltip, onMoveTooltip, onHideTooltip }) { + const values = element.style_json?.attachedValues || [] + const style = element.style_json || {} + if (!Array.isArray(values) || values.length === 0) return null + + return ( + <> + {values.slice(0, 3).map((item, index) => { + const ref = resolveTemplateRef(item, style) + const point = livePointFromRef(ref, liveValues) + const txt = formatLiveValue(point, item.decimals ?? 1) + const fontSize = Number(item.fontSize || 13) + const tooltipItems = collectTooltipItemsFromRefs([item], liveValues, style) + + return ( +
0 ? (e) => { + e.stopPropagation() + onShowTooltip(e, tooltipItems, item.label || point?.label || `Wert ${index + 1}`) + } : undefined} + onPointerMove={tooltipsEnabled && tooltipItems.length > 0 ? (e) => { + e.stopPropagation() + onMoveTooltip(e) + } : undefined} + onPointerLeave={tooltipsEnabled && tooltipItems.length > 0 ? (e) => { + e.stopPropagation() + onHideTooltip(e) + } : undefined} + > + + {item.label || point?.raw_label || `Wert ${index + 1}`} + + {txt} +
+ ) + })} + + ) +} + +function SetpointEditor({ pageId, element, point, canWrite, onWritten }) { + const [open, setOpen] = useState(false) + const [value, setValue] = useState('') + const [saving, setSaving] = useState(false) + + if (element.style_json?.kind !== 'setpoint') return null + if (!canWrite) return null + + async function writeValue(e) { + e.preventDefault() + setSaving(true) + + try { + await apiPost('set_value', { + page_id: pageId, + external_id: element.style_json?.point?.external_id || '', + variable_id: element.style_json?.point?.variable_id || '', + point_key: element.style_json?.point?.point_key || '', + value, + }) + + setOpen(false) + setValue('') + await onWritten?.() + } catch (err) { + alert(err.message) + } finally { + setSaving(false) + } + } + + return ( + <> + + + {open && ( +
e.stopPropagation()}> +
+
Sollwert schreiben
+
{element.label}
+ + setValue(e.target.value)} + placeholder={String(point?.value ?? '')} + autoFocus + /> + +
+ + + +
+
+
+ )} + + ) +} + +function ElementView({ + pageId, + element, + liveValue, + liveValues, + canWriteSetpoints, + onSetpointWritten, + tooltipsEnabled, + onShowTooltip, + onMoveTooltip, + onHideTooltip, + onExpandTrend, + trendRange, + trendCustomRange, + onTrendRangeChange, +}) { + const style = element.style_json || {} + const color = style.color || '#60a5fa' + const transform = `translate(${element.x}px, ${element.y}px) rotate(${element.rotation || 0}deg)` + if (!resolveElementVisibility(element, liveValues)) return null + + const tooltipItems = tooltipItemsForElement(element, liveValues) + const tooltipTitle = element.label || element.name || 'Datenpunkt' + const tooltipHandlers = tooltipsEnabled && tooltipItems.length > 0 + ? { + onPointerEnter: (e) => onShowTooltip(e, tooltipItems, tooltipTitle), + onPointerMove: onMoveTooltip, + onPointerLeave: onHideTooltip, + } + : {} + + return ( +
+ {element.element_type === 'asset' && ( + <> + {(() => { + const assetRender = resolveAssetImage(element, liveValues) + + if (assetRender.image) { + return ( + {element.label + ) + } + + return assetRender.hasState ? null :
Kein Zustand
+ })()} + + + + )} + + {element.element_type === 'text' && ( + + )} + + {element.element_type === 'value' && style.kind === 'trend_chart' && ( + + )} + + {element.element_type === 'value' && style.kind === 'sankey_chart' && ( + + )} + + {element.element_type === 'value' && ['scale_bar', 'dial_gauge'].includes(style.kind) && ( + + )} + + {element.element_type === 'value' && !['scale_bar', 'dial_gauge', 'trend_chart', 'sankey_chart'].includes(style.kind) && ( +
+
+ {style.marker || (style.kind === 'setpoint' ? 'SP' : 'T')} +
+ +
+
{element.label || (style.kind === 'setpoint' ? 'Sollwert' : 'Fühler')}
+
{formatElementValue(liveValue, style)}
+
+ + +
+ )} + + {element.element_type === 'shape' && } +
+ ) +} + +function ProjectTree({ currentPageId, canDesign, onToggle }) { + const [pages, setPages] = useState([]) + const [q, setQ] = useState('') + const [openProjects, setOpenProjects] = useState({}) + const [error, setError] = useState('') + + useEffect(() => { + api('pages') + .then((data) => { + const nextPages = data.pages || [] + setPages(nextPages) + + const current = nextPages.find((p) => Number(p.id) === Number(currentPageId)) + if (current?.project_id) { + setOpenProjects((old) => ({ + ...old, + [current.project_id]: true, + })) + } + }) + .catch((err) => setError(err.message)) + }, [currentPageId]) + + const filteredPages = useMemo(() => { + const needle = q.trim().toLowerCase() + if (!needle) return pages + + return pages.filter((p) => { + const hay = [ + p.name, + p.description, + p.year, + p.project_number, + p.project_name, + p.customer_name, + ].filter(Boolean).join(' ').toLowerCase() + + return hay.includes(needle) + }) + }, [pages, q]) + + const projectGroups = useMemo(() => groupPagesByProject(filteredPages), [filteredPages]) + + function toggleProject(projectId) { + setOpenProjects((old) => ({ + ...old, + [projectId]: !old[projectId], + })) + } + + if (error) { + return ( + + ) + } + + return ( + + ) +} + +export default function Viewer({ pageId }) { + const viewerScrollRef = useRef(null) + const fitPlaneRef = useRef(null) + const viewerCanvasRef = useRef(null) + const liveValuesInFlightRef = useRef(false) + const [page, setPage] = useState(null) + const [elements, setElements] = useState([]) + const [liveValues, setLiveValues] = useState({}) + const [canWriteSetpoints, setCanWriteSetpoints] = useState(false) + const [canDesign, setCanDesign] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(Boolean(pageId)) + const [treeOpen, setTreeOpen] = useState(true) + const [viewerScale, setViewerScale] = useState(1) + const [focusMode, setFocusMode] = useState(false) + const [tooltipsEnabled, setTooltipsEnabled] = useState(tooltipEnabledDefault) + const [tooltipPosition, setTooltipPosition] = useState(tooltipPositionDefault) + const [hoverTooltip, setHoverTooltip] = useState(null) + const [expandedTrend, setExpandedTrend] = useState(null) + const [trendRange, setTrendRange] = useState(trendRangeDefault) + const [trendCustomRange, setTrendCustomRange] = useState(null) + + useEffect(() => { + rememberViewerPage(pageId) + }, [pageId]) + + async function loadValues() { + if (liveValuesInFlightRef.current) return + + liveValuesInFlightRef.current = true + + try { + const data = await api('live_values', { page_id: pageId }) + setLiveValues(data.values || {}) + setCanWriteSetpoints(Boolean(data.can_write_setpoints)) + } finally { + liveValuesInFlightRef.current = false + } + } + + useEffect(() => { + if (!pageId) { + setLoading(false) + return + } + + setExpandedTrend(null) + + api('page', { id: pageId }) + .then(async (data) => { + const loadedElements = (data.elements || []).map(normalizeElement) + const nextElements = await refreshTemplateInstances(loadedElements, (id) => api('template', { id })) + const firstTrend = nextElements.find((item) => item.element_type === 'value' && item.style_json?.kind === 'trend_chart') + + setPage(normalizePage(data.page || {})) + setElements(nextElements) + setTrendRange(firstTrend?.style_json?.range || trendRangeDefault()) + setTrendCustomRange(null) + setCanWriteSetpoints(Boolean(data.can_write_setpoints)) + setCanDesign(Boolean(data.can_design)) + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)) + }, [pageId]) + + useEffect(() => { + if (!pageId) return + + let stop = false + + async function loop() { + if (liveValuesInFlightRef.current) return + + liveValuesInFlightRef.current = true + + try { + const data = await api('live_values', { page_id: pageId }) + + if (!stop) { + setLiveValues(data.values || {}) + setCanWriteSetpoints(Boolean(data.can_write_setpoints)) + } + } catch { + // nächster Tick heilt meistens alles + } finally { + liveValuesInFlightRef.current = false + } + } + + loop() + const timer = setInterval(loop, 10000) + + return () => { + stop = true + clearInterval(timer) + } + }, [pageId]) + + useEffect(() => { + if (!page || !viewerScrollRef.current) return undefined + + function updateScale() { + const host = viewerScrollRef.current + if (!host) return + + const pad = 20 + const availableWidth = Math.max(120, host.clientWidth - pad * 2) + const availableHeight = Math.max(120, host.clientHeight - pad * 2) + const scaleX = availableWidth / Math.max(1, Number(page.width || 1)) + const scaleY = availableHeight / Math.max(1, Number(page.height || 1)) + const nextScale = Math.max(0.08, Math.min(2.5, Math.min(scaleX, scaleY))) + setViewerScale(nextScale) + } + + updateScale() + + const observer = new ResizeObserver(updateScale) + observer.observe(viewerScrollRef.current) + window.addEventListener('resize', updateScale) + + return () => { + observer.disconnect() + window.removeEventListener('resize', updateScale) + } + }, [page?.id, page?.width, page?.height, treeOpen, focusMode]) + + useEffect(() => { + try { + sessionStorage.setItem(VIEWER_TOOLTIP_SESSION_KEY, tooltipsEnabled ? '1' : '0') + } catch { + // ignorieren + } + }, [tooltipsEnabled]) + + useEffect(() => { + try { + sessionStorage.setItem(VIEWER_TOOLTIP_POSITION_SESSION_KEY, tooltipPosition) + } catch { + // ignorieren + } + }, [tooltipPosition]) + + useEffect(() => { + if (!tooltipsEnabled) { + setHoverTooltip(null) + } + }, [tooltipsEnabled]) + + useEffect(() => { + if (!tooltipsEnabled || tooltipPosition === 'cursor') return + + setHoverTooltip((old) => ( + old + ? { + ...old, + ...resolveTooltipPosition(null), + } + : old + )) + }, [tooltipPosition, viewerScale, treeOpen, focusMode, tooltipsEnabled]) + + useEffect(() => { + try { + sessionStorage.setItem(VIEWER_TREND_RANGE_SESSION_KEY, trendRange) + } catch { + // ignorieren + } + }, [trendRange]) + + function updateTrendRange(next) { + if (next?.customRange) { + setTrendCustomRange(next.customRange) + return + } + + const range = next?.range || '24h' + setTrendRange(range) + setTrendCustomRange(null) + } + + function resolveTooltipPosition(e) { + const viewport = { + left: 8, + top: 8, + right: window.innerWidth - 8, + bottom: window.innerHeight - 8, + } + + if (tooltipPosition === 'cursor' && e) { + let x = e.clientX + VIEWER_TOOLTIP_GAP + let y = e.clientY + VIEWER_TOOLTIP_GAP + + if (x + VIEWER_TOOLTIP_WIDTH > viewport.right) { + x = e.clientX - VIEWER_TOOLTIP_WIDTH - VIEWER_TOOLTIP_GAP + } + + if (y + VIEWER_TOOLTIP_HEIGHT > viewport.bottom) { + y = e.clientY - VIEWER_TOOLTIP_HEIGHT - VIEWER_TOOLTIP_GAP + } + + return { + x: clampNumber(x, viewport.left, Math.max(viewport.left, viewport.right - VIEWER_TOOLTIP_WIDTH)), + y: clampNumber(y, viewport.top, Math.max(viewport.top, viewport.bottom - VIEWER_TOOLTIP_HEIGHT)), + align: 'left', + } + } + + const canvasRect = fitPlaneRef.current?.getBoundingClientRect() + const hostRect = viewerScrollRef.current?.getBoundingClientRect() + const rect = canvasRect && hostRect + ? { + left: Math.max(canvasRect.left, hostRect.left), + top: Math.max(canvasRect.top, hostRect.top), + right: Math.min(canvasRect.right, hostRect.right), + bottom: Math.min(canvasRect.bottom, hostRect.bottom), + } + : (canvasRect || hostRect) + + if (!rect) { + return { + x: viewport.left + VIEWER_TOOLTIP_GAP, + y: viewport.top + VIEWER_TOOLTIP_GAP, + align: 'left', + } + } + + const left = Math.max(viewport.left, rect.left + VIEWER_TOOLTIP_GAP) + const right = Math.min(viewport.right, rect.right - VIEWER_TOOLTIP_GAP) + const top = Math.max(viewport.top, rect.top + VIEWER_TOOLTIP_GAP) + + const x = tooltipPosition === 'corner-right' + ? right - VIEWER_TOOLTIP_WIDTH + : left + + return { + x: clampNumber(x, viewport.left, Math.max(viewport.left, viewport.right - VIEWER_TOOLTIP_WIDTH)), + y: clampNumber(top, viewport.top, Math.max(viewport.top, viewport.bottom - VIEWER_TOOLTIP_HEIGHT)), + align: 'left', + } + } + + function showTooltip(e, items, title = '') { + if (!tooltipsEnabled || !Array.isArray(items) || items.length === 0) return + const position = resolveTooltipPosition(e) + + setHoverTooltip({ + ...position, + title, + items, + }) + } + + function moveTooltip(e) { + if (tooltipPosition !== 'cursor') return + + setHoverTooltip((old) => ( + old + ? { + ...old, + ...resolveTooltipPosition(e), + } + : old + )) + } + + function hideTooltip() { + setHoverTooltip(null) + } + + function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) + } + + function inlineComputedStyles(source, target) { + if (!(source instanceof Element) || !(target instanceof Element)) return + + const computed = window.getComputedStyle(source) + target.setAttribute('style', Array.from(computed).map((key) => `${key}:${computed.getPropertyValue(key)};`).join('')) + + Array.from(source.children).forEach((child, index) => { + inlineComputedStyles(child, target.children[index]) + }) + } + + function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result) + reader.onerror = () => reject(new Error('Bild konnte nicht eingebettet werden.')) + reader.readAsDataURL(blob) + }) + } + + async function urlToDataUrl(url) { + if (!url || url.startsWith('data:')) return url + const absoluteUrl = new URL(url, window.location.href).toString() + const res = await fetch(absoluteUrl, { credentials: 'same-origin' }) + if (!res.ok) throw new Error('Bild konnte nicht geladen werden.') + return blobToDataUrl(await res.blob()) + } + + async function inlineExportImages(clone) { + const images = Array.from(clone.querySelectorAll('img')) + + await Promise.all(images.map(async (image) => { + const src = image.getAttribute('src') || image.src + if (!src || src.startsWith('data:')) return + + try { + image.setAttribute('src', await urlToDataUrl(src)) + } catch { + // Export soll nicht komplett scheitern, wenn ein einzelnes Bild fehlt. + } + })) + + const nodes = [clone, ...Array.from(clone.querySelectorAll('*'))] + + await Promise.all(nodes.map(async (node) => { + const background = node.style?.backgroundImage || '' + const match = background.match(/url\(["']?([^"')]+)["']?\)/) + const url = match?.[1] + if (!url || url.startsWith('data:')) return + + try { + node.style.backgroundImage = background.replace(url, await urlToDataUrl(url)) + } catch { + // Siehe oben: fehlende Einzelbilder sollen den Export nicht blockieren. + } + })) + } + + function binaryStringToBytes(value) { + const out = new Uint8Array(value.length) + for (let i = 0; i < value.length; i += 1) out[i] = value.charCodeAt(i) + return out + } + + function jpegDataUrlToPdf(dataUrl, width, height) { + const jpeg = atob(dataUrl.split(',')[1] || '') + const pageW = Math.max(1, Math.round(width * 0.75)) + const pageH = Math.max(1, Math.round(height * 0.75)) + const objects = [ + '1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n', + '2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n', + `3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n`, + `4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n${jpeg}\nendstream\nendobj\n`, + `5 0 obj\n<< /Length ${`q\n${pageW} 0 0 ${pageH} 0 0 cm\n/Im0 Do\nQ\n`.length} >>\nstream\nq\n${pageW} 0 0 ${pageH} 0 0 cm\n/Im0 Do\nQ\nendstream\nendobj\n`, + ] + let pdf = '%PDF-1.4\n' + const offsets = [0] + + objects.forEach((obj) => { + offsets.push(pdf.length) + pdf += obj + }) + + const xref = pdf.length + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + offsets.slice(1).forEach((offset) => { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n` + }) + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF` + + return new Blob([binaryStringToBytes(pdf)], { type: 'application/pdf' }) + } + + async function renderViewerCanvasToCanvas() { + const node = viewerCanvasRef.current + if (!node || !page) throw new Error('Canvas nicht gefunden.') + + const clone = node.cloneNode(true) + inlineComputedStyles(node, clone) + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml') + clone.style.transform = 'none' + clone.style.width = `${page.width}px` + clone.style.height = `${page.height}px` + await inlineExportImages(clone) + + const svg = ` + + ${new XMLSerializer().serializeToString(clone)} + + ` + const img = new Image() + img.decoding = 'async' + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` + await new Promise((resolve, reject) => { + img.onload = resolve + img.onerror = () => reject(new Error('Canvas-Export konnte nicht gerendert werden.')) + }) + + const canvas = document.createElement('canvas') + canvas.width = page.width + canvas.height = page.height + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0) + return canvas + } + + async function exportCanvas(format) { + try { + const canvas = await renderViewerCanvasToCanvas() + const baseName = (page?.name || 'visualisierung').replace(/[^a-z0-9_-]+/gi, '_') + + if (format === 'pdf') { + const jpeg = canvas.toDataURL('image/jpeg', 0.92) + downloadBlob(jpegDataUrlToPdf(jpeg, canvas.width, canvas.height), `${baseName}.pdf`) + return + } + + const type = format === 'jpg' ? 'image/jpeg' : 'image/png' + const ext = format === 'jpg' ? 'jpg' : 'png' + canvas.toBlob((blob) => { + if (blob) downloadBlob(blob, `${baseName}.${ext}`) + }, type, 0.92) + } catch (err) { + alert(err.message || 'Export fehlgeschlagen.') + } + } + + function exportCurrentValuesCsv(delimiter = ',') { + const rows = uniqueLiveValueRows(liveValues) + const header = ['Label', 'Wert', 'Einheit', 'Typ', 'Gruppe', 'Datenpunkt', 'Quelle', 'Qualitaet', 'Alter Sekunden'] + const lines = [ + header.map((cell) => csvEscape(cell, delimiter)).join(delimiter), + ...rows.map((point) => ([ + point.label || point.raw_label || '', + point.value ?? point.value_formatted ?? '', + point.unit || '', + point.type || '', + point.group || '', + point.point_key || '', + point.external_id || '', + point.quality || '', + point.age_s ?? '', + ].map((cell) => csvEscape(cell, delimiter)).join(delimiter))), + ] + + downloadBlob( + new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), + `${(page?.name || 'visualisierung').replace(/[^a-z0-9_-]+/gi, '_')}_werte.csv` + ) + } + + return ( +
+
+ {!focusMode && ( +
+
+
Viewer
+

{page?.name || `Visualisierung #${pageId || '?'}`}

+ {page &&

{page.year} · {page.project_number} · {page.project_name}

} +
+ +
+ +
+ Trendzeit + + {trendCustomRange && ( + + )} +
+
+ Export +
+ + + + + +
+
+ + + Dashboard + Verwaltung + Templates + Datenpunkte + Virtuelle Datenpunkte + CSV Exporte + Anzeigen + {pageId > 0 && canDesign && ✏️ Bearbeiten} +
+
+ )} + + {loading &&
Visualisierung wird geladen…
} + {error &&
{error}
} + + {page && ( +
+ {treeOpen && !focusMode && ( + setTreeOpen(false)} + /> + )} + +
+ {focusMode && ( + + )} + {!treeOpen && ( + + )} +
+
+
+ {elements.map((el) => { + const style = el.style_json || {} + const pointRef = style.point ? resolveTemplateRef(style.point, style) : null + const liveValue = + (pointRef ? livePointFromRef(pointRef, liveValues) : null) || + livePointFromRef(el, liveValues) + + return ( + setExpandedTrend(el)} + trendRange={trendRange} + trendCustomRange={trendCustomRange} + onTrendRangeChange={updateTrendRange} + /> + ) + })} + + {expandedTrend && ( +
+ {}} + /> + +
+ )} +
+
+
+
+
+ )} + +
+
+ ) +} diff --git a/visu-src/src/pages/VirtualPoints.jsx b/visu-src/src/pages/VirtualPoints.jsx new file mode 100644 index 0000000..4f20156 --- /dev/null +++ b/visu-src/src/pages/VirtualPoints.jsx @@ -0,0 +1,645 @@ +import { useEffect, useMemo, useState } from 'react' +import ViewerShortcutButton from '../components/ViewerShortcutButton' +import { api, apiPost } from '../lib/visuApi' +import { livePointFromRef, sourceKeyFromRef } from '../lib/visuUtils' + +function nextTokenId(prefix) { + return `${prefix}-${Date.now()}-${Math.round(Math.random() * 100000)}` +} + +function clonePointRef(point) { + if (!point) return null + + return { + type: point.type || '', + variable_type: point.type || point.variable_type || '', + external_id: point.external_id || '', + variable_id: point.variable_id || '', + point_key: point.point_key || '', + point_label: point.label || point.point_label || '', + label: point.label || point.point_label || '', + source_key: point.source_key || sourceKeyFromRef(point), + } +} + +function tokenLabel(token) { + if (token?.type === 'point') return token.ref?.point_label || token.ref?.label || token.ref?.point_key || '[Quelle]' + if (token?.type === 'number') return token.value || '0' + if (token?.type === 'operator') return token.value || '+' + return '?' +} + +function tokenTypeLabel(token) { + if (token?.type === 'point') return 'Quelle' + if (token?.type === 'number') return 'Zahl' + if (token?.type === 'operator') return 'Operator' + return 'Baustein' +} + +function buildFormulaPreview(tokens) { + return tokens.map((token) => tokenLabel(token)).join(' ') +} + +function slugifyPointKey(value) { + return String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .replace(/_{2,}/g, '_') +} + +function displayPointValue(point) { + if (!point) return '--' + return point.value_formatted || point.value || '--' +} + +function normalizeFormula(formula) { + if (!Array.isArray(formula) || !formula.length) { + return [ + { id: nextTokenId('token-point'), type: 'point', ref: null }, + { id: nextTokenId('token-op'), type: 'operator', value: '+' }, + { id: nextTokenId('token-number'), type: 'number', value: '0' }, + ] + } + + return formula.map((token) => ({ + ...token, + id: token.id || nextTokenId('token'), + })) +} + +function createEmptyDraft(projectId = 0) { + return { + id: 0, + project_id: projectId, + point_key: '', + name: '', + description: '', + unit: '', + decimals: 2, + sort_order: 100, + formula: normalizeFormula([]), + } +} + +function mapPointRowToDraft(item, fallbackProjectId) { + return { + id: Number(item.id || 0), + project_id: Number(item.project_id || fallbackProjectId || 0), + point_key: item.point_key || '', + name: item.name || '', + description: item.description || '', + unit: item.unit || '', + decimals: Number(item.decimals ?? 2), + sort_order: Number(item.sort_order ?? 100), + formula: normalizeFormula(item.formula), + } +} + +function ProjectSelect({ value, onChange, disabled = false }) { + const [projects, setProjects] = useState([]) + const [q, setQ] = useState('') + + useEffect(() => { + api('projects') + .then((data) => setProjects(data.projects || [])) + .catch(() => setProjects([])) + }, []) + + const filtered = useMemo(() => projects.filter((project) => { + const hay = [project.year, project.project_number, project.name, project.customer_name].filter(Boolean).join(' ').toLowerCase() + return hay.includes(q.trim().toLowerCase()) + }), [projects, q]) + + return ( +
+ + setQ(e.target.value)} + placeholder="Projekt suchen" + /> + +
+ ) +} + +function FormulaPointSelect({ projectId, value, onChange, excludePointKey = '', disabled = false }) { + const [values, setValues] = useState([]) + const [q, setQ] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!projectId) { + setValues([]) + return + } + + let alive = true + setLoading(true) + + api('project_values', { project_id: projectId }) + .then((data) => { + if (alive) setValues(data.values || []) + }) + .catch(() => { + if (alive) setValues([]) + }) + .finally(() => { + if (alive) setLoading(false) + }) + + return () => { + alive = false + } + }, [projectId]) + + const filtered = useMemo(() => values.filter((item) => { + if (excludePointKey && item.type === 'virtual' && item.point_key === excludePointKey) return false + + const hay = [ + item.type, + item.label, + item.point_key, + item.value_formatted, + item.group, + item.external_id, + ].filter(Boolean).join(' ').toLowerCase() + + return hay.includes(q.trim().toLowerCase()) + }), [values, q, excludePointKey]) + + function handleSelect(sourceKey) { + const point = values.find((item) => item.source_key === sourceKey) + onChange(point ? clonePointRef(point) : null) + } + + return ( +
+ + setQ(e.target.value)} + placeholder={loading ? 'Laedt...' : 'Datenpunkt oder VDP suchen'} + /> + +
+ ) +} + +function FormulaEditor({ + projectId, + pointKey, + targetLabel, + targetPointKey, + tokens, + liveValuesMap, + targetValue, + onChange, + disabled = false, +}) { + const [dragIndex, setDragIndex] = useState(-1) + + function updateToken(index, patch) { + const next = [...tokens] + next[index] = { ...next[index], ...patch } + onChange(next) + } + + function addToken(type, patch = {}) { + onChange([ + ...tokens, + { + id: nextTokenId(`token-${type}`), + type, + ...(type === 'number' ? { value: '0' } : {}), + ...(type === 'operator' ? { value: '+' } : {}), + ...(type === 'point' ? { ref: null } : {}), + ...patch, + }, + ]) + } + + function removeToken(index) { + onChange(tokens.filter((_, tokenIndex) => tokenIndex !== index)) + } + + function moveToken(fromIndex, toIndex) { + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return + const next = [...tokens] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + onChange(next) + } + + const preview = buildFormulaPreview(tokens) + + return ( +
+
+
+
Baustein-Editor
+
Quellen, Zahlen und Operatoren von links nach rechts verbinden.
+
+
{tokens.length} Bausteine
+
+ +
+ + + {['+', '-', '*', '/', '(', ')'].map((operator) => ( + + ))} +
+ +
+
+
+ {tokens.map((token, index) => ( +
setDragIndex(index)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => { + moveToken(dragIndex, index) + setDragIndex(-1) + }} + onDragEnd={() => setDragIndex(-1)} + > + + +
+
+
+ {tokenTypeLabel(token)} +
{tokenLabel(token)}
+
+ +
+ + {token.type === 'point' && ( + <> + updateToken(index, { ref })} + /> +
+ Aktuell + {displayPointValue(livePointFromRef(token.ref || null, liveValuesMap))} +
+ + )} + + {token.type === 'number' && ( +
+ + updateToken(index, { value: e.target.value })} /> +
+ )} + + {token.type === 'operator' && ( +
+ + +
+ )} + +
+ + +
+
+
+ ))} + +
e.preventDefault()} + onDrop={() => { + moveToken(dragIndex, Math.max(0, tokens.length - 1)) + setDragIndex(-1) + }} + > + +
+
Ziel
+ {targetLabel || 'Neuer virtueller Datenpunkt'} + {targetPointKey || 'point_key fehlt noch'} +
+ Aktuell + {displayPointValue(targetValue)} +
+
+
+
+
+
+ +
{preview || 'Noch keine Formel vorhanden.'}
+
+ ) +} + +export default function VirtualPoints() { + const params = new URLSearchParams(window.location.search) + const [projectId, setProjectId] = useState(Number(params.get('project_id') || 0)) + const [points, setPoints] = useState([]) + const [projectValues, setProjectValues] = useState([]) + const [activeId, setActiveId] = useState(0) + const [draft, setDraft] = useState(createEmptyDraft(projectId)) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [canEdit, setCanEdit] = useState(false) + + async function loadProjectValues(currentProjectId) { + if (!currentProjectId) { + setProjectValues([]) + return + } + + try { + const valuesData = await api('project_values', { project_id: currentProjectId }) + setProjectValues(valuesData.values || []) + } catch { + // keep previous live values when a refresh fails + } + } + + async function loadVirtualPoints(currentProjectId) { + if (!currentProjectId) { + setPoints([]) + setProjectValues([]) + setActiveId(0) + setDraft(createEmptyDraft(0)) + return + } + + setLoading(true) + setError('') + + try { + const [vdpData, valuesData] = await Promise.all([ + api('virtual_points', { project_id: currentProjectId }), + api('project_values', { project_id: currentProjectId }), + ]) + + const rows = vdpData.virtual_points || [] + setPoints(rows) + setProjectValues(valuesData.values || []) + setCanEdit(Boolean(vdpData.can_edit)) + + if (rows.length > 0) { + const initial = rows[0] + setActiveId(Number(initial.id || 0)) + setDraft(mapPointRowToDraft(initial, currentProjectId)) + } else { + setActiveId(0) + setDraft(createEmptyDraft(currentProjectId)) + } + } catch (err) { + setError(err.message) + setPoints([]) + setProjectValues([]) + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadVirtualPoints(projectId) + }, [projectId]) + + useEffect(() => { + if (!projectId) return undefined + + const timer = window.setInterval(() => { + loadProjectValues(projectId) + }, 5000) + + return () => window.clearInterval(timer) + }, [projectId]) + + const effectivePointKey = useMemo(() => ( + draft.point_key || slugifyPointKey(draft.name) + ), [draft.point_key, draft.name]) + + const liveValuesMap = useMemo(() => { + const map = {} + + projectValues.forEach((point) => { + if (point?.source_key) map[point.source_key] = point + if (point?.variable_id) map[String(point.variable_id)] = point + }) + + return map + }, [projectValues]) + + const currentValue = useMemo(() => { + if (!effectivePointKey) return null + return projectValues.find((point) => point.type === 'virtual' && point.point_key === effectivePointKey) || null + }, [projectValues, effectivePointKey]) + + function selectPoint(item) { + setActiveId(Number(item.id || 0)) + setDraft(mapPointRowToDraft(item, projectId)) + } + + function createNew() { + setActiveId(0) + setDraft(createEmptyDraft(projectId)) + } + + async function savePoint() { + if (!projectId) { + setError('Bitte zuerst ein Projekt waehlen.') + return + } + + setSaving(true) + setError('') + + try { + await apiPost('virtual_point_save', { + point_id: draft.id || '', + project_id: projectId, + point_key: effectivePointKey, + name: draft.name, + description: draft.description, + unit: draft.unit, + decimals: draft.decimals, + sort_order: draft.sort_order, + formula_json: JSON.stringify(draft.formula || []), + }) + + await loadVirtualPoints(projectId) + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + async function deletePoint() { + if (!draft.id) return + + setError('') + + try { + await apiPost('virtual_point_delete', { point_id: draft.id }) + await loadVirtualPoints(projectId) + } catch (err) { + setError(err.message) + } + } + + return ( +
+
+
+
+
Virtuelle Datenpunkte
+

VDP Editor

+

Projektbezogene Formeln als links-nach-rechts Bausteinkette bauen und wie normale Werte in der Visu verwenden.

+
+ +
+ + {error &&
{error}
} + +
+ + +
+
+ + { + const name = e.target.value + setDraft((old) => ({ + ...old, + name, + point_key: slugifyPointKey(name), + })) + }} + /> +
+ +
+
+ + +
+
+ + setDraft((old) => ({ ...old, unit: e.target.value }))} placeholder="z.B. kW" /> +
+
+ + setDraft((old) => ({ ...old, decimals: Math.max(0, Math.min(8, Number(e.target.value || 0))) }))} /> +
+
+ + setDraft((old) => ({ ...old, sort_order: Number(e.target.value || 100) }))} /> +
+
+ +
+ +