This commit is contained in:
jhartworks
2026-06-22 15:52:25 +02:00
commit 62a697ce45
47 changed files with 26655 additions and 0 deletions
+24
View File
@@ -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?
+16
View File
@@ -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.
+21
View File
@@ -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 } },
},
},
])
+13
View File
@@ -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>
+2410
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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

+24
View File
@@ -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

+4404
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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

+1
View File
@@ -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

+547
View File
@@ -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>
)
}
+354
View File
@@ -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>
)
}
+284
View File
@@ -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>
)
}
+185
View File
@@ -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>
}
+111
View File
@@ -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);
}
+224
View File
@@ -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
}
+87
View File
@@ -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
}
+674
View File
@@ -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})`
}
+10
View File
@@ -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>
)
+483
View File
@@ -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
+426
View File
@@ -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
+172
View File
@@ -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
+645
View File
@@ -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)}>&lt;</button>
<button disabled={disabled || index >= tokens.length - 1} type="button" className="mini-btn" onClick={() => moveToken(index, index + 1)}>&gt;</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>
)
}
+572
View File
@@ -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;
}
+12
View File
@@ -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
}
})