init
This commit is contained in:
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>visu-src</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2410
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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 <Designer pageId={route.pageId} />
|
||||
if (route.mode === 'templates') return <TemplateDesigner templateId={route.templateId} />
|
||||
if (route.mode === 'monitoring') return <MonitoringData />
|
||||
if (route.mode === 'csv_exports') return <CsvExports />
|
||||
if (route.mode === 'virtual_points') return <VirtualPoints />
|
||||
if (route.mode === 'viewer') return <Viewer pageId={route.pageId} />
|
||||
if (route.mode === 'manage') return <Overview mode="manage" />
|
||||
|
||||
return <Overview mode="dashboard" />
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 (
|
||||
<div className="field compact-field">
|
||||
<label>API / VDP Quelle</label>
|
||||
<input
|
||||
className="form-input"
|
||||
disabled={disabled}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={loading ? 'Laedt...' : 'Datenpunkt suchen'}
|
||||
/>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={disabled || loading}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleSelect(e.target.value)}
|
||||
>
|
||||
<option value="">Quelle waehlen</option>
|
||||
{filtered.slice(0, 500).map((point) => (
|
||||
<option key={point.source_key} value={point.source_key}>
|
||||
{point.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="sankey-influx-picker">
|
||||
<div className="prop-grid-2">
|
||||
<div className="field compact-field">
|
||||
<label>Influx DB</label>
|
||||
<select className="form-input" disabled={disabled || loading} value={database} onChange={(e) => setDatabase(e.target.value)}>
|
||||
<option value="">DB waehlen</option>
|
||||
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Measurement</label>
|
||||
<select className="form-input" disabled={disabled || loading} value={measurementFilter} onChange={(e) => setMeasurementFilter(e.target.value)}>
|
||||
<option value="">Alle Measurements</option>
|
||||
{measurements.map((measurement) => <option key={measurement} value={measurement}>{measurement}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field compact-field">
|
||||
<label>Influx Quelle</label>
|
||||
<input
|
||||
className="form-input"
|
||||
disabled={disabled}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={loading ? 'Laedt...' : 'Measurement / Tag suchen'}
|
||||
/>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={disabled || loading}
|
||||
value={value ? influxPointKey(value) : ''}
|
||||
onChange={(e) => handleSelect(e.target.value)}
|
||||
>
|
||||
<option value="">Influx Punkt waehlen</option>
|
||||
{filtered.slice(0, 500).map((point) => (
|
||||
<option key={influxPointKey(point)} value={influxPointKey(point)}>
|
||||
{point.measurement} - {point.tag_value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="sankey-editor">
|
||||
<div className="sankey-overview-strip">
|
||||
<div className="sankey-overview-card">
|
||||
<span>Knoten</span>
|
||||
<strong>{nodes.length}</strong>
|
||||
</div>
|
||||
<div className="sankey-overview-card">
|
||||
<span>Sections</span>
|
||||
<strong>{links.length}</strong>
|
||||
</div>
|
||||
<div className="sankey-overview-card">
|
||||
<span>API / VDP</span>
|
||||
<strong>{pointCount}</strong>
|
||||
</div>
|
||||
<div className="sankey-overview-card">
|
||||
<span>Influx</span>
|
||||
<strong>{influxCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sankey-control-grid">
|
||||
<div className="sankey-control-card">
|
||||
<div className="small-title">Daten</div>
|
||||
<div className="prop-grid-2">
|
||||
<div className="field">
|
||||
<label>Aktualisierung (s)</label>
|
||||
<input disabled={disabled} type="number" min="0" value={Number(style.refreshSeconds ?? 30)} onChange={(e) => updateStyle({ refreshSeconds: Math.max(0, Number(e.target.value || 0)) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nachkommastellen</label>
|
||||
<input disabled={disabled} type="number" min="0" max="8" value={Number(style.decimals ?? 1)} onChange={(e) => updateStyle({ decimals: Math.max(0, Math.min(8, Number(e.target.value || 0))) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Einheit</label>
|
||||
<input disabled={disabled} value={style.unit || ''} onChange={(e) => updateStyle({ unit: e.target.value })} placeholder="z.B. kW" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Eckenradius</label>
|
||||
<input disabled={disabled} type="number" min="0" max="40" value={Number(style.radius ?? 14)} onChange={(e) => updateStyle({ radius: Number(e.target.value || 14) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sankey-control-card">
|
||||
<div className="small-title">Look and Feel</div>
|
||||
<div className="prop-grid-2">
|
||||
<div className="field compact-field">
|
||||
<label>Hintergrund</label>
|
||||
<input disabled={disabled} type="color" value={style.backgroundColorHex || '#0f172a'} onChange={(e) => updateStyle({ backgroundColor: e.target.value, backgroundColorHex: e.target.value })} />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Rahmen</label>
|
||||
<input disabled={disabled} type="color" value={style.borderColorHex || '#94a3b8'} onChange={(e) => updateStyle({ borderColor: e.target.value, borderColorHex: e.target.value })} />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Textfarbe</label>
|
||||
<input disabled={disabled} type="color" value={style.labelColor || '#e2e8f0'} onChange={(e) => updateStyle({ labelColor: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Kurve</label>
|
||||
<input disabled={disabled} type="range" min="0.15" max="0.6" step="0.01" value={Number(style.curve ?? 0.42)} onChange={(e) => updateStyle({ curve: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="prop-grid-2">
|
||||
<div className="field">
|
||||
<label>Link Deckkraft</label>
|
||||
<input disabled={disabled} type="range" min="0.1" max="1" step="0.05" value={Number(style.linkOpacity ?? 0.46)} onChange={(e) => updateStyle({ linkOpacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Knoten Deckkraft</label>
|
||||
<input disabled={disabled} type="range" min="0.2" max="1" step="0.05" value={Number(style.nodeOpacity ?? 0.9)} onChange={(e) => updateStyle({ nodeOpacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="check-line">
|
||||
<input disabled={disabled} type="checkbox" checked={style.showValues !== false} onChange={(e) => updateStyle({ showValues: e.target.checked })} />
|
||||
Werte / Labels anzeigen
|
||||
</label>
|
||||
|
||||
<label className="check-line">
|
||||
<input disabled={disabled} type="checkbox" checked={style.showLegend !== false} onChange={(e) => updateStyle({ showLegend: e.target.checked })} />
|
||||
Legende anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="sankey-control-card">
|
||||
<div className="small-title">Geometrie</div>
|
||||
<div className="prop-grid-2">
|
||||
<div className="field">
|
||||
<label>Knotenbreite</label>
|
||||
<input disabled={disabled} type="number" min="12" max="80" value={Number(style.nodeWidth ?? 26)} onChange={(e) => updateStyle({ nodeWidth: Number(e.target.value || 26) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Knotenradius</label>
|
||||
<input disabled={disabled} type="number" min="0" max="40" value={Number(style.nodeRadius ?? 10)} onChange={(e) => updateStyle({ nodeRadius: Number(e.target.value || 10) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Knotenabstand</label>
|
||||
<input disabled={disabled} type="number" min="4" max="80" value={Number(style.nodeGap ?? 14)} onChange={(e) => updateStyle({ nodeGap: Number(e.target.value || 14) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mindestdicke Fluss</label>
|
||||
<input disabled={disabled} type="number" min="1" max="40" value={Number(style.minLinkWidth ?? 4)} onChange={(e) => updateStyle({ minLinkWidth: Number(e.target.value || 4) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mindesthoehe Knoten</label>
|
||||
<input disabled={disabled} type="number" min="10" max="80" value={Number(style.minNodeHeight ?? 24)} onChange={(e) => updateStyle({ minNodeHeight: Number(e.target.value || 24) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sankey-node-list">
|
||||
<div className="sankey-section-head">
|
||||
<div>
|
||||
<div className="small-title">Knoten</div>
|
||||
<div className="small-muted">Spalten und Reihenfolge bestimmen das Grundlayout.</div>
|
||||
</div>
|
||||
<button disabled={disabled} type="button" className="btn small" onClick={addNode}>+ Knoten</button>
|
||||
</div>
|
||||
|
||||
{nodes.map((node, index) => (
|
||||
<div key={node.id || index} className="sankey-node-row">
|
||||
<div className="prop-grid-2">
|
||||
<div className="field compact-field">
|
||||
<label>Name</label>
|
||||
<input disabled={disabled} value={node.label || ''} onChange={(e) => updateNode(index, { label: e.target.value })} />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Farbe</label>
|
||||
<input disabled={disabled} type="color" value={node.color || '#38bdf8'} onChange={(e) => updateNode(index, { color: e.target.value })} />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Spalte</label>
|
||||
<input disabled={disabled} type="number" min="0" max="12" value={Number(node.column ?? 0)} onChange={(e) => updateNode(index, { column: Number(e.target.value || 0) })} />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Reihenfolge</label>
|
||||
<input disabled={disabled} type="number" min="0" max="99" value={Number(node.order ?? index)} onChange={(e) => updateNode(index, { order: Number(e.target.value || 0) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rule-footer">
|
||||
<span>{node.id}</span>
|
||||
<button disabled={disabled} type="button" className="mini-btn danger" onClick={() => removeNode(index)}>Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{nodes.length === 0 && <div className="empty-mini">Noch keine Knoten vorhanden.</div>}
|
||||
</div>
|
||||
|
||||
<div className="sankey-link-list">
|
||||
<div className="sankey-section-head">
|
||||
<div>
|
||||
<div className="small-title">Sections / Fluesse</div>
|
||||
<div className="small-muted">Jede Section verbindet genau zwei Knoten und kann statisch, per API/VDP oder per Influx gespeist werden.</div>
|
||||
</div>
|
||||
<button disabled={disabled || nodes.length < 2} type="button" className="btn small" onClick={addLink}>+ Section</button>
|
||||
</div>
|
||||
|
||||
{links.map((link, index) => (
|
||||
<div key={link.id || index} className="sankey-link-row">
|
||||
<div className="prop-grid-2">
|
||||
<div className="field compact-field">
|
||||
<label>Bezeichnung</label>
|
||||
<input disabled={disabled} value={link.label || ''} onChange={(e) => updateLink(index, { label: e.target.value })} placeholder="z.B. Waermemenge" />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Von</label>
|
||||
<select className="form-input" disabled={disabled} value={link.sourceId || ''} onChange={(e) => updateLink(index, { sourceId: e.target.value })}>
|
||||
<option value="">Knoten waehlen</option>
|
||||
{nodes.map((node) => <option key={node.id} value={node.id}>{node.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Nach</label>
|
||||
<select className="form-input" disabled={disabled} value={link.targetId || ''} onChange={(e) => updateLink(index, { targetId: e.target.value })}>
|
||||
<option value="">Knoten waehlen</option>
|
||||
{nodes.map((node) => <option key={node.id} value={node.id}>{node.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Quelle</label>
|
||||
<select className="form-input" disabled={disabled} value={link.sourceMode || 'static'} onChange={(e) => updateLink(index, { sourceMode: e.target.value })}>
|
||||
<option value="static">Fester Wert</option>
|
||||
<option value="point">API / VDP</option>
|
||||
<option value="influx">Influx</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Farbe</label>
|
||||
<input disabled={disabled} type="color" value={link.color || '#38bdf8'} onChange={(e) => updateLink(index, { color: e.target.value })} />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Dickenfaktor</label>
|
||||
<input disabled={disabled} type="number" min="0.25" step="0.25" value={Number(link.thicknessFactor ?? 1)} onChange={(e) => updateLink(index, { thicknessFactor: Math.max(0.25, Number(e.target.value || 1)) })} />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label>Deckkraft</label>
|
||||
<input disabled={disabled} type="range" min="0.1" max="1" step="0.05" value={Number(link.opacity ?? style.linkOpacity ?? 0.46)} onChange={(e) => updateLink(index, { opacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{link.sourceMode === 'static' && (
|
||||
<div className="field compact-field">
|
||||
<label>Fester Wert</label>
|
||||
<input disabled={disabled} value={link.staticValue ?? ''} onChange={(e) => updateLink(index, { staticValue: e.target.value })} placeholder="z.B. 125" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{link.sourceMode === 'point' && (
|
||||
<ProjectPointSelect
|
||||
projectId={page?.project_id}
|
||||
disabled={disabled}
|
||||
value={link.point ? sourceKeyFromRef(link.point) : ''}
|
||||
onChange={(point) => updateLink(index, { point })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{link.sourceMode === 'influx' && (
|
||||
<InfluxPointSelect
|
||||
disabled={disabled}
|
||||
value={link.influx || null}
|
||||
onChange={(influx) => updateLink(index, { influx })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rule-footer">
|
||||
<span>
|
||||
{link.sourceMode === 'point'
|
||||
? (link.point?.point_label || link.point?.label || 'API / VDP')
|
||||
: link.sourceMode === 'influx'
|
||||
? (link.influx?.label || link.influx?.tag_value || 'Influx')
|
||||
: 'Statisch'}
|
||||
</span>
|
||||
<button disabled={disabled} type="button" className="mini-btn danger" onClick={() => removeLink(index)}>Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{links.length === 0 && <div className="empty-mini">Noch keine Fluesse vorhanden.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="sankey-chart" style={{ background, borderColor: border }}>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
||||
<rect x="0.5" y="0.5" width={width - 1} height={height - 1} rx={Number(style.radius ?? 14)} className="trend-chart-frame" />
|
||||
|
||||
{layout.links.map((link) => (
|
||||
<g key={link.id}>
|
||||
<path d={link.path} fill={link.color} opacity={link.opacity} />
|
||||
{showValues && link.label && (
|
||||
<text
|
||||
x={link.labelX}
|
||||
y={link.labelY}
|
||||
className="sankey-link-label"
|
||||
textAnchor="middle"
|
||||
style={{ fill: labelColor }}
|
||||
>
|
||||
{link.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{layout.nodes.map((node) => (
|
||||
<g key={node.id}>
|
||||
<rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.w}
|
||||
height={node.h}
|
||||
rx={node.radius}
|
||||
fill={node.color}
|
||||
opacity={nodeOpacity}
|
||||
/>
|
||||
<text
|
||||
x={node.x + node.w + 8}
|
||||
y={node.y + 16}
|
||||
className="sankey-node-label"
|
||||
style={{ fill: labelColor }}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="sankey-chart-head">
|
||||
<strong>{element.label || element.name || 'Sankey'}</strong>
|
||||
<span>{layout.links.length} Flüsse · {layout.nodes.length} Knoten</span>
|
||||
</div>
|
||||
|
||||
{showLegend && showValues && layout.links.length > 0 && (
|
||||
<div className="sankey-legend">
|
||||
{layout.links.slice(0, 6).map((link) => (
|
||||
<span key={link.id}>
|
||||
<i style={{ background: link.color }} />
|
||||
{link.label || `${link.source.label} → ${link.target.label}`}: {formatValue(link.value, Number(style.decimals ?? 1), style.unit || '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout.links.length === 0 && <div className="trend-empty">Sankey-Flüsse konfigurieren</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<path
|
||||
key={id}
|
||||
d={arrowPath(type)}
|
||||
fill={color}
|
||||
transform={`translate(${tipX} ${tipY}) rotate(${angle}) scale(${scale})`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg viewBox={`0 0 ${element.w} ${element.h}`} className="shape-svg">
|
||||
<defs>
|
||||
{fillMode === 'radial' && (
|
||||
<radialGradient id={fillId} cx={`${origin.x}%`} cy={`${origin.y}%`} r="72%">
|
||||
<stop offset="0%" stopColor={paint.fillColor} />
|
||||
<stop offset="100%" stopColor={paint.gradientColor} />
|
||||
</radialGradient>
|
||||
)}
|
||||
|
||||
{fillMode === 'linear' && (
|
||||
<linearGradient
|
||||
id={fillId}
|
||||
x1={centerOrigin ? '0%' : `${origin.x}%`}
|
||||
y1={centerOrigin ? '50%' : `${origin.y}%`}
|
||||
x2={centerOrigin ? '100%' : `${100 - origin.x}%`}
|
||||
y2={centerOrigin ? '50%' : `${100 - origin.y}%`}
|
||||
gradientTransform={`rotate(${Number(style.gradientAngle || 0)} 0.5 0.5)`}
|
||||
>
|
||||
{centerOrigin ? (
|
||||
<>
|
||||
<stop offset="0%" stopColor={paint.gradientColor} />
|
||||
<stop offset="50%" stopColor={paint.fillColor} />
|
||||
<stop offset="100%" stopColor={paint.gradientColor} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<stop offset="0%" stopColor={paint.fillColor} />
|
||||
<stop offset="100%" stopColor={paint.gradientColor} />
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
)}
|
||||
|
||||
</defs>
|
||||
|
||||
{subtype === 'polyline' && (
|
||||
<>
|
||||
{style.fillClosed && points.length > 2 && (
|
||||
<polygon points={pointString} fill={fill} stroke="none" />
|
||||
)}
|
||||
<polyline
|
||||
points={pointString}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{showFlow && (
|
||||
<polyline
|
||||
points={pointString}
|
||||
className="shape-flow-line"
|
||||
fill="none"
|
||||
stroke={style.flowColor || '#e0f2fe'}
|
||||
strokeWidth={Math.max(2, strokeWidth * 0.55)}
|
||||
strokeDasharray="14 18"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
animationDuration: `${flow.duration.toFixed(2)}s`,
|
||||
animationDirection: flow.reverse ? 'reverse' : 'normal',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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' && (
|
||||
<polygon
|
||||
points={pointString}
|
||||
fill={fill}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(subtype === 'ellipse' || subtype === 'circle') && (
|
||||
<ellipse
|
||||
cx={element.w / 2}
|
||||
cy={element.h / 2}
|
||||
rx={Math.max(1, element.w / 2 - strokeWidth)}
|
||||
ry={Math.max(1, element.h / 2 - strokeWidth)}
|
||||
fill={fill}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{subtype === 'rect' && (
|
||||
<rect
|
||||
x={strokeWidth / 2}
|
||||
y={strokeWidth / 2}
|
||||
width={Math.max(1, element.w - strokeWidth)}
|
||||
height={Math.max(1, element.h - strokeWidth)}
|
||||
rx={Number(style.radius || 0)}
|
||||
fill={fill}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pointEditMode && points.map((p, idx) => (
|
||||
<circle
|
||||
key={idx}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="7"
|
||||
className="poly-point"
|
||||
onPointerDown={(e) => onPointPointerDown(e, idx)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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 <span className={className} style={style}>{children}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<a className={className} style={style} href={href} target={target} rel={rel}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`text-element text-menu ${menuOrientation === 'horizontal' ? 'is-horizontal' : 'is-vertical'} ${renderAsButton ? 'has-button-items' : ''}`}
|
||||
style={{
|
||||
...commonStyle,
|
||||
gap: `${Math.max(0, Number(style.menuGap ?? 8))}px`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TextLink
|
||||
key={item.id}
|
||||
href={resolveLinkHref(item)}
|
||||
targetMode={item.linkTarget}
|
||||
interactive={interactive}
|
||||
className={`text-menu-item ${renderAsButton ? 'as-button' : ''}`}
|
||||
style={renderAsButton
|
||||
? {
|
||||
...buttonVisualStyle(style),
|
||||
width: menuOrientation === 'horizontal' ? `${menuButtonSize}px` : '100%',
|
||||
height: menuOrientation === 'vertical' ? `${menuButtonSize}px` : '100%',
|
||||
minHeight: menuOrientation === 'vertical' ? `${menuButtonSize}px` : '100%',
|
||||
flex: '0 0 auto',
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{item.label}
|
||||
</TextLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (subtype === 'link') {
|
||||
return (
|
||||
<div className="text-element" style={commonStyle}>
|
||||
<TextLink
|
||||
href={resolveLinkHref(style)}
|
||||
targetMode={style.linkTarget || 'same_tab'}
|
||||
interactive={interactive}
|
||||
className={`text-link-anchor ${renderAsButton ? 'as-button' : ''}`}
|
||||
style={renderAsButton
|
||||
? {
|
||||
...buttonVisualStyle(style),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{text}
|
||||
</TextLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-element" style={commonStyle}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="trend-chart" style={{ background, borderColor: border }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
onPointerDown={pointerDown}
|
||||
onPointerMove={pointerMove}
|
||||
onPointerUp={pointerUp}
|
||||
onDoubleClick={resetZoomFromChart}
|
||||
onPointerLeave={() => {
|
||||
dragRef.current = null
|
||||
setSelection(null)
|
||||
setHoverPoint(null)
|
||||
onHideTooltip?.()
|
||||
}}
|
||||
>
|
||||
<rect x="0.5" y="0.5" width={width - 1} height={height - 1} rx={Number(style.radius ?? 14)} className="trend-chart-frame" />
|
||||
|
||||
{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 (
|
||||
<g key={axis}>
|
||||
{ticks.map((tick) => {
|
||||
const y = mapY(tick, axis)
|
||||
return (
|
||||
<g key={`${axis}-${tick}`}>
|
||||
{isLeft && <line x1={margin.left} x2={width - margin.right} y1={y} y2={y} className="trend-grid-line" />}
|
||||
<text x={labelX} y={y + 4} className="trend-axis-label" textAnchor={textAnchor} style={{ fill: axisInfo.color }}>{formatNumber(tick)}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{axisInfo.unit && (
|
||||
<text x={labelX} y={margin.top - 12} className="trend-axis-label" textAnchor={textAnchor} style={{ fill: axisInfo.color }}>
|
||||
{axisInfo.unit}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{displayMode !== 'donut' && xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick}
|
||||
x={mapX(tick)}
|
||||
y={baseY + 24}
|
||||
className="trend-axis-label trend-x-label"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{dateTick(tick)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{displayMode !== 'donut' && displayMode !== 'digital_bar' && (
|
||||
<line x1={margin.left} x2={margin.left} y1={margin.top} y2={baseY} className="trend-axis" />
|
||||
)}
|
||||
{displayMode !== 'donut' && displayMode !== 'digital_bar' && activeAxes.slice(1).map((axis, index) => (
|
||||
<line
|
||||
key={axis}
|
||||
x1={width - margin.right + 6 + index * 42}
|
||||
x2={width - margin.right + 6 + index * 42}
|
||||
y1={margin.top}
|
||||
y2={baseY}
|
||||
className="trend-axis"
|
||||
/>
|
||||
))}
|
||||
{displayMode !== 'donut' && (
|
||||
<line x1={margin.left} x2={width - margin.right} y1={baseY} y2={baseY} className="trend-axis" />
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<g key={serie.id || index}>
|
||||
<text x={margin.left - 8} y={y + h / 2 + 4} className="trend-axis-label" textAnchor="end">
|
||||
{serie.label || `Trend ${index + 1}`}
|
||||
</text>
|
||||
{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 (
|
||||
<rect
|
||||
key={`${serie.id || index}-${pointIndex}`}
|
||||
x={x}
|
||||
y={y}
|
||||
width={Math.max(1, x2 - x)}
|
||||
height={h}
|
||||
fill={active ? color : 'rgba(2, 6, 23, 0.58)'}
|
||||
opacity={active ? 0.74 : 0.5}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{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 (
|
||||
<g key={serie.id || index}>
|
||||
{points.map((point, pointIndex) => {
|
||||
const x = mapX(point.x) - groupWidth / 2 + index * (barWidth + 2)
|
||||
const y = mapY(point.y, serie.yAxis || 1)
|
||||
return (
|
||||
<rect
|
||||
key={`${serie.id || index}-${pointIndex}`}
|
||||
x={x}
|
||||
y={Math.min(y, baseY)}
|
||||
width={barWidth}
|
||||
height={Math.max(1, Math.abs(baseY - y))}
|
||||
rx="2"
|
||||
fill={color}
|
||||
opacity="0.82"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{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 (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={radius} fill="none" stroke="rgba(15,23,42,0.58)" strokeWidth={strokeWidth} />
|
||||
{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 (
|
||||
<circle
|
||||
key={item.serie.id || item.index}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={item.serie.color || '#38bdf8'}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={dash}
|
||||
strokeDashoffset={currentOffset}
|
||||
pathLength="100"
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<text x={cx} y={cy + 5} className="trend-axis-label" textAnchor="middle">
|
||||
{formatNumber(total)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
|
||||
{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 (
|
||||
<g key={serie.id || index}>
|
||||
{serie.fillEnabled && fillPath && (
|
||||
<path d={fillPath} fill={color} opacity={fillOpacity} />
|
||||
)}
|
||||
{linePath && (
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={lineWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{hoverPoint && (
|
||||
<circle
|
||||
cx={hoverPoint.x}
|
||||
cy={hoverPoint.y}
|
||||
r="5"
|
||||
fill={hoverPoint.color}
|
||||
className="trend-hover-point"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection && (
|
||||
<rect
|
||||
x={Math.min(selection.startX, selection.currentX)}
|
||||
y={margin.top}
|
||||
width={Math.abs(selection.currentX - selection.startX)}
|
||||
height={plotH}
|
||||
className="trend-selection"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<div className="trend-chart-head">
|
||||
<strong>{element.label || element.name || 'Trendgraph'}</strong>
|
||||
<span>{currentRange === 'custom' ? 'Auswahl' : currentRange} · {data?.interval || style.interval || 'auto'}</span>
|
||||
</div>
|
||||
|
||||
{hasLegend && (
|
||||
<div className={`trend-legend ${interactive ? 'has-footer' : ''}`}>
|
||||
{chart.series.map((serie, index) => (
|
||||
<span key={serie.id || index}>
|
||||
<i style={{ background: serie.color || '#38bdf8' }} />
|
||||
{serie.label || `Trend ${index + 1}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!seriesConfig.length && <div className="trend-empty">Datenpunkte auswählen</div>}
|
||||
{loading && !data && <div className="trend-empty">Trend wird geladen...</div>}
|
||||
{error && <div className="trend-error">{error}</div>}
|
||||
|
||||
{interactive && (
|
||||
<div className="trend-footer" onPointerDown={(e) => e.stopPropagation()}>
|
||||
{controlsMode !== 'global' && (
|
||||
<select
|
||||
value={customRange ? 'custom' : (rangeOverride || style.range || '24h')}
|
||||
onChange={(e) => {
|
||||
if (e.target.value !== 'custom') resetRange(e.target.value)
|
||||
}}
|
||||
>
|
||||
{customRange && <option value="custom">Auswahl</option>}
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{controlsMode !== 'global' && customRange && (
|
||||
<button type="button" onClick={() => resetRange(rangeOverride || style.range || '24h')}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button type="button" title="Trend auf Canvas vergrößern" onClick={() => onExpand?.(element)}>
|
||||
⤢
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <line className="instrument-pointer-line" x1={x} x2={x} y1={y - 18} y2={y + 18} style={{ stroke: color }} />
|
||||
}
|
||||
|
||||
if (style.pointerStyle === 'diamond') {
|
||||
return <path className="instrument-pointer" d={`M ${x} ${y - 15} l 8 8 l -8 8 l -8 -8 z`} style={{ fill: color }} />
|
||||
}
|
||||
|
||||
return <path className="instrument-pointer" d={`M ${x} ${y - 15} l -7 -10 h 14 z`} style={{ fill: color }} />
|
||||
}
|
||||
|
||||
function VerticalPointer({ style, x, y }) {
|
||||
const color = style.needleColor || '#f8fafc'
|
||||
|
||||
if (style.pointerStyle === 'line') {
|
||||
return <line className="instrument-pointer-line" x1={x - 18} x2={x + 18} y1={y} y2={y} style={{ stroke: color }} />
|
||||
}
|
||||
|
||||
if (style.pointerStyle === 'diamond') {
|
||||
return <path className="instrument-pointer" d={`M ${x + 9} ${y} l 8 -8 l 8 8 l -8 8 z`} style={{ fill: color }} />
|
||||
}
|
||||
|
||||
return <path className="instrument-pointer" d={`M ${x + 9} ${y} l 13 -7 v 14 z`} style={{ fill: color }} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg className="instrument-svg" viewBox={`0 0 ${w} ${h}`}>
|
||||
<line className="instrument-rail" x1={left} x2={right} y1={axisY} y2={axisY} style={{ stroke: style.barColor || '#334155' }} />
|
||||
{ranges.map((range, index) => (
|
||||
<line key={index} className="instrument-range" x1={left + range.start * length} x2={left + range.end * length} y1={axisY} y2={axisY} style={{ stroke: range.color }} />
|
||||
))}
|
||||
{style.fillEnabled && (
|
||||
<line className="instrument-fill" x1={left} x2={left + config.ratio * length} y1={axisY} y2={axisY} style={{ stroke: style.fillColor || '#38bdf8' }} />
|
||||
)}
|
||||
{major.flatMap((value, index) => {
|
||||
const ratio = (value - config.low) / (config.high - config.low)
|
||||
const x = left + ratio * length
|
||||
const marks = [<line key={`major-${index}`} className="instrument-major" x1={x} x2={x} y1={axisY + 8} y2={axisY + 18} />]
|
||||
|
||||
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(<line key={`minor-${index}-${inner}`} className="instrument-minor" x1={minorX} x2={minorX} y1={axisY + 10} y2={axisY + 15} />)
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabel(style.labelMode || 'all', index, major.length)) {
|
||||
marks.push(<text key={`label-${index}`} className="instrument-label" x={x} y={labelY} style={{ fontSize: labelSize }}>{formatTick(value)}</text>)
|
||||
}
|
||||
|
||||
return marks
|
||||
})}
|
||||
<HorizontalPointer style={style} x={left + config.ratio * length} y={axisY} />
|
||||
<text className="instrument-value" x={w / 2} y={h - 10}>{config.display}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg className="instrument-svg" viewBox={`0 0 ${w} ${h}`}>
|
||||
<line className="instrument-rail" x1={axisX} x2={axisX} y1={bottom} y2={top} style={{ stroke: style.barColor || '#334155' }} />
|
||||
{ranges.map((range, index) => (
|
||||
<line key={index} className="instrument-range" x1={axisX} x2={axisX} y1={bottom - range.start * length} y2={bottom - range.end * length} style={{ stroke: range.color }} />
|
||||
))}
|
||||
{style.fillEnabled && (
|
||||
<line className="instrument-fill" x1={axisX} x2={axisX} y1={bottom} y2={bottom - config.ratio * length} style={{ stroke: style.fillColor || '#38bdf8' }} />
|
||||
)}
|
||||
{major.flatMap((value, index) => {
|
||||
const ratio = (value - config.low) / (config.high - config.low)
|
||||
const y = bottom - ratio * length
|
||||
const marks = [<line key={`major-${index}`} className="instrument-major" x1={axisX - 18} x2={axisX - 8} y1={y} y2={y} />]
|
||||
|
||||
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(<line key={`minor-${index}-${inner}`} className="instrument-minor" x1={axisX - 15} x2={axisX - 9} y1={minorY} y2={minorY} />)
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabel(style.labelMode || 'all', index, major.length)) {
|
||||
marks.push(<text key={`label-${index}`} className="instrument-label end" x={labelX} y={y + labelSize / 3} style={{ fontSize: labelSize }}>{formatTick(value)}</text>)
|
||||
}
|
||||
|
||||
return marks
|
||||
})}
|
||||
<VerticalPointer style={style} x={axisX} y={bottom - config.ratio * length} />
|
||||
<text className="instrument-value" x={w / 2} y={h - 7}>{config.display}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg className="instrument-svg" viewBox={`0 0 ${w} ${h}`}>
|
||||
<path className="instrument-dial-rail" d={arcPath(cx, cy, radius, startAngle, endAngle)} style={{ stroke: style.barColor || '#334155' }} />
|
||||
{ranges.map((range, index) => (
|
||||
<path
|
||||
key={index}
|
||||
className="instrument-dial-range"
|
||||
d={arcPath(cx, cy, radius, startAngle + range.start * span, startAngle + range.end * span)}
|
||||
style={{ stroke: range.color }}
|
||||
/>
|
||||
))}
|
||||
{style.fillEnabled && config.ratio > 0 && (
|
||||
<path
|
||||
className="instrument-dial-fill"
|
||||
d={arcPath(cx, cy, radius, startAngle, startAngle + config.ratio * span)}
|
||||
style={{ stroke: style.fillColor || '#38bdf8' }}
|
||||
/>
|
||||
)}
|
||||
{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 = [<line key={`major-${index}`} className="instrument-major" x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} />]
|
||||
|
||||
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(<line key={`minor-${index}-${innerIndex}`} className="instrument-minor" x1={minorInner.x} y1={minorInner.y} x2={minorOuter.x} y2={minorOuter.y} />)
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabel(style.labelMode || 'all', index, major.length)) {
|
||||
marks.push(<text key={`label-${index}`} className="instrument-label" x={label.x} y={label.y + labelSize / 3} style={{ fontSize: labelSize }}>{formatTick(value)}</text>)
|
||||
}
|
||||
|
||||
return marks
|
||||
})}
|
||||
{style.pointerStyle === 'wedge' ? (
|
||||
<path className="instrument-dial-wedge" d={`M ${wedgeLeft.x} ${wedgeLeft.y} L ${pointer.x} ${pointer.y} L ${wedgeRight.x} ${wedgeRight.y} Z`} style={{ fill: style.needleColor || '#f8fafc' }} />
|
||||
) : (
|
||||
<line
|
||||
className={`instrument-needle ${style.pointerStyle === 'slim' ? 'slim' : ''}`}
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={pointer.x}
|
||||
y2={pointer.y}
|
||||
style={{ stroke: style.needleColor || '#f8fafc' }}
|
||||
/>
|
||||
)}
|
||||
<circle className="instrument-hub" cx={cx} cy={cy} r="7" style={{ fill: style.needleColor || '#f8fafc' }} />
|
||||
<text className="instrument-value" x={cx} y={h - 9}>{config.display}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ValueInstrumentGraphic({ element, liveValues = {} }) {
|
||||
const style = element.style_json || {}
|
||||
const config = scaleConfig(style, liveValues)
|
||||
|
||||
if (style.kind === 'dial_gauge') {
|
||||
return <DialGauge element={element} style={style} config={config} />
|
||||
}
|
||||
|
||||
if (style.orientation === 'vertical') {
|
||||
return <VerticalBar element={element} style={style} config={config} />
|
||||
}
|
||||
|
||||
return <HorizontalBar element={element} style={style} config={config} />
|
||||
}
|
||||
@@ -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 <span className={className}>{label}</span>
|
||||
}
|
||||
|
||||
return <a href={href} className={className}>{label}</a>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})`
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -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 (
|
||||
<div className="page-shell full viewer-full">
|
||||
<section className="glass-card visu-workspace monitoring-page">
|
||||
<div className="section-head visu-topbar">
|
||||
<div>
|
||||
<div className="eyebrow">CSV Exporte</div>
|
||||
<h1>Zyklische Exportjobs</h1>
|
||||
<p className="subline">Eigene Seite fuer Scheduler, CSV Pool und wiederkehrende Dateiexporte.</p>
|
||||
</div>
|
||||
<div className="designer-actions">
|
||||
<ViewerShortcutButton />
|
||||
<a href="/visu/?mode=dashboard" className="btn">Dashboard</a>
|
||||
<a href="/visu/?mode=manage" className="btn">Verwaltung</a>
|
||||
<a href="/visu/?mode=templates" className="btn">Templates</a>
|
||||
<a href="/visu/?mode=monitoring" className="btn">Datenpunkte</a>
|
||||
<a href="/visu/?mode=virtual_points" className="btn">Virtuelle Datenpunkte</a>
|
||||
<a href="/visu/?mode=csv_exports" className="btn primary">CSV Exporte</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-box small">{error}</div>}
|
||||
|
||||
<div className="monitoring-export-layout">
|
||||
<div className="monitoring-panel">
|
||||
<div className="monitoring-export-head">
|
||||
<div>
|
||||
<div className="eyebrow">Scheduler</div>
|
||||
<h2>Exportjobs</h2>
|
||||
<p className="small-muted">Projekt waehlen, gespeicherte Selection zuweisen und den Laufplan hinterlegen.</p>
|
||||
</div>
|
||||
<button type="button" className="btn small" onClick={resetExportJobDraft}>+ Neuer Job</button>
|
||||
</div>
|
||||
|
||||
<div className="prop-grid-2">
|
||||
<div className="field">
|
||||
<label>Projekt</label>
|
||||
<select value={exportProjectId || ''} onChange={(e) => setExportProjectId(Number(e.target.value || 0))}>
|
||||
<option value="">Projekt waehlen</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.year} - {project.project_number} - {project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Gespeicherte Selection</label>
|
||||
<select
|
||||
value={exportJobDraft.selection_id || ''}
|
||||
onChange={(e) => setExportJobDraft((current) => ({ ...current, selection_id: Number(e.target.value || 0) }))}
|
||||
>
|
||||
<option value="">Selection waehlen</option>
|
||||
{savedSelections.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="monitoring-job-list">
|
||||
{loadingExports && <div className="small-muted">Exportjobs werden geladen...</div>}
|
||||
{exportJobs.map((job) => (
|
||||
<div key={job.id} className="monitoring-job-row">
|
||||
<div>
|
||||
<strong>{job.name}</strong>
|
||||
<span>{job.selection_name || `Selection #${job.selection_id}`} - {exportStatusLabel(job.last_status)} - Next: {formatDateTime(job.next_run_at)}</span>
|
||||
</div>
|
||||
<div className="monitoring-row-actions">
|
||||
<button type="button" className="mini-btn" onClick={() => loadExportJob(job)}>Bearbeiten</button>
|
||||
<button type="button" className="mini-btn danger" onClick={() => deleteExportJob(job)}>Loeschen</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loadingExports && exportJobs.length === 0 && <div className="empty-mini">Noch keine Exportjobs fuer dieses Projekt vorhanden.</div>}
|
||||
</div>
|
||||
|
||||
<div className="monitoring-job-form">
|
||||
<div className="prop-grid-2">
|
||||
<div className="field">
|
||||
<label>Job Name</label>
|
||||
<input value={exportJobDraft.name} onChange={(e) => setExportJobDraft((current) => ({ ...current, name: e.target.value }))} placeholder="z.B. Tagesexport HKL" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Exporttyp</label>
|
||||
<select value={exportJobDraft.export_kind} onChange={(e) => setExportJobDraft((current) => ({ ...current, export_kind: e.target.value }))}>
|
||||
{EXPORT_KIND_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>CSV Trennzeichen</label>
|
||||
<select value={exportJobDraft.csv_delimiter} onChange={(e) => setExportJobDraft((current) => ({ ...current, csv_delimiter: e.target.value }))}>
|
||||
<option value=",">Komma (,)</option>
|
||||
<option value=";">Semikolon (;)</option>
|
||||
<option value="\t">Tab</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Planung</label>
|
||||
<select value={exportJobDraft.schedule_kind} onChange={(e) => setExportJobDraft((current) => ({ ...current, schedule_kind: e.target.value }))}>
|
||||
{SCHEDULE_KIND_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="prop-grid-3">
|
||||
<div className="field">
|
||||
<label>Intervall (Min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled={exportJobDraft.schedule_kind !== 'interval'}
|
||||
value={Number(exportJobDraft.interval_minutes || 60)}
|
||||
onChange={(e) => setExportJobDraft((current) => ({ ...current, interval_minutes: Number(e.target.value || 60) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Uhrzeit</label>
|
||||
<input
|
||||
type="time"
|
||||
disabled={exportJobDraft.schedule_kind === 'interval'}
|
||||
value={exportJobDraft.time_of_day || '06:00'}
|
||||
onChange={(e) => setExportJobDraft((current) => ({ ...current, time_of_day: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Monatstag</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
disabled={exportJobDraft.schedule_kind !== 'monthly'}
|
||||
value={Number(exportJobDraft.month_day || 1)}
|
||||
onChange={(e) => setExportJobDraft((current) => ({ ...current, month_day: Number(e.target.value || 1) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportJobDraft.schedule_kind === 'weekly' && (
|
||||
<div className="monitoring-weekdays">
|
||||
{WEEKDAY_OPTIONS.map((option) => (
|
||||
<label key={option.value} className={`monitoring-weekday ${exportJobDraft.weekdays.includes(option.value) ? 'is-selected' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportJobDraft.weekdays.includes(option.value)}
|
||||
onChange={() => toggleWeekday(option.value)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(exportJobDraft.enabled)}
|
||||
onChange={(e) => setExportJobDraft((current) => ({ ...current, enabled: e.target.checked }))}
|
||||
/>
|
||||
Job aktiv
|
||||
</label>
|
||||
|
||||
<div className="monitoring-job-meta">
|
||||
<span>Status: {exportStatusLabel(exportJobDraft.last_status)}</span>
|
||||
<span>Naechster Lauf: {formatDateTime(exportJobDraft.next_run_at)}</span>
|
||||
{exportJobDraft.last_error ? <span className="danger-text">Letzter Fehler: {exportJobDraft.last_error}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="designer-actions">
|
||||
<button type="button" className="btn primary" disabled={savingExportJob} onClick={saveExportJob}>
|
||||
{savingExportJob ? 'Speichert...' : 'Exportjob speichern'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={resetExportJobDraft}>Formular leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="monitoring-panel">
|
||||
<div className="monitoring-export-head">
|
||||
<div>
|
||||
<div className="eyebrow">CSV Pool</div>
|
||||
<h2>Exportdateien</h2>
|
||||
<p className="small-muted">Vorhandene CSV-Dateien werden hier 30 Tage vorgehalten und danach automatisch geloescht.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="monitoring-file-list">
|
||||
{loadingExports && <div className="small-muted">Dateipool wird geladen...</div>}
|
||||
{exportFiles.map((file) => (
|
||||
<div key={file.id} className="monitoring-file-row">
|
||||
<div>
|
||||
<strong>{file.file_name}</strong>
|
||||
<span>{exportStatusLabel(file.status)} - {formatDateTime(file.created_at)} - {formatBytes(file.file_size)} - {file.row_count || 0} Zeilen</span>
|
||||
</div>
|
||||
<div className="monitoring-row-actions">
|
||||
{file.status === 'success' && (
|
||||
<a className="mini-btn" href={`/api/visu_api.php?action=csv_export_file_download&file_id=${file.id}`}>Download</a>
|
||||
)}
|
||||
<button type="button" className="mini-btn danger" onClick={() => deleteExportFile(file)}>Loeschen</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loadingExports && exportFiles.length === 0 && <div className="empty-mini">Noch keine Exportdateien im Pool.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = [
|
||||
'<table>',
|
||||
`<tr>${headers.map((cell) => `<th>${String(cell).replace(/[<>&]/g, '')}</th>`).join('')}</tr>`,
|
||||
...sortedRows.map((row) => `<tr>${headers.map((cell) => `<td>${String(row[cell] ?? '').replace(/[<>&]/g, '')}</td>`).join('')}</tr>`),
|
||||
'</table>',
|
||||
].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 (
|
||||
<div className="page-shell full viewer-full">
|
||||
<section className="glass-card visu-workspace monitoring-page">
|
||||
<div className="section-head visu-topbar">
|
||||
<div>
|
||||
<div className="eyebrow">Monitoring</div>
|
||||
<h1>Datenpunkt-Uebersicht</h1>
|
||||
<p className="subline">Datenpunkte auswaehlen, Selections speichern und manuelle CSV- oder Excel-Exporte ziehen.</p>
|
||||
</div>
|
||||
<div className="designer-actions">
|
||||
<ViewerShortcutButton />
|
||||
<a href="/visu/?mode=dashboard" className="btn">Dashboard</a>
|
||||
<a href="/visu/?mode=manage" className="btn">Verwaltung</a>
|
||||
<a href="/visu/?mode=templates" className="btn">Templates</a>
|
||||
<a href="/visu/?mode=monitoring" className="btn primary">Datenpunkte</a>
|
||||
<a href="/visu/?mode=virtual_points" className="btn">Virtuelle Datenpunkte</a>
|
||||
<a href="/visu/?mode=csv_exports" className="btn">CSV Exporte</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-box small">{error}</div>}
|
||||
|
||||
<div className="monitoring-layout">
|
||||
<div className="monitoring-panel">
|
||||
<div className="prop-grid-2">
|
||||
<div className="field">
|
||||
<label>Influx DB</label>
|
||||
<select value={database} onChange={(e) => setDatabase(e.target.value)}>
|
||||
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Measurement / FROM</label>
|
||||
<select value={measurementFilter} onChange={(e) => setMeasurementFilter(e.target.value)}>
|
||||
<option value="">Alle Measurements</option>
|
||||
{measurements.map((measurement) => <option key={measurement} value={measurement}>{measurement}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Suchen</label>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Measurement, Tag, Datenpunkt..." />
|
||||
</div>
|
||||
{loading && <div className="small-muted">Datenpunkte werden geladen...</div>}
|
||||
<div className="monitoring-point-list">
|
||||
{filteredPoints.map((point) => (
|
||||
<button key={pointKey(point)} type="button" className={`monitoring-point-row ${selectionKeys.has(pointKey(point)) ? 'is-selected' : ''}`} onClick={() => addPoint(point)}>
|
||||
<strong>{point.tag_value}</strong>
|
||||
<span>{point.measurement} - {point.tag_key}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="monitoring-panel">
|
||||
<div className="monitoring-selection-head">
|
||||
<div className="field">
|
||||
<label>Selection Name</label>
|
||||
<input value={selectionName} onChange={(e) => setSelectionName(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="btn primary" disabled={savingSelection} onClick={saveCurrentSelection}>
|
||||
{savingSelection ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="prop-grid-2">
|
||||
<div className="field">
|
||||
<label>Zeitbereich</label>
|
||||
<select value={range} onChange={(e) => setRange(e.target.value)}>
|
||||
{RANGE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Intervall</label>
|
||||
<select value={interval} onChange={(e) => setIntervalValue(e.target.value)}>
|
||||
{INTERVAL_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Aggregation</label>
|
||||
<select value={aggregate} onChange={(e) => setAggregate(e.target.value)}>
|
||||
{AGGREGATES.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>CSV Trennzeichen</label>
|
||||
<select value={delimiter} onChange={(e) => setDelimiter(e.target.value)}>
|
||||
<option value=",">Komma (,)</option>
|
||||
<option value=";">Semikolon (;)</option>
|
||||
<option value="\t">Tab</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="designer-actions">
|
||||
<button type="button" className="btn small" disabled={!selection.length} onClick={exportLastValuesCsv}>LastValues CSV</button>
|
||||
<button type="button" className="btn small" disabled={!selection.length} onClick={() => exportTrendData('csv')}>Trend CSV</button>
|
||||
<button type="button" className="btn small" disabled={!selection.length} onClick={() => exportTrendData('xls')}>Excel</button>
|
||||
<a href="/visu/?mode=csv_exports" className="btn small">Zyklische Exporte</a>
|
||||
</div>
|
||||
|
||||
<div className="monitoring-selection-list">
|
||||
{selection.map((point) => {
|
||||
const last = lastValues[pointKey(point)] || {}
|
||||
return (
|
||||
<div key={pointKey(point)} className="monitoring-selection-row">
|
||||
<div>
|
||||
<strong>{point.tag_value}</strong>
|
||||
<span>{last.value !== '' && last.value !== undefined ? last.value : '...'} {last.time ? `- ${new Date(last.time).toLocaleString('de-DE')}` : ''}</span>
|
||||
</div>
|
||||
<button type="button" className="mini-btn danger" onClick={() => removePoint(pointKey(point))}>Entfernen</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="monitoring-saved-list">
|
||||
<div className="small-title">Gespeicherte Selections</div>
|
||||
{savedSelections.map((item) => (
|
||||
<div key={item.id || item.name} className="monitoring-saved-row">
|
||||
<button type="button" className="mini-btn" onClick={() => loadSelection(item)}>{item.name}</button>
|
||||
<button type="button" className="mini-btn danger" onClick={() => deleteSelection(item)}>Loeschen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <Designer templateMode templateId={templateId} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="page-shell full">
|
||||
<section className="glass-card overview-full">
|
||||
<div className="section-head visu-topbar">
|
||||
<div>
|
||||
<div className="eyebrow">Templates</div>
|
||||
<h1>Template-Verwaltung</h1>
|
||||
<p className="subline">Vorlagen mit Platzhaltern für wiederkehrende Visualisierungen.</p>
|
||||
</div>
|
||||
|
||||
<div className="designer-actions">
|
||||
<ViewerShortcutButton />
|
||||
<a href="/visu/?mode=dashboard" className="btn">Dashboard</a>
|
||||
<a href="/visu/?mode=manage" className="btn">Verwaltung</a>
|
||||
<a href="/visu/?mode=templates" className="btn primary">Templates</a>
|
||||
<a href="/visu/?mode=monitoring" className="btn">Datenpunkte</a>
|
||||
<a href="/visu/?mode=virtual_points" className="btn">Virtuelle Datenpunkte</a>
|
||||
<a href="/visu/?mode=csv_exports" className="btn">CSV Exporte</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-manage-layout single-column">
|
||||
<form className="overview-create compact" onSubmit={createTemplate}>
|
||||
<div className="field">
|
||||
<label>Name</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="z.B. Heizkreis Standard" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Beschreibung</label>
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
<button className="btn primary" type="submit">+ Template</button>
|
||||
<button className="btn" type="button" onClick={() => fileRef.current?.click()}>Import</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={(e) => importTemplate(e.target.files?.[0])}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{error && <div className="error-box small">{error}</div>}
|
||||
{loading && <div className="empty-mini">Templates werden geladen…</div>}
|
||||
|
||||
<div className="project-page-grid">
|
||||
{templates.map((template) => (
|
||||
<div key={template.id} className="visu-card compact">
|
||||
<div className="visu-title">{template.name}</div>
|
||||
<div className="visu-meta">
|
||||
{template.width}×{template.height}
|
||||
{template.placeholder_count ? ` · ${template.placeholder_count} Platzhalter` : ''}
|
||||
</div>
|
||||
{template.description && <div className="visu-description">{template.description}</div>}
|
||||
<div className="visu-actions">
|
||||
<a className="mini-btn" href={`/visu/?mode=templates&template_id=${template.id}`}>Bearbeiten</a>
|
||||
<button className="mini-btn" type="button" onClick={() => exportTemplate(template.id)}>Export</button>
|
||||
<button className="mini-btn danger" type="button" onClick={() => deleteTemplate(template.id)}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div className="field">
|
||||
<label>Projekt</label>
|
||||
<input
|
||||
className="form-input"
|
||||
disabled={disabled}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Projekt suchen"
|
||||
/>
|
||||
<select className="form-input" disabled={disabled} value={value || ''} onChange={(e) => onChange(Number(e.target.value || 0))}>
|
||||
<option value="">Projekt waehlen</option>
|
||||
{filtered.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.year} - {project.project_number} - {project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="field compact-field">
|
||||
<label>Quelle</label>
|
||||
<input
|
||||
className="form-input"
|
||||
disabled={disabled}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={loading ? 'Laedt...' : 'Datenpunkt oder VDP suchen'}
|
||||
/>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={disabled || loading}
|
||||
value={value?.source_key || sourceKeyFromRef(value) || ''}
|
||||
onChange={(e) => handleSelect(e.target.value)}
|
||||
>
|
||||
<option value="">Quelle waehlen</option>
|
||||
{filtered.slice(0, 500).map((point) => (
|
||||
<option key={point.source_key} value={point.source_key}>
|
||||
{point.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="vdp-formula-editor">
|
||||
<div className="vdp-formula-head">
|
||||
<div>
|
||||
<div className="small-title">Baustein-Editor</div>
|
||||
<div className="small-muted">Quellen, Zahlen und Operatoren von links nach rechts verbinden.</div>
|
||||
</div>
|
||||
<div className="vdp-flow-badge">{tokens.length} Bausteine</div>
|
||||
</div>
|
||||
|
||||
<div className="vdp-token-toolbar">
|
||||
<button disabled={disabled} type="button" className="btn small" onClick={() => addToken('point')}>+ Quelle</button>
|
||||
<button disabled={disabled} type="button" className="btn small" onClick={() => addToken('number')}>+ Zahl</button>
|
||||
{['+', '-', '*', '/', '(', ')'].map((operator) => (
|
||||
<button disabled={disabled} key={operator} type="button" className="btn small ghost" onClick={() => addToken('operator', { value: operator })}>
|
||||
{operator}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vdp-flow-stage">
|
||||
<div className="vdp-flow-scroll">
|
||||
<div className="vdp-flow-list">
|
||||
{tokens.map((token, index) => (
|
||||
<div
|
||||
key={token.id || index}
|
||||
className={`vdp-flow-item is-${token.type}`}
|
||||
draggable={!disabled}
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
moveToken(dragIndex, index)
|
||||
setDragIndex(-1)
|
||||
}}
|
||||
onDragEnd={() => setDragIndex(-1)}
|
||||
>
|
||||
<div className="vdp-flow-link" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="vdp-token-card">
|
||||
<div className="vdp-token-head">
|
||||
<div>
|
||||
<strong>{tokenTypeLabel(token)}</strong>
|
||||
<div className="small-muted">{tokenLabel(token)}</div>
|
||||
</div>
|
||||
<button disabled={disabled} type="button" className="mini-btn danger" onClick={() => removeToken(index)}>Entfernen</button>
|
||||
</div>
|
||||
|
||||
{token.type === 'point' && (
|
||||
<>
|
||||
<FormulaPointSelect
|
||||
projectId={projectId}
|
||||
value={token.ref || null}
|
||||
excludePointKey={pointKey}
|
||||
disabled={disabled}
|
||||
onChange={(ref) => updateToken(index, { ref })}
|
||||
/>
|
||||
<div className="vdp-token-live">
|
||||
<span>Aktuell</span>
|
||||
<strong>{displayPointValue(livePointFromRef(token.ref || null, liveValuesMap))}</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{token.type === 'number' && (
|
||||
<div className="field compact-field">
|
||||
<label>Wert</label>
|
||||
<input className="form-input" disabled={disabled} value={token.value ?? '0'} onChange={(e) => updateToken(index, { value: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{token.type === 'operator' && (
|
||||
<div className="field compact-field">
|
||||
<label>Operator</label>
|
||||
<select className="form-input" disabled={disabled} value={token.value || '+'} onChange={(e) => updateToken(index, { value: e.target.value })}>
|
||||
{['+', '-', '*', '/', '(', ')'].map((operator) => <option key={operator} value={operator}>{operator}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vdp-token-actions">
|
||||
<button disabled={disabled || index === 0} type="button" className="mini-btn" onClick={() => moveToken(index, index - 1)}><</button>
|
||||
<button disabled={disabled || index >= tokens.length - 1} type="button" className="mini-btn" onClick={() => moveToken(index, index + 1)}>></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="vdp-flow-target-wrap"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
moveToken(dragIndex, Math.max(0, tokens.length - 1))
|
||||
setDragIndex(-1)
|
||||
}}
|
||||
>
|
||||
<div className="vdp-flow-link" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div className="vdp-flow-target">
|
||||
<div className="eyebrow">Ziel</div>
|
||||
<strong>{targetLabel || 'Neuer virtueller Datenpunkt'}</strong>
|
||||
<span>{targetPointKey || 'point_key fehlt noch'}</span>
|
||||
<div className="vdp-flow-target-value">
|
||||
<span>Aktuell</span>
|
||||
<strong>{displayPointValue(targetValue)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vdp-formula-preview">{preview || 'Noch keine Formel vorhanden.'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="page-shell full viewer-full">
|
||||
<section className="glass-card visu-workspace monitoring-page">
|
||||
<div className="section-head visu-topbar">
|
||||
<div>
|
||||
<div className="eyebrow">Virtuelle Datenpunkte</div>
|
||||
<h1>VDP Editor</h1>
|
||||
<p className="subline">Projektbezogene Formeln als links-nach-rechts Bausteinkette bauen und wie normale Werte in der Visu verwenden.</p>
|
||||
</div>
|
||||
<div className="designer-actions">
|
||||
<ViewerShortcutButton />
|
||||
<a href="/visu/?mode=dashboard" className="btn">Dashboard</a>
|
||||
<a href="/visu/?mode=manage" className="btn">Verwaltung</a>
|
||||
<a href="/visu/?mode=templates" className="btn">Templates</a>
|
||||
<a href="/visu/?mode=monitoring" className="btn">Datenpunkte</a>
|
||||
<a href="/visu/?mode=virtual_points" className="btn primary">Virtuelle Datenpunkte</a>
|
||||
<a href="/visu/?mode=csv_exports" className="btn">CSV Exporte</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-box small">{error}</div>}
|
||||
|
||||
<div className="vdp-layout">
|
||||
<aside className="vdp-sidebar">
|
||||
<ProjectSelect value={projectId} onChange={setProjectId} />
|
||||
<button type="button" className="btn small primary" disabled={!projectId || !canEdit} onClick={createNew}>+ Neuer VDP</button>
|
||||
|
||||
{loading && <div className="small-muted">VDPs werden geladen...</div>}
|
||||
|
||||
<div className="vdp-point-list">
|
||||
{points.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`vdp-point-row ${Number(activeId) === Number(item.id) ? 'is-selected' : ''}`}
|
||||
onClick={() => selectPoint(item)}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.point_key}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="vdp-editor-panel">
|
||||
<div className="field">
|
||||
<label>Name</label>
|
||||
<input
|
||||
className="form-input"
|
||||
disabled={!canEdit}
|
||||
value={draft.name || ''}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
setDraft((old) => ({
|
||||
...old,
|
||||
name,
|
||||
point_key: slugifyPointKey(name),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vdp-meta-grid">
|
||||
<div className="field">
|
||||
<label>Punkt-Key (automatisch)</label>
|
||||
<input className="form-input" disabled value={effectivePointKey || ''} placeholder="wird aus dem Namen erzeugt" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Einheit</label>
|
||||
<input className="form-input" disabled={!canEdit} value={draft.unit || ''} onChange={(e) => setDraft((old) => ({ ...old, unit: e.target.value }))} placeholder="z.B. kW" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nachkommastellen</label>
|
||||
<input className="form-input" disabled={!canEdit} type="number" min="0" max="8" value={Number(draft.decimals ?? 2)} onChange={(e) => setDraft((old) => ({ ...old, decimals: Math.max(0, Math.min(8, Number(e.target.value || 0))) }))} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Sortierung</label>
|
||||
<input className="form-input" disabled={!canEdit} type="number" value={Number(draft.sort_order ?? 100)} onChange={(e) => setDraft((old) => ({ ...old, sort_order: Number(e.target.value || 100) }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Beschreibung</label>
|
||||
<textarea className="form-input" disabled={!canEdit} value={draft.description || ''} onChange={(e) => setDraft((old) => ({ ...old, description: e.target.value }))} rows="3" />
|
||||
</div>
|
||||
|
||||
<FormulaEditor
|
||||
projectId={projectId}
|
||||
pointKey={effectivePointKey}
|
||||
targetLabel={draft.name}
|
||||
targetPointKey={effectivePointKey}
|
||||
tokens={Array.isArray(draft.formula) ? draft.formula : []}
|
||||
liveValuesMap={liveValuesMap}
|
||||
targetValue={currentValue}
|
||||
disabled={!canEdit}
|
||||
onChange={(formula) => setDraft((old) => ({ ...old, formula }))}
|
||||
/>
|
||||
|
||||
<div className="designer-actions">
|
||||
<button type="button" className="btn primary" disabled={!canEdit || saving} onClick={savePoint}>
|
||||
{saving ? 'Speichert...' : 'VDP speichern'}
|
||||
</button>
|
||||
<button type="button" className="btn danger" disabled={!canEdit || !draft.id} onClick={deletePoint}>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
color: white;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
border: 1px solid rgba(156, 163, 175, 0.32);
|
||||
background: rgba(31, 41, 55, 0.58);
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: #93c5fd;
|
||||
font-weight: 900;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.glass-card h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 3vw, 48px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.section-head,
|
||||
.form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-head h2,
|
||||
.form-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.section-head span,
|
||||
.form-head span,
|
||||
.subline {
|
||||
font-size: 12px;
|
||||
color: rgba(209, 213, 219, 0.78);
|
||||
}
|
||||
|
||||
.error-box,
|
||||
.empty-box {
|
||||
border: 1px solid rgba(248, 113, 113, 0.5);
|
||||
background: rgba(127, 29, 29, 0.45);
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
color: #fecaca;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.error-box.small {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
color: rgba(229, 231, 235, 0.78);
|
||||
}
|
||||
|
||||
.btn,
|
||||
.mini-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(55, 65, 81, 0.7);
|
||||
color: white;
|
||||
border-radius: 14px;
|
||||
padding: 10px 16px;
|
||||
font-weight: 900;
|
||||
transition: 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover,
|
||||
.mini-btn:hover {
|
||||
background: rgba(75, 85, 99, 0.85);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: rgba(37, 99, 235, 0.78);
|
||||
border-color: rgba(96, 165, 250, 0.55);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
}
|
||||
|
||||
.btn.small {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn.danger,
|
||||
.mini-btn.danger {
|
||||
background: rgba(185, 28, 28, 0.65);
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
.btn.danger:hover,
|
||||
.mini-btn.danger:hover {
|
||||
background: rgba(220, 38, 38, 0.82);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: rgba(17, 24, 39, 0.32);
|
||||
border-radius: 22px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 1.6fr) minmax(260px, 1fr) 120px 120px;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-description {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.form-action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(209, 213, 219, 0.78);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.prop-row input,
|
||||
.prop-row select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(75, 85, 99, 0.8);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.prop-row input:focus {
|
||||
border-color: rgba(96, 165, 250, 0.9);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.project-select-wrap {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tiny-error {
|
||||
font-size: 11px;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.visu-card {
|
||||
border: 1px solid rgba(75, 85, 99, 0.8);
|
||||
background: rgba(17, 24, 39, 0.55);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.visu-title {
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.visu-meta {
|
||||
color: rgba(156, 163, 175, 0.9);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.visu-description {
|
||||
color: rgba(229, 231, 235, 0.78);
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visu-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.designer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.designer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr) 280px;
|
||||
gap: 16px;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.toolbox,
|
||||
.properties,
|
||||
.canvas-wrap {
|
||||
border: 1px solid rgba(75, 85, 99, 0.72);
|
||||
background: rgba(17, 24, 39, 0.52);
|
||||
border-radius: 20px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.toolbox h3,
|
||||
.properties h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(229, 231, 235, 0.88);
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(75, 85, 99, 0.8);
|
||||
background: rgba(31, 41, 55, 0.75);
|
||||
color: white;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: rgba(37, 99, 235, 0.6);
|
||||
}
|
||||
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: rgba(209, 213, 219, 0.75);
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.canvas-scroll,
|
||||
.viewer-scroll {
|
||||
overflow: auto;
|
||||
max-height: 76vh;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(75, 85, 99, 0.65);
|
||||
background: rgba(2, 6, 23, 0.55);
|
||||
}
|
||||
|
||||
.canvas,
|
||||
.viewer-canvas {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(rgba(15, 23, 42, 0.80), rgba(15, 23, 42, 0.80)),
|
||||
radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.22), transparent 28%),
|
||||
radial-gradient(circle at 80% 70%, rgba(16, 185, 129, 0.18), transparent 34%);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.canvas-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-element {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: center center;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas-element:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.canvas-element.is-selected {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.symbol-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.symbol-svg.preserve {
|
||||
preserveAspectRatio: none;
|
||||
}
|
||||
|
||||
.generic-shape {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 4px solid;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.text-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.sensor-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 3px solid;
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.sensor-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 99px;
|
||||
box-shadow: 0 0 20px currentColor;
|
||||
}
|
||||
|
||||
.sensor-label {
|
||||
font-size: 12px;
|
||||
color: rgba(229, 231, 235, 0.75);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sensor-value {
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
font-weight: 950;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.prop-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prop-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty-mini {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(75, 85, 99, 0.6);
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
color: rgba(229, 231, 235, 0.72);
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.form-description {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.form-action {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.designer-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-description {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-picker {
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(75, 85, 99, 0.72);
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.asset-button {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(75, 85, 99, 0.8);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: white;
|
||||
border-radius: 14px;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-button:hover {
|
||||
background: rgba(37, 99, 235, 0.42);
|
||||
}
|
||||
|
||||
.asset-button strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-button span {
|
||||
display: block;
|
||||
color: rgba(209, 213, 219, 0.65);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.asset-preview {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: rgba(2, 6, 23, 0.75);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.asset-canvas-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.asset-create-box {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.asset-create-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(2, 6, 23, 0.55);
|
||||
border: 1px solid rgba(75, 85, 99, 0.8);
|
||||
}
|
||||
|
||||
.asset-files-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-files-grid input {
|
||||
font-size: 11px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/visu/dist/',
|
||||
build: {
|
||||
outDir: '../visu/dist',
|
||||
emptyOutDir: true,
|
||||
manifest: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user