Compare commits
7 Commits
main
...
57e9bf12ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 57e9bf12ca | |||
| 8193ce818a | |||
| 0030dca448 | |||
| 8d1fb5b28e | |||
| 5411bd16ff | |||
| 61a8af109c | |||
| a5bd4985d1 |
+26
@@ -39,3 +39,29 @@ steps:
|
||||
commands:
|
||||
- curl -u 'howard:Papadopolus0' -X POST 'https://portainer.martinfencl.eu/api/stacks/webhooks/72df3f63-b271-4aef-9325-772a2ccbaeca'
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: scrape-cron
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
cron:
|
||||
- lipno-scraper
|
||||
|
||||
steps:
|
||||
- name: scrape-and-commit
|
||||
image: node:18-alpine
|
||||
environment:
|
||||
GIT_AUTHOR_NAME: drone
|
||||
GIT_AUTHOR_EMAIL: drone@internet-master.cz
|
||||
GIT_COMMITTER_NAME: drone
|
||||
GIT_COMMITTER_EMAIL: drone@internet-master.cz
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- npm ci
|
||||
- node scripts/scrapeLipno.js
|
||||
- git add public/data/lipno.json
|
||||
- git commit -m "chore: update lipno reservoir data [CI SKIP]" || true
|
||||
- git push origin main || true
|
||||
@@ -1,73 +1,72 @@
|
||||
# React + TypeScript + Vite
|
||||
# 🌊 HLADINATOR
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
HLADINATOR je interaktivní a vizuálně poutavá webová aplikace pro sledování aktuálního stavu a historie českých přehrad. Aplikace poskytuje přesná data o výšce hladiny, odtoku, přítoku, aktuálním objemu a navíc sbírá historii počasí (teploty a srážek) přímo od zdroje.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
Zdroj dat: **Povodí Vltavy (pvl.cz)** a další povodí v ČR.
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
---
|
||||
|
||||
## React Compiler
|
||||
## 🚀 Jak spustit aplikaci lokálně
|
||||
|
||||
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).
|
||||
Aplikace je postavena na moderním stacku (React, Vite, TypeScript, Recharts, Leaflet). Pro její spuštění nepotřebuješ žádný složitý backend, data se čtou z předgenerovaných JSON souborů.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
1. Nainstaluj závislosti (pokud jsi to ještě neudělal):
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
2. Spusť lokální vývojový server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
3. Otevři prohlížeč na adrese `http://localhost:5173`.
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
---
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
## 🔄 Jak aktualizovat data (Scraping)
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
Povodí Vltavy neposkytuje standardní API pro historii srážek a teplot, ani nepodporuje přímé dotazy z klientského prohlížeče (kvůli CORS a bezpečnosti). Proto využíváme vlastní **scraper**.
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
Pro ruční stažení těch nejnovějších dat z webu povodí spusť v terminálu:
|
||||
```bash
|
||||
npm run data:update
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
Tento příkaz provede dvě věci:
|
||||
1. `npm run scrape`: Otevře stránky povodí pro všech 12 přehrad, přečte tabulky s historickými měřeními a najde "Aktuální hodnoty", odkud vytáhne exaktní **přítok, objem, srážky a teplotu**. Tato data inteligentně sloučí s tvojí lokální databází (`public/data/*.json`). Pokud Povodí aktuálně počasí neposkytuje, skript zrecykluje tvou dřívější uloženou hodnotu, aby se graf "nerozbil".
|
||||
2. `npm run build-index`: Zaktualizuje hlavní indexový soubor `lakes_index.json`, který aplikace využívá pro vykreslení rychlých náhledů (např. v levém menu nebo na mapě).
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
---
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
## ⏰ Automatické stahování dat (Cron / Spouštěč)
|
||||
|
||||
Aby se ti automaticky budovala bohatá historie počasí a srážek i ve chvíli, kdy spíš, doporučuji nastavit automatické spouštění skriptu `npm run data:update`.
|
||||
|
||||
Zde jsou nejběžnější možnosti, jak si to můžeš nastavit ty sám:
|
||||
|
||||
### Možnost A: Přes Crontab na Macu / Linuxu (Lokálně)
|
||||
Pokud ti běží počítač (nebo domácí server/Raspberry Pi) nepřetržitě, můžeš využít systémový `cron`.
|
||||
1. Otevři terminál a napiš: `crontab -e`
|
||||
2. Na konec souboru vlož následující řádek (uprav cestu ke svému projektu a Node.js):
|
||||
```bash
|
||||
# Spustit scraping každých 15 minut
|
||||
*/15 * * * * cd /Users/davis/WebstormProjects/davisfe.cz && /usr/local/bin/npm run data:update >> scraper.log 2>&1
|
||||
```
|
||||
3. Ulož a zavři editor. Od této chvíle se systém postará o automatický sběr dat!
|
||||
|
||||
### Možnost B: Pomocí GitHub Actions (Pro Produkci)
|
||||
Až projekt nahraješ na GitHub, můžeš si vytvořit workflow soubor (např. `.github/workflows/scrape.yml`), který bude skript spouštět na serverech GitHubu zdarma každou hodinu, a výsledné `.json` soubory automaticky commitne a publikuje na web.
|
||||
|
||||
### Možnost C: Jednoduchý integrovaný spouštěč (Nejlehčí)
|
||||
Pokud nechceš řešit složitý systémový crontab, napsal jsem pro tebe přímo do Node.js malý spouštěč. Stačí si otevřít další okno terminálu a napsat:
|
||||
```bash
|
||||
npm run data:watch 10
|
||||
```
|
||||
Tento příkaz ihned provede první stažení a následně bude aplikaci automaticky aktualizovat **každých 10 minut** (číslo na konci si můžeš libovolně přepsat podle toho, jak často chceš stahovat). Skript poběží, dokud okno terminálu nezavřeš.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Struktura klíčových datových složek
|
||||
|
||||
* `/scripts/lakesConfig.ts` - Tady najdeš definici všech 12 sledovaných přehrad (včetně jejich ID pro Povodí Vltavy, GPS souřadnic, maximálních objemů a stavebních kót). Sem můžeš přidávat nové přehrady.
|
||||
* `/public/data/` - Zde se ukládají vygenerovaná JSON data. V produkci musí být tyto soubory přístupné jako statické assety.
|
||||
* `/src/components/` - Obsahuje samotné vizuální karty, Leaflet mapu a detailní `LakeDetail.tsx` (kde se vykresluje hydrologický a meteorologický graf přes Recharts).
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
body, html {
|
||||
margin:0; padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Helvetica Neue, Helvetica, Arial;
|
||||
font-size: 14px;
|
||||
color:#333;
|
||||
}
|
||||
.small { font-size: 12px; }
|
||||
*, *:after, *:before {
|
||||
-webkit-box-sizing:border-box;
|
||||
-moz-box-sizing:border-box;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
h1 { font-size: 20px; margin: 0;}
|
||||
h2 { font-size: 14px; }
|
||||
pre {
|
||||
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-moz-tab-size: 2;
|
||||
-o-tab-size: 2;
|
||||
tab-size: 2;
|
||||
}
|
||||
a { color:#0074D9; text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
.strong { font-weight: bold; }
|
||||
.space-top1 { padding: 10px 0 0 0; }
|
||||
.pad2y { padding: 20px 0; }
|
||||
.pad1y { padding: 10px 0; }
|
||||
.pad2x { padding: 0 20px; }
|
||||
.pad2 { padding: 20px; }
|
||||
.pad1 { padding: 10px; }
|
||||
.space-left2 { padding-left:55px; }
|
||||
.space-right2 { padding-right:20px; }
|
||||
.center { text-align:center; }
|
||||
.clearfix { display:block; }
|
||||
.clearfix:after {
|
||||
content:'';
|
||||
display:block;
|
||||
height:0;
|
||||
clear:both;
|
||||
visibility:hidden;
|
||||
}
|
||||
.fl { float: left; }
|
||||
@media only screen and (max-width:640px) {
|
||||
.col3 { width:100%; max-width:100%; }
|
||||
.hide-mobile { display:none!important; }
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: #7f7f7f;
|
||||
color: rgba(0,0,0,0.5);
|
||||
}
|
||||
.quiet a { opacity: 0.7; }
|
||||
|
||||
.fraction {
|
||||
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
background: #E8E8E8;
|
||||
padding: 4px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div.path a:link, div.path a:visited { color: #333; }
|
||||
table.coverage {
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table.coverage td {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.coverage td.line-count {
|
||||
text-align: right;
|
||||
padding: 0 5px 0 20px;
|
||||
}
|
||||
table.coverage td.line-coverage {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
min-width:20px;
|
||||
}
|
||||
|
||||
table.coverage td span.cline-any {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.missing-if-branch {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #333;
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.skip-if-branch {
|
||||
display: none;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #ccc;
|
||||
color: white;
|
||||
}
|
||||
.missing-if-branch .typ, .skip-if-branch .typ {
|
||||
color: inherit !important;
|
||||
}
|
||||
.coverage-summary {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.coverage-summary tr { border-bottom: 1px solid #bbb; }
|
||||
.keyline-all { border: 1px solid #ddd; }
|
||||
.coverage-summary td, .coverage-summary th { padding: 10px; }
|
||||
.coverage-summary tbody { border: 1px solid #bbb; }
|
||||
.coverage-summary td { border-right: 1px solid #bbb; }
|
||||
.coverage-summary td:last-child { border-right: none; }
|
||||
.coverage-summary th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coverage-summary th.file { border-right: none !important; }
|
||||
.coverage-summary th.pct { }
|
||||
.coverage-summary th.pic,
|
||||
.coverage-summary th.abs,
|
||||
.coverage-summary td.pct,
|
||||
.coverage-summary td.abs { text-align: right; }
|
||||
.coverage-summary td.file { white-space: nowrap; }
|
||||
.coverage-summary td.pic { min-width: 120px !important; }
|
||||
.coverage-summary tfoot td { }
|
||||
|
||||
.coverage-summary .sorter {
|
||||
height: 10px;
|
||||
width: 7px;
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
|
||||
}
|
||||
.coverage-summary .sorted .sorter {
|
||||
background-position: 0 -20px;
|
||||
}
|
||||
.coverage-summary .sorted-desc .sorter {
|
||||
background-position: 0 -10px;
|
||||
}
|
||||
.status-line { height: 10px; }
|
||||
/* yellow */
|
||||
.cbranch-no { background: yellow !important; color: #111; }
|
||||
/* dark red */
|
||||
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
|
||||
.low .chart { border:1px solid #C21F39 }
|
||||
.highlighted,
|
||||
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
|
||||
background: #C21F39 !important;
|
||||
}
|
||||
/* medium red */
|
||||
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
|
||||
/* light red */
|
||||
.low, .cline-no { background:#FCE1E5 }
|
||||
/* light green */
|
||||
.high, .cline-yes { background:rgb(230,245,208) }
|
||||
/* medium green */
|
||||
.cstat-yes { background:rgb(161,215,106) }
|
||||
/* dark green */
|
||||
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
|
||||
.high .chart { border:1px solid rgb(77,146,33) }
|
||||
/* dark yellow (gold) */
|
||||
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
|
||||
.medium .chart { border:1px solid #f9cd0b; }
|
||||
/* light yellow */
|
||||
.medium { background: #fff4c2; }
|
||||
|
||||
.cstat-skip { background: #ddd; color: #111; }
|
||||
.fstat-skip { background: #ddd; color: #111 !important; }
|
||||
.cbranch-skip { background: #ddd !important; color: #111; }
|
||||
|
||||
span.cline-neutral { background: #eaeaea; }
|
||||
|
||||
.coverage-summary td.empty {
|
||||
opacity: .5;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cover-fill, .cover-empty {
|
||||
display:inline-block;
|
||||
height: 12px;
|
||||
}
|
||||
.chart {
|
||||
line-height: 0;
|
||||
}
|
||||
.cover-empty {
|
||||
background: white;
|
||||
}
|
||||
.cover-full {
|
||||
border-right: none !important;
|
||||
}
|
||||
pre.prettyprint {
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.com { color: #999 !important; }
|
||||
.ignore-none { color: #999; font-weight: normal; }
|
||||
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
height: 100%;
|
||||
margin: 0 auto -48px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 48px;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
var jumpToCode = (function init() {
|
||||
// Classes of code we would like to highlight in the file view
|
||||
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
|
||||
|
||||
// Elements to highlight in the file listing view
|
||||
var fileListingElements = ['td.pct.low'];
|
||||
|
||||
// We don't want to select elements that are direct descendants of another match
|
||||
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
|
||||
|
||||
// Selector that finds elements on the page to which we can jump
|
||||
var selector =
|
||||
fileListingElements.join(', ') +
|
||||
', ' +
|
||||
notSelector +
|
||||
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
|
||||
|
||||
// The NodeList of matching elements
|
||||
var missingCoverageElements = document.querySelectorAll(selector);
|
||||
|
||||
var currentIndex;
|
||||
|
||||
function toggleClass(index) {
|
||||
missingCoverageElements
|
||||
.item(currentIndex)
|
||||
.classList.remove('highlighted');
|
||||
missingCoverageElements.item(index).classList.add('highlighted');
|
||||
}
|
||||
|
||||
function makeCurrent(index) {
|
||||
toggleClass(index);
|
||||
currentIndex = index;
|
||||
missingCoverageElements.item(index).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
var nextIndex = 0;
|
||||
if (typeof currentIndex !== 'number' || currentIndex === 0) {
|
||||
nextIndex = missingCoverageElements.length - 1;
|
||||
} else if (missingCoverageElements.length > 1) {
|
||||
nextIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
var nextIndex = 0;
|
||||
|
||||
if (
|
||||
typeof currentIndex === 'number' &&
|
||||
currentIndex < missingCoverageElements.length - 1
|
||||
) {
|
||||
nextIndex = currentIndex + 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
return function jump(event) {
|
||||
if (
|
||||
document.getElementById('fileSearch') === document.activeElement &&
|
||||
document.activeElement != null
|
||||
) {
|
||||
// if we're currently focused on the search input, we don't want to navigate
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 78: // n
|
||||
case 74: // j
|
||||
goToNext();
|
||||
break;
|
||||
case 66: // b
|
||||
case 75: // k
|
||||
case 80: // p
|
||||
goToPrevious();
|
||||
break;
|
||||
}
|
||||
};
|
||||
})();
|
||||
window.addEventListener('keydown', jumpToCode);
|
||||
@@ -0,0 +1,133 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<coverage generated="1780693430332" clover="3.2.0">
|
||||
<project timestamp="1780693430332" name="All files">
|
||||
<metrics statements="106" coveredstatements="32" conditionals="98" coveredconditionals="29" methods="18" coveredmethods="5" elements="222" coveredelements="66" complexity="0" loc="106" ncloc="106" packages="3" files="4" classes="4"/>
|
||||
<package name="scripts">
|
||||
<metrics statements="93" coveredstatements="24" conditionals="75" coveredconditionals="14" methods="12" coveredmethods="3"/>
|
||||
<file name="lakesConfig.ts" path="/Users/davis/WebstormProjects/davisfe.cz/scripts/lakesConfig.ts">
|
||||
<metrics statements="1" coveredstatements="1" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0"/>
|
||||
<line num="12" count="1" type="stmt"/>
|
||||
</file>
|
||||
<file name="scrapeLakes.ts" path="/Users/davis/WebstormProjects/davisfe.cz/scripts/scrapeLakes.ts">
|
||||
<metrics statements="92" coveredstatements="23" conditionals="75" coveredconditionals="14" methods="12" coveredmethods="3"/>
|
||||
<line num="20" count="7" type="stmt"/>
|
||||
<line num="21" count="7" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="22" count="4" type="stmt"/>
|
||||
<line num="23" count="4" type="stmt"/>
|
||||
<line num="24" count="4" type="stmt"/>
|
||||
<line num="26" count="4" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="28" count="2" type="stmt"/>
|
||||
<line num="29" count="2" type="stmt"/>
|
||||
<line num="30" count="2" type="stmt"/>
|
||||
<line num="31" count="2" type="stmt"/>
|
||||
<line num="32" count="2" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="33" count="2" type="cond" truecount="5" falsecount="0"/>
|
||||
<line num="34" count="1" type="stmt"/>
|
||||
<line num="36" count="0" type="stmt"/>
|
||||
<line num="41" count="1" type="stmt"/>
|
||||
<line num="42" count="1" type="stmt"/>
|
||||
<line num="44" count="1" type="stmt"/>
|
||||
<line num="45" count="1" type="stmt"/>
|
||||
<line num="46" count="1" type="stmt"/>
|
||||
<line num="53" count="0" type="stmt"/>
|
||||
<line num="55" count="0" type="stmt"/>
|
||||
<line num="56" count="0" type="stmt"/>
|
||||
<line num="57" count="0" type="stmt"/>
|
||||
<line num="58" count="0" type="stmt"/>
|
||||
<line num="60" count="0" type="stmt"/>
|
||||
<line num="61" count="0" type="stmt"/>
|
||||
<line num="62" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="63" count="0" type="stmt"/>
|
||||
<line num="64" count="0" type="stmt"/>
|
||||
<line num="65" count="0" type="stmt"/>
|
||||
<line num="66" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="67" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="68" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="69" count="0" type="stmt"/>
|
||||
<line num="70" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="72" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="73" count="0" type="stmt"/>
|
||||
<line num="74" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="80" count="0" type="stmt"/>
|
||||
<line num="81" count="0" type="stmt"/>
|
||||
<line num="82" count="0" type="stmt"/>
|
||||
<line num="83" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="84" count="0" type="stmt"/>
|
||||
<line num="88" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="89" count="0" type="stmt"/>
|
||||
<line num="90" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="91" count="0" type="stmt"/>
|
||||
<line num="92" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="93" count="0" type="stmt"/>
|
||||
<line num="94" count="0" type="stmt"/>
|
||||
<line num="95" count="0" type="stmt"/>
|
||||
<line num="96" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="97" count="0" type="stmt"/>
|
||||
<line num="100" count="0" type="stmt"/>
|
||||
<line num="101" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="102" count="0" type="stmt"/>
|
||||
<line num="114" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="116" count="0" type="stmt"/>
|
||||
<line num="117" count="0" type="stmt"/>
|
||||
<line num="118" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="119" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="122" count="0" type="stmt"/>
|
||||
<line num="123" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="124" count="0" type="stmt"/>
|
||||
<line num="125" count="0" type="stmt"/>
|
||||
<line num="128" count="0" type="stmt"/>
|
||||
<line num="129" count="0" type="stmt"/>
|
||||
<line num="130" count="0" type="stmt"/>
|
||||
<line num="132" count="0" type="stmt"/>
|
||||
<line num="133" count="0" type="stmt"/>
|
||||
<line num="137" count="0" type="stmt"/>
|
||||
<line num="138" count="0" type="stmt"/>
|
||||
<line num="139" count="0" type="stmt"/>
|
||||
<line num="140" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="141" count="0" type="stmt"/>
|
||||
<line num="142" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="143" count="0" type="stmt"/>
|
||||
<line num="146" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="147" count="0" type="stmt"/>
|
||||
<line num="148" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="149" count="0" type="stmt"/>
|
||||
<line num="153" count="0" type="stmt"/>
|
||||
<line num="154" count="0" type="stmt"/>
|
||||
<line num="156" count="0" type="stmt"/>
|
||||
<line num="159" count="0" type="stmt"/>
|
||||
<line num="164" count="1" type="stmt"/>
|
||||
<line num="166" count="1" type="stmt"/>
|
||||
<line num="168" count="1" type="stmt"/>
|
||||
<line num="169" count="1" type="stmt"/>
|
||||
<line num="171" count="0" type="stmt"/>
|
||||
<line num="174" count="0" type="stmt"/>
|
||||
<line num="177" count="1" type="stmt"/>
|
||||
</file>
|
||||
</package>
|
||||
<package name="src">
|
||||
<metrics statements="1" coveredstatements="1" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0"/>
|
||||
<file name="translations.ts" path="/Users/davis/WebstormProjects/davisfe.cz/src/translations.ts">
|
||||
<metrics statements="1" coveredstatements="1" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0"/>
|
||||
<line num="3" count="2" type="stmt"/>
|
||||
</file>
|
||||
</package>
|
||||
<package name="src.components">
|
||||
<metrics statements="12" coveredstatements="7" conditionals="23" coveredconditionals="15" methods="6" coveredmethods="2"/>
|
||||
<file name="KpiCards.tsx" path="/Users/davis/WebstormProjects/davisfe.cz/src/components/KpiCards.tsx">
|
||||
<metrics statements="12" coveredstatements="7" conditionals="23" coveredconditionals="15" methods="6" coveredmethods="2"/>
|
||||
<line num="21" count="1" type="cond" truecount="1" falsecount="0"/>
|
||||
<line num="22" count="3" type="stmt"/>
|
||||
<line num="23" count="3" type="stmt"/>
|
||||
<line num="24" count="3" type="stmt"/>
|
||||
<line num="26" count="3" type="stmt"/>
|
||||
<line num="27" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="28" count="0" type="stmt"/>
|
||||
<line num="29" count="0" type="stmt"/>
|
||||
<line num="31" count="0" type="stmt"/>
|
||||
<line num="35" count="3" type="stmt"/>
|
||||
<line num="86" count="0" type="stmt"/>
|
||||
<line num="93" count="0" type="stmt"/>
|
||||
</file>
|
||||
</package>
|
||||
</project>
|
||||
</coverage>
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,146 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for All files</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1>All files</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">28.92% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>35/121</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">29.59% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>29/98</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">27.77% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>5/18</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">30.18% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>32/106</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line low'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file low" data-value="scripts"><a href="scripts/index.html">scripts</a></td>
|
||||
<td data-value="25.23" class="pic low">
|
||||
<div class="chart"><div class="cover-fill" style="width: 25%"></div><div class="cover-empty" style="width: 75%"></div></div>
|
||||
</td>
|
||||
<td data-value="25.23" class="pct low">25.23%</td>
|
||||
<td data-value="107" class="abs low">27/107</td>
|
||||
<td data-value="18.66" class="pct low">18.66%</td>
|
||||
<td data-value="75" class="abs low">14/75</td>
|
||||
<td data-value="25" class="pct low">25%</td>
|
||||
<td data-value="12" class="abs low">3/12</td>
|
||||
<td data-value="25.8" class="pct low">25.8%</td>
|
||||
<td data-value="93" class="abs low">24/93</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file high" data-value="src"><a href="src/index.html">src</a></td>
|
||||
<td data-value="100" class="pic high">
|
||||
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
|
||||
</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="1" class="abs high">1/1</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="0" class="abs high">0/0</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="0" class="abs high">0/0</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="1" class="abs high">1/1</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file medium" data-value="src/components"><a href="src/components/index.html">src/components</a></td>
|
||||
<td data-value="53.84" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 53%"></div><div class="cover-empty" style="width: 47%"></div></div>
|
||||
</td>
|
||||
<td data-value="53.84" class="pct medium">53.84%</td>
|
||||
<td data-value="13" class="abs medium">7/13</td>
|
||||
<td data-value="65.21" class="pct medium">65.21%</td>
|
||||
<td data-value="23" class="abs medium">15/23</td>
|
||||
<td data-value="33.33" class="pct low">33.33%</td>
|
||||
<td data-value="6" class="abs low">2/6</td>
|
||||
<td data-value="58.33" class="pct medium">58.33%</td>
|
||||
<td data-value="12" class="abs medium">7/12</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,131 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for scripts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../prettify.css" />
|
||||
<link rel="stylesheet" href="../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../index.html">All files</a> scripts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">25.23% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>27/107</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">18.66% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>14/75</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">25% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>3/12</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">25.8% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>24/93</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line low'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file high" data-value="lakesConfig.ts"><a href="lakesConfig.ts.html">lakesConfig.ts</a></td>
|
||||
<td data-value="100" class="pic high">
|
||||
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
|
||||
</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="1" class="abs high">1/1</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="0" class="abs high">0/0</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="0" class="abs high">0/0</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="1" class="abs high">1/1</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file low" data-value="scrapeLakes.ts"><a href="scrapeLakes.ts.html">scrapeLakes.ts</a></td>
|
||||
<td data-value="24.52" class="pic low">
|
||||
<div class="chart"><div class="cover-fill" style="width: 24%"></div><div class="cover-empty" style="width: 76%"></div></div>
|
||||
</td>
|
||||
<td data-value="24.52" class="pct low">24.52%</td>
|
||||
<td data-value="106" class="abs low">26/106</td>
|
||||
<td data-value="18.66" class="pct low">18.66%</td>
|
||||
<td data-value="75" class="abs low">14/75</td>
|
||||
<td data-value="25" class="pct low">25%</td>
|
||||
<td data-value="12" class="abs low">3/12</td>
|
||||
<td data-value="25" class="pct low">25%</td>
|
||||
<td data-value="92" class="abs low">23/92</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../sorter.js"></script>
|
||||
<script src="../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for scripts/lakesConfig.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../prettify.css" />
|
||||
<link rel="stylesheet" href="../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../index.html">All files</a> / <a href="index.html">scripts</a> lakesConfig.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>1/1</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>1/1</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">export interface LakeConfig {
|
||||
id: string;
|
||||
text: string;
|
||||
priority?: boolean;
|
||||
coords: [number, number];
|
||||
maxVolume?: number;
|
||||
minLevel?: number;
|
||||
maxLevel?: number;
|
||||
storageLevel?: number;
|
||||
}
|
||||
|
||||
export const lakesConfig: LakeConfig[] = [
|
||||
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306.0, minLevel: 715.00, maxLevel: 725.60, storageLevel: 724.9 },
|
||||
{ id: "VLL2|1", text: "VD Lipno II - Vltava", coords: [48.6250, 14.3180], maxVolume: 1.5, minLevel: 510.0, maxLevel: 511.5, storageLevel: 511.5 },
|
||||
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 365.0, maxLevel: 370.5, storageLevel: 370.1 },
|
||||
{ id: "VLKO|1", text: "VD Kořensko - Vltava", coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 352.0, maxLevel: 353.5, storageLevel: 352.6 },
|
||||
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 330.00, maxLevel: 354.00, storageLevel: 349.9 },
|
||||
{ id: "UHKA|2", text: "VD Kamýk - Vltava", coords: [49.6360, 14.2540], maxVolume: 12.8, minLevel: 283.0, maxLevel: 285.6, storageLevel: 285.6 },
|
||||
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 265.50, maxLevel: 271.10, storageLevel: 270.6 },
|
||||
{ id: "VLST|2", text: "VD Štěchovice - Vltava", coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 217.0, maxLevel: 219.5, storageLevel: 219.4 },
|
||||
{ id: "VRSN|2", text: "VD Vrané - Vltava", coords: [49.9340, 14.3850], maxVolume: 11.1, minLevel: 198.5, maxLevel: 200.5, storageLevel: 200.5 },
|
||||
{ id: "SVKR|2", text: "VD Švihov - Želivka", priority: true, coords: [49.7180, 15.1060], maxVolume: 266.6, minLevel: 343.0, maxLevel: 377.0, storageLevel: 377.0 },
|
||||
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 458.0, maxLevel: 471.0, storageLevel: 470.65 },
|
||||
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 360.0, maxLevel: 373.0, storageLevel: 354.1 }
|
||||
];
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../sorter.js"></script>
|
||||
<script src="../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for scripts/scrapeLakes.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../prettify.css" />
|
||||
<link rel="stylesheet" href="../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../index.html">All files</a> / <a href="index.html">scripts</a> scrapeLakes.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">24.52% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>26/106</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">18.66% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>14/75</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">25% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>3/12</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">25% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>23/92</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line low'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a>
|
||||
<a name='L148'></a><a href='#L148'>148</a>
|
||||
<a name='L149'></a><a href='#L149'>149</a>
|
||||
<a name='L150'></a><a href='#L150'>150</a>
|
||||
<a name='L151'></a><a href='#L151'>151</a>
|
||||
<a name='L152'></a><a href='#L152'>152</a>
|
||||
<a name='L153'></a><a href='#L153'>153</a>
|
||||
<a name='L154'></a><a href='#L154'>154</a>
|
||||
<a name='L155'></a><a href='#L155'>155</a>
|
||||
<a name='L156'></a><a href='#L156'>156</a>
|
||||
<a name='L157'></a><a href='#L157'>157</a>
|
||||
<a name='L158'></a><a href='#L158'>158</a>
|
||||
<a name='L159'></a><a href='#L159'>159</a>
|
||||
<a name='L160'></a><a href='#L160'>160</a>
|
||||
<a name='L161'></a><a href='#L161'>161</a>
|
||||
<a name='L162'></a><a href='#L162'>162</a>
|
||||
<a name='L163'></a><a href='#L163'>163</a>
|
||||
<a name='L164'></a><a href='#L164'>164</a>
|
||||
<a name='L165'></a><a href='#L165'>165</a>
|
||||
<a name='L166'></a><a href='#L166'>166</a>
|
||||
<a name='L167'></a><a href='#L167'>167</a>
|
||||
<a name='L168'></a><a href='#L168'>168</a>
|
||||
<a name='L169'></a><a href='#L169'>169</a>
|
||||
<a name='L170'></a><a href='#L170'>170</a>
|
||||
<a name='L171'></a><a href='#L171'>171</a>
|
||||
<a name='L172'></a><a href='#L172'>172</a>
|
||||
<a name='L173'></a><a href='#L173'>173</a>
|
||||
<a name='L174'></a><a href='#L174'>174</a>
|
||||
<a name='L175'></a><a href='#L175'>175</a>
|
||||
<a name='L176'></a><a href='#L176'>176</a>
|
||||
<a name='L177'></a><a href='#L177'>177</a>
|
||||
<a name='L178'></a><a href='#L178'>178</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cheerio from 'cheerio';
|
||||
import axios from 'axios';
|
||||
import https from 'https';
|
||||
import { lakesConfig } from './lakesConfig';
|
||||
|
||||
interface DataRecord {
|
||||
timestamp: string;
|
||||
level: number;
|
||||
flow: number;
|
||||
inflow?: number;
|
||||
volume?: number;
|
||||
temperature?: number | null;
|
||||
precipitation?: number | null;
|
||||
}
|
||||
|
||||
// Parse date from DD.MM.YYYY HH:MM to ISO
|
||||
export function parseDateString(dateStr: string): string | null {
|
||||
try {
|
||||
if (!dateStr || !dateStr.includes(' ')) return null;
|
||||
const [datePart, timePart] = dateStr.trim().split(' ');
|
||||
const [day, month, year] = datePart.split('.');
|
||||
const [hours, minutes] = timePart.split(':');
|
||||
|
||||
if (!year || !hours) return null;
|
||||
|
||||
const y = parseInt(year);
|
||||
const m = parseInt(month) - 1;
|
||||
const dDay = parseInt(day);
|
||||
const d = new Date(y, m, dDay, parseInt(hours), parseInt(minutes));
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (isNaN(d.getTime())) <span class="cstat-no" title="statement not covered" >return null;</span>
|
||||
if (d.getFullYear() !== y || d.getMonth() !== m || d.getDate() !== dDay) return null;
|
||||
return d.toISOString();
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > return null;</span>
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
||||
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
|
||||
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
|
||||
|
||||
try {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
const response = await axios.get(URL, {
|
||||
httpsAgent: agent,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
const $ = <span class="cstat-no" title="statement not covered" >cheerio.load(response.data);</span>
|
||||
|
||||
let currentInflow = <span class="cstat-no" title="statement not covered" >0;</span>
|
||||
let currentVolume = <span class="cstat-no" title="statement not covered" >0;</span>
|
||||
let currentTemp: number | null = <span class="cstat-no" title="statement not covered" >null;</span>
|
||||
let currentPrecip: number | null = <span class="cstat-no" title="statement not covered" >null;</span>
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > $('table').each(<span class="fstat-no" title="function not covered" >(i</span>, tbl) => {</span>
|
||||
const text = <span class="cstat-no" title="statement not covered" >$(tbl).text();</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (text.includes('Aktuální hodnoty') && text.includes('Přítok')) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > $(tbl).find('tr').each(<span class="fstat-no" title="function not covered" >(j</span>, r) => {</span>
|
||||
const label = <span class="cstat-no" title="statement not covered" >$(r).find('td').eq(0).text().trim();</span>
|
||||
const valStr = <span class="cstat-no" title="statement not covered" >$(r).find('td').eq(1).text().trim().replace(/\s/g, '').replace(',', '.');</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (label.includes('Přítok')) <span class="cstat-no" title="statement not covered" >currentInflow = parseFloat(valStr) || 0;</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > if (label.includes('Objem')) <span class="cstat-no" title="statement not covered" >currentVolume = parseFloat(valStr) || 0;</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > if (label.includes('Teplota')) {</span>
|
||||
const v = <span class="cstat-no" title="statement not covered" >parseFloat(valStr);</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (!isNaN(v)) <span class="cstat-no" title="statement not covered" >currentTemp = v;</span></span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > if (label.includes('Srážky')) {</span>
|
||||
const v = <span class="cstat-no" title="statement not covered" >parseFloat(valStr);</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (!isNaN(v)) <span class="cstat-no" title="statement not covered" >currentPrecip = v;</span></span>
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const records: DataRecord[] = <span class="cstat-no" title="statement not covered" >[];</span>
|
||||
let dataTable = <span class="cstat-no" title="statement not covered" >null;</span>
|
||||
<span class="cstat-no" title="statement not covered" > $('table').each(<span class="fstat-no" title="function not covered" >(i</span>, tbl) => {</span>
|
||||
<span class="cstat-no" title="statement not covered" > if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > dataTable = $(tbl);</span>
|
||||
}
|
||||
});
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > if (dataTable) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > dataTable.find('tr').each(<span class="fstat-no" title="function not covered" >(i</span>, row) => {</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (i === 0) <span class="cstat-no" title="statement not covered" >return; // skip header</span></span>
|
||||
const cols = <span class="cstat-no" title="statement not covered" >$(row).find('td');</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (cols.length >= 3) {</span>
|
||||
const rawDate = <span class="cstat-no" title="statement not covered" >$(cols[0]).text().trim(); </span>
|
||||
const levelStr = <span class="cstat-no" title="statement not covered" >$(cols[1]).text().trim().replace(',', '.');</span>
|
||||
let flowStr = <span class="cstat-no" title="statement not covered" >$(cols[2]).text().trim().replace(',', '.');</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (flowStr === '' && cols.length >= 4) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > flowStr = $(cols[3]).text().trim().replace(',', '.');</span>
|
||||
}
|
||||
|
||||
const parsedDateStr = <span class="cstat-no" title="statement not covered" >parseDateString(rawDate);</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (parsedDateStr) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > records.push({</span>
|
||||
timestamp: parsedDateStr,
|
||||
level: parseFloat(levelStr) || 0,
|
||||
flow: parseFloat(flowStr) || 0,
|
||||
inflow: 0,
|
||||
volume: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > if (records.length > 0) {</span>
|
||||
// Apply current values to the latest record
|
||||
<span class="cstat-no" title="statement not covered" > records[0].inflow = currentInflow;</span>
|
||||
<span class="cstat-no" title="statement not covered" > records[0].volume = currentVolume;</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (currentTemp !== null) <span class="cstat-no" title="statement not covered" >records[0].temperature = currentTemp;</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > if (currentPrecip !== null) <span class="cstat-no" title="statement not covered" >records[0].precipitation = currentPrecip;</span></span>
|
||||
}
|
||||
|
||||
let existingData: DataRecord[] = <span class="cstat-no" title="statement not covered" >[];</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (fs.existsSync(DATA_FILE)) {</span>
|
||||
const fileContent = <span class="cstat-no" title="statement not covered" >fs.readFileSync(DATA_FILE, 'utf-8');</span>
|
||||
<span class="cstat-no" title="statement not covered" > existingData = JSON.parse(fileContent);</span>
|
||||
}
|
||||
|
||||
const dataMap = <span class="cstat-no" title="statement not covered" >new Map<string, DataRecord>();</span>
|
||||
<span class="cstat-no" title="statement not covered" > existingData.forEach(<span class="fstat-no" title="function not covered" >item => <span class="cstat-no" title="statement not covered" >d</span>ataMap.set(item.timestamp, item))</span>;</span>
|
||||
<span class="cstat-no" title="statement not covered" > records.forEach(<span class="fstat-no" title="function not covered" >item => <span class="cstat-no" title="statement not covered" >d</span>ataMap.set(item.timestamp, item))</span>;</span>
|
||||
|
||||
const mergedData = <span class="cstat-no" title="statement not covered" >Array.from(dataMap.values()).sort(<span class="fstat-no" title="function not covered" >(a</span>, b) => {</span>
|
||||
<span class="cstat-no" title="statement not covered" > return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();</span>
|
||||
});
|
||||
|
||||
// Propagate previous values if missing (user requested)
|
||||
let lastKnownTemp: number | null = <span class="cstat-no" title="statement not covered" >null;</span>
|
||||
let lastKnownPrecip: number | null = <span class="cstat-no" title="statement not covered" >null;</span>
|
||||
<span class="cstat-no" title="statement not covered" > mergedData.forEach(<span class="fstat-no" title="function not covered" >item => {</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > if (item.temperature !== undefined && item.temperature !== null) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > lastKnownTemp = item.temperature;</span>
|
||||
<span class="cstat-no" title="statement not covered" > } else if (lastKnownTemp !== null) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > item.temperature = lastKnownTemp;</span>
|
||||
}
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > if (item.precipitation !== undefined && item.precipitation !== null) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > lastKnownPrecip = item.precipitation;</span>
|
||||
<span class="cstat-no" title="statement not covered" > } else if (lastKnownPrecip !== null) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > item.precipitation = lastKnownPrecip;</span>
|
||||
}
|
||||
});
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });</span>
|
||||
<span class="cstat-no" title="statement not covered" > fs.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');</span>
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > console.log(`[${internalId}] Scraped ${records.length} records. DB total: ${mergedData.length}`);</span>
|
||||
|
||||
} catch (error: any) {
|
||||
<span class="cstat-no" title="statement not covered" > console.error(`[${internalId}] Error scraping data:`, error.message);</span>
|
||||
}
|
||||
}
|
||||
|
||||
async function runScraper() {
|
||||
console.log(`Starting bulk scraper for ${lakesConfig.length} lakes...`);
|
||||
|
||||
for (const lake of lakesConfig) {
|
||||
// ID format: VLL1|1 -> internalId=VLL1, oid=1
|
||||
const [internalId, oid] = lake.id.split('|');
|
||||
await scrapeLake(lake.id, oid, internalId);
|
||||
// Add small delay to not hammer the server
|
||||
<span class="cstat-no" title="statement not covered" > await new Promise(<span class="fstat-no" title="function not covered" >resolve => <span class="cstat-no" title="statement not covered" >s</span>etTimeout(resolve, 500))</span>;</span>
|
||||
}
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > console.log('Bulk scraping finished.');</span>
|
||||
}
|
||||
|
||||
runScraper();
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../sorter.js"></script>
|
||||
<script src="../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable */
|
||||
var addSorting = (function() {
|
||||
'use strict';
|
||||
var cols,
|
||||
currentSort = {
|
||||
index: 0,
|
||||
desc: false
|
||||
};
|
||||
|
||||
// returns the summary table element
|
||||
function getTable() {
|
||||
return document.querySelector('.coverage-summary');
|
||||
}
|
||||
// returns the thead element of the summary table
|
||||
function getTableHeader() {
|
||||
return getTable().querySelector('thead tr');
|
||||
}
|
||||
// returns the tbody element of the summary table
|
||||
function getTableBody() {
|
||||
return getTable().querySelector('tbody');
|
||||
}
|
||||
// returns the th element for nth column
|
||||
function getNthColumn(n) {
|
||||
return getTableHeader().querySelectorAll('th')[n];
|
||||
}
|
||||
|
||||
function onFilterInput() {
|
||||
const searchValue = document.getElementById('fileSearch').value;
|
||||
const rows = document.getElementsByTagName('tbody')[0].children;
|
||||
|
||||
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
|
||||
// it will be treated as a plain text search
|
||||
let searchRegex;
|
||||
try {
|
||||
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
|
||||
} catch (error) {
|
||||
searchRegex = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
let isMatch = false;
|
||||
|
||||
if (searchRegex) {
|
||||
// If a valid regex was created, use it for matching
|
||||
isMatch = searchRegex.test(row.textContent);
|
||||
} else {
|
||||
// Otherwise, fall back to the original plain text search
|
||||
isMatch = row.textContent
|
||||
.toLowerCase()
|
||||
.includes(searchValue.toLowerCase());
|
||||
}
|
||||
|
||||
row.style.display = isMatch ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// loads the search box
|
||||
function addSearchBox() {
|
||||
var template = document.getElementById('filterTemplate');
|
||||
var templateClone = template.content.cloneNode(true);
|
||||
templateClone.getElementById('fileSearch').oninput = onFilterInput;
|
||||
template.parentElement.appendChild(templateClone);
|
||||
}
|
||||
|
||||
// loads all columns
|
||||
function loadColumns() {
|
||||
var colNodes = getTableHeader().querySelectorAll('th'),
|
||||
colNode,
|
||||
cols = [],
|
||||
col,
|
||||
i;
|
||||
|
||||
for (i = 0; i < colNodes.length; i += 1) {
|
||||
colNode = colNodes[i];
|
||||
col = {
|
||||
key: colNode.getAttribute('data-col'),
|
||||
sortable: !colNode.getAttribute('data-nosort'),
|
||||
type: colNode.getAttribute('data-type') || 'string'
|
||||
};
|
||||
cols.push(col);
|
||||
if (col.sortable) {
|
||||
col.defaultDescSort = col.type === 'number';
|
||||
colNode.innerHTML =
|
||||
colNode.innerHTML + '<span class="sorter"></span>';
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
// attaches a data attribute to every tr element with an object
|
||||
// of data values keyed by column name
|
||||
function loadRowData(tableRow) {
|
||||
var tableCols = tableRow.querySelectorAll('td'),
|
||||
colNode,
|
||||
col,
|
||||
data = {},
|
||||
i,
|
||||
val;
|
||||
for (i = 0; i < tableCols.length; i += 1) {
|
||||
colNode = tableCols[i];
|
||||
col = cols[i];
|
||||
val = colNode.getAttribute('data-value');
|
||||
if (col.type === 'number') {
|
||||
val = Number(val);
|
||||
}
|
||||
data[col.key] = val;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
// loads all row data
|
||||
function loadData() {
|
||||
var rows = getTableBody().querySelectorAll('tr'),
|
||||
i;
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
rows[i].data = loadRowData(rows[i]);
|
||||
}
|
||||
}
|
||||
// sorts the table using the data for the ith column
|
||||
function sortByIndex(index, desc) {
|
||||
var key = cols[index].key,
|
||||
sorter = function(a, b) {
|
||||
a = a.data[key];
|
||||
b = b.data[key];
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
},
|
||||
finalSorter = sorter,
|
||||
tableBody = document.querySelector('.coverage-summary tbody'),
|
||||
rowNodes = tableBody.querySelectorAll('tr'),
|
||||
rows = [],
|
||||
i;
|
||||
|
||||
if (desc) {
|
||||
finalSorter = function(a, b) {
|
||||
return -1 * sorter(a, b);
|
||||
};
|
||||
}
|
||||
|
||||
for (i = 0; i < rowNodes.length; i += 1) {
|
||||
rows.push(rowNodes[i]);
|
||||
tableBody.removeChild(rowNodes[i]);
|
||||
}
|
||||
|
||||
rows.sort(finalSorter);
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
tableBody.appendChild(rows[i]);
|
||||
}
|
||||
}
|
||||
// removes sort indicators for current column being sorted
|
||||
function removeSortIndicators() {
|
||||
var col = getNthColumn(currentSort.index),
|
||||
cls = col.className;
|
||||
|
||||
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
|
||||
col.className = cls;
|
||||
}
|
||||
// adds sort indicators for current column being sorted
|
||||
function addSortIndicators() {
|
||||
getNthColumn(currentSort.index).className += currentSort.desc
|
||||
? ' sorted-desc'
|
||||
: ' sorted';
|
||||
}
|
||||
// adds event listeners for all sorter widgets
|
||||
function enableUI() {
|
||||
var i,
|
||||
el,
|
||||
ithSorter = function ithSorter(i) {
|
||||
var col = cols[i];
|
||||
|
||||
return function() {
|
||||
var desc = col.defaultDescSort;
|
||||
|
||||
if (currentSort.index === i) {
|
||||
desc = !currentSort.desc;
|
||||
}
|
||||
sortByIndex(i, desc);
|
||||
removeSortIndicators();
|
||||
currentSort.index = i;
|
||||
currentSort.desc = desc;
|
||||
addSortIndicators();
|
||||
};
|
||||
};
|
||||
for (i = 0; i < cols.length; i += 1) {
|
||||
if (cols[i].sortable) {
|
||||
// add the click event handler on the th so users
|
||||
// dont have to click on those tiny arrows
|
||||
el = getNthColumn(i).querySelector('.sorter').parentElement;
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener('click', ithSorter(i));
|
||||
} else {
|
||||
el.attachEvent('onclick', ithSorter(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// adds sorting functionality to the UI
|
||||
return function() {
|
||||
if (!getTable()) {
|
||||
return;
|
||||
}
|
||||
cols = loadColumns();
|
||||
loadData();
|
||||
addSearchBox();
|
||||
addSortIndicators();
|
||||
enableUI();
|
||||
};
|
||||
})();
|
||||
|
||||
window.addEventListener('load', addSorting);
|
||||
@@ -0,0 +1,466 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/components/KpiCards.tsx</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> KpiCards.tsx</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">53.84% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>7/13</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">65.21% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>15/23</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">33.33% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>2/6</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">58.33% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>7/12</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface KpiData {
|
||||
level: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
storageDiff?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KpiData;
|
||||
language: Language;
|
||||
lakeName?: string;
|
||||
}
|
||||
|
||||
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const dict = t[language].kpi;
|
||||
const flowDiff = data.inflow - data.outflow;
|
||||
|
||||
useEffect(() => {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (showTooltip) {
|
||||
const timer = <span class="cstat-no" title="statement not covered" >setTimeout(<span class="fstat-no" title="function not covered" >() => {</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > setShowTooltip(false);</span>
|
||||
}, 3500);
|
||||
<span class="cstat-no" title="statement not covered" > return <span class="fstat-no" title="function not covered" >() => <span class="cstat-no" title="statement not covered" >c</span>learTimeout(timer);</span></span>
|
||||
}
|
||||
}, [showTooltip]);
|
||||
|
||||
return (
|
||||
<div className="kpi-grid-container">
|
||||
{/* CARD 1: HLADINA */}
|
||||
<div className="kpi-card">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{dict.level} {lakeName}
|
||||
</div>
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{data.level.toFixed(2)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n. m.</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--color-green)' }}>
|
||||
(+0.02 m / 24h)
|
||||
</div>
|
||||
|
||||
{/* Decorative Circle for Level */}
|
||||
<div style={{ position: 'absolute', right: '1.5rem', top: '1.5rem', width: '40px', height: '40px', borderRadius: '50%', border: '4px solid rgba(0, 195, 255, 0.2)', borderTopColor: 'var(--color-cyan)', transform: 'rotate(45deg)' }}></div>
|
||||
</div>
|
||||
|
||||
{/* CARD 2: PRŮTOK */}
|
||||
<div className="kpi-card">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{dict.flow}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#8b5cf6', marginRight: '6px' }}></span>
|
||||
{dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-orange)', marginRight: '2px' }}></span>
|
||||
{dict.outflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.outflow.toFixed(1)} m³/s</span>
|
||||
{flowDiff > 0 ? <FiArrowUp color="var(--color-green)" /> : <span class="branch-1 cbranch-no" title="branch not covered" >flowDiff < 0 ? <FiArrowDown color="var(--color-red)" /> : null}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Circle */}
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '50%', border: '4px solid rgba(0, 195, 255, 0.2)', borderTopColor: 'var(--color-cyan)', borderRightColor: 'var(--color-cyan)', display: 'flex', alignItems: 'center', justifyContent: 'center', transform: 'rotate(-45deg)' }}>
|
||||
<span style={{ fontSize: '0.65rem', transform: 'rotate(45deg)', color: 'var(--text-main)', fontWeight: 'bold' }}>
|
||||
<div style={{ lineHeight: 1 }}>{data.outflow.toFixed(1)}</div>
|
||||
<div style={{ fontSize: '0.45rem', opacity: 0.7 }}>m³/s</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CARD 3: NAPLNĚNOST */}
|
||||
<div className="kpi-card">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
|
||||
{dict.fullness}
|
||||
<span
|
||||
onClick={<span class="fstat-no" title="function not covered" >() => <span class="cstat-no" title="statement not covered" >s</span>etShowTooltip(!showTooltip)}</span>
|
||||
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.6, padding: '0 4px' }}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
{showTooltip && (
|
||||
<span class="branch-1 cbranch-no" title="branch not covered" > <div </span>
|
||||
onClick={<span class="fstat-no" title="function not covered" >() => <span class="cstat-no" title="statement not covered" >s</span>etShowTooltip(false)}</span>
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '8px',
|
||||
width: '250px',
|
||||
zIndex: 100,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
{language === 'cs' ? "Rozdíl mezi aktuální hladinou a hladinou zásobního prostoru (důležité pro jachtaře a rekreaci)." : "Difference between current water level and storage space level (important for sailing and recreation)."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
|
||||
{data.storageDiff !== undefined && data.storageDiff !== 0 ? (data.storageDiff > 0 ? `+${data.storageDiff.toFixed(2)} m` : `${data.storageDiff.toFixed(2)} m`) : (data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : <span class="branch-1 cbranch-no" title="branch not covered" >'N/A')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{dict.volume}: {data.volume.toFixed(1)} mil. m³
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KpiCards;
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/components</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> src/components</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">53.84% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>7/13</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">65.21% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>15/23</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">33.33% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>2/6</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">58.33% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>7/12</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file medium" data-value="KpiCards.tsx"><a href="KpiCards.tsx.html">KpiCards.tsx</a></td>
|
||||
<td data-value="53.84" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 53%"></div><div class="cover-empty" style="width: 47%"></div></div>
|
||||
</td>
|
||||
<td data-value="53.84" class="pct medium">53.84%</td>
|
||||
<td data-value="13" class="abs medium">7/13</td>
|
||||
<td data-value="65.21" class="pct medium">65.21%</td>
|
||||
<td data-value="23" class="abs medium">15/23</td>
|
||||
<td data-value="33.33" class="pct low">33.33%</td>
|
||||
<td data-value="6" class="abs low">2/6</td>
|
||||
<td data-value="58.33" class="pct medium">58.33%</td>
|
||||
<td data-value="12" class="abs medium">7/12</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../prettify.css" />
|
||||
<link rel="stylesheet" href="../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../index.html">All files</a> src</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>1/1</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>1/1</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file high" data-value="translations.ts"><a href="translations.ts.html">translations.ts</a></td>
|
||||
<td data-value="100" class="pic high">
|
||||
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
|
||||
</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="1" class="abs high">1/1</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="0" class="abs high">0/0</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="0" class="abs high">0/0</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="1" class="abs high">1/1</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../sorter.js"></script>
|
||||
<script src="../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/translations.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../prettify.css" />
|
||||
<link rel="stylesheet" href="../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../index.html">All files</a> / <a href="index.html">src</a> translations.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>1/1</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>0/0</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>1/1</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">export type Language = 'en' | 'cs';
|
||||
|
||||
export const t = {
|
||||
en: {
|
||||
sidebar: {
|
||||
favorites: 'Favorites',
|
||||
lakes: 'Lakes',
|
||||
map: 'Map',
|
||||
settings: 'Settings'
|
||||
},
|
||||
topbar: {
|
||||
search: 'Search river or reservoir (e.g. Lipno)...',
|
||||
updated: 'Last updated:'
|
||||
},
|
||||
kpi: {
|
||||
level: 'WATER LEVEL',
|
||||
flow: 'FLOW RATE',
|
||||
inflow: 'Inflow',
|
||||
outflow: 'Outflow',
|
||||
fullness: 'STORAGE LEVEL',
|
||||
volume: 'Volume'
|
||||
},
|
||||
chart: {
|
||||
title: 'Long-term development',
|
||||
timeframe: 'Timeframe',
|
||||
timeframeMobile: 'Time',
|
||||
view: 'View',
|
||||
raw: 'Raw data',
|
||||
smoothed: 'Smoothed',
|
||||
calendar: 'Calendar',
|
||||
all: 'All',
|
||||
year: 'Year',
|
||||
level: 'Water level',
|
||||
inflow: 'Inflow',
|
||||
outflow: 'Outflow',
|
||||
maxLevel: 'Max retention level',
|
||||
storageLevel: 'Storage space level',
|
||||
dataSources: 'Data sources:',
|
||||
createdIn: 'Created with ♥ in the Czech Republic'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
theme: 'Theme',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
language: 'Language',
|
||||
english: 'English',
|
||||
czech: 'Čeština',
|
||||
buyCoffee: 'Buy Me a Coffee'
|
||||
}
|
||||
},
|
||||
cs: {
|
||||
sidebar: {
|
||||
favorites: 'Oblíbené',
|
||||
lakes: 'Jezera',
|
||||
map: 'Mapa',
|
||||
settings: 'Nastavení'
|
||||
},
|
||||
topbar: {
|
||||
search: 'Hledat tok nebo nádrž (např. Lipno)...',
|
||||
updated: 'Aktualizováno:'
|
||||
},
|
||||
kpi: {
|
||||
level: 'HLADINA',
|
||||
flow: 'PRŮTOK',
|
||||
inflow: 'Přítok',
|
||||
outflow: 'Odtok',
|
||||
fullness: 'ZÁSOBNÍ PROSTOR',
|
||||
volume: 'Objem'
|
||||
},
|
||||
chart: {
|
||||
title: 'Dlouhodobý vývoj',
|
||||
timeframe: 'Časové období',
|
||||
timeframeMobile: 'Časové',
|
||||
view: 'Zobrazení',
|
||||
raw: 'Syrová data',
|
||||
smoothed: 'Vyhlazená',
|
||||
calendar: 'Kalendář',
|
||||
all: 'Vše',
|
||||
year: 'Rok',
|
||||
level: 'Hladina',
|
||||
inflow: 'Přítok',
|
||||
outflow: 'Odtok',
|
||||
maxLevel: 'Max. retenční hladina',
|
||||
storageLevel: 'Hladina zásobního prostoru',
|
||||
dataSources: 'Zdroje dat:',
|
||||
createdIn: 'Vytvořeno s ♥ v České republice'
|
||||
},
|
||||
settings: {
|
||||
title: 'Nastavení',
|
||||
theme: 'Vzhled',
|
||||
dark: 'Tmavý',
|
||||
light: 'Světlý',
|
||||
language: 'Jazyk',
|
||||
english: 'English',
|
||||
czech: 'Čeština',
|
||||
buyCoffee: 'Kup mi kávu'
|
||||
}
|
||||
}
|
||||
};
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-06-05T21:03:50.324Z
|
||||
</div>
|
||||
<script src="../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../sorter.js"></script>
|
||||
<script src="../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Analýza dostupných dat z Povodí Vltavy (PVL.cz)
|
||||
|
||||
Tento dokument sumarizuje všechna data, která jsme schopni strojově získat z webových stránek Povodí Vltavy pro jednotlivé vodní nádrže (např. z adresy `Mereni.aspx?oid=1&id=VLL1`).
|
||||
|
||||
Data jsou na zdrojovém backendu rozdělena do několika logických celků (tabulek), které můžeme libovolně vytěžovat.
|
||||
|
||||
## 1. Technické parametry nádrže (Základní údaje)
|
||||
Tato data jsou statická a definují fyzické a inženýrské limity přehrady.
|
||||
|
||||
* **Tok (River):** Na jaké řece se nádrž nachází (např. Vltava).
|
||||
* **Koruna hráze:** Absolutní nadmořská výška nejvyššího bodu hráze [m n.m.].
|
||||
* **Kóta přelivu:** Výška přelivových hran [m n.m.].
|
||||
* **Maximální retenční hladina:** Krizová úroveň nadržení při povodních [m n.m.].
|
||||
* **Hladina zásobního prostoru:** Maximální běžná hladina, pro kterou je určen zásobní objem [m n.m.].
|
||||
* **Hladina stálého nadržení:** Minimální hladina nutná pro zachování ekologických a technických funkcí [m n.m.] (lze využít pro přesný výpočet procentuální naplněnosti).
|
||||
* **Výškový systém:** Zpravidla "Balt p.v." (Baltský po vyrovnání).
|
||||
|
||||
## 2. Aktuální hodnoty (Real-time data)
|
||||
Tato tabulka obsahuje nejčerstvější data z měřicích stanic s přesným časovým razítkem. Ne všechny hodnoty musí být vždy u všech přehrad dostupné.
|
||||
|
||||
* **Časové razítko:** Přesný čas posledního měření (např. *05.06.2026 22:10*).
|
||||
* **Hladina vody v nádrži:** Aktuální výška [m n.m.].
|
||||
* **Objem:** Skutečný aktuální zadržovaný objem vody [mil. m³].
|
||||
* **Přítok (Inflow):** Odhadovaný/měřený přítok do přehrady [m³/s].
|
||||
* **Odtok (Outflow):** Skutečný odtok z přehrady [m³/s].
|
||||
* **Srážky (24h):** Úhrn srážek za posledních 24 hodin [mm] *(k dispozici pouze u vybraných stanic)*.
|
||||
* **Teplota vzduchu:** Aktuální teplota vzduchu [°C] *(k dispozici pouze u vybraných stanic)*.
|
||||
|
||||
## 3. Historická časová řada (Tabulka měření)
|
||||
Tyto tabulky obsahují historický vývoj po jednotlivých hodinách za posledních několik dnů, což využíváme pro vykreslování grafů.
|
||||
|
||||
* **Datum a čas:** Hodinové intervaly (např. *05.06.2026 22:00*).
|
||||
* **Hladina:** Měřená výška hladiny [m n.m.].
|
||||
* **Odtok:** Odtok přes hráz [m³/s].
|
||||
* *(Poznámka: Přítok a Objem se do historické tabulky u většiny přehrad ze strany PVL neukládají, zveřejňují pouze hladinu a odtok).*
|
||||
* **QN:** Indikátor kvality dat (ověřená/neověřená).
|
||||
|
||||
---
|
||||
|
||||
### Možnosti budoucího rozšíření HLADINATORu
|
||||
Na základě výše zmíněných dostupných bodů můžeme do aplikace snadno přidat:
|
||||
1. **Srážky a teplotu** - Pokud je pro danou přehradu údaj dostupný, můžeme přidat widget pro zobrazení úhrnu srážek za 24h a aktuální teploty vzduchu.
|
||||
2. **Přesnější výpočet %** - Pomocí limitů "Maximální retenční hladina" a "Hladina stálého nadržení" můžeme přesně indikovat blížící se povodňový stav.
|
||||
3. **Výstražný systém (Alerts)** - Vizuální varování (např. změna barvy panelu na červenou), pokud se aktuální hladina nebezpečně blíží kótě přelivu.
|
||||
Generated
+2873
-21
File diff suppressed because it is too large
Load Diff
+25
-3
@@ -7,26 +7,48 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"mock": "tsx scripts/generateMockLakes.ts",
|
||||
"scrape": "tsx scripts/scrapeLakes.ts",
|
||||
"build-index": "tsx scripts/buildIndex.ts",
|
||||
"data:update": "npm run scrape && npm run build-index",
|
||||
"data:watch": "tsx scripts/watchData.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.17.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,350 @@
|
||||
[
|
||||
{
|
||||
"id": "VLL1|1",
|
||||
"name": "Lipno 1",
|
||||
"river": "Vltava",
|
||||
"priority": true,
|
||||
"level": "723.08",
|
||||
"capacity": 76.2,
|
||||
"storageDiff": -1.82,
|
||||
"inflow": "2.5",
|
||||
"outflow": "1.5",
|
||||
"volume": 199.67,
|
||||
"maxVolume": 306,
|
||||
"lat": 48.6322,
|
||||
"lng": 14.2215,
|
||||
"sparkline": [
|
||||
1.49,
|
||||
13.76,
|
||||
34.78,
|
||||
37.78,
|
||||
33.61,
|
||||
14.02,
|
||||
1.51,
|
||||
1.51,
|
||||
1.51,
|
||||
1.51,
|
||||
1.51,
|
||||
1.51
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "VLL2|1",
|
||||
"name": "Lipno II",
|
||||
"river": "Vltava",
|
||||
"priority": false,
|
||||
"level": "559.89",
|
||||
"capacity": 100,
|
||||
"storageDiff": 48.39,
|
||||
"inflow": "3.7",
|
||||
"outflow": "0.0",
|
||||
"volume": 0.68,
|
||||
"maxVolume": 1.5,
|
||||
"lat": 48.625,
|
||||
"lng": 14.318,
|
||||
"sparkline": [
|
||||
7.31,
|
||||
7.34,
|
||||
7.48,
|
||||
7.29,
|
||||
7.27,
|
||||
7.24,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "VLHN|1",
|
||||
"name": "Hněvkovice",
|
||||
"river": "Vltava",
|
||||
"priority": true,
|
||||
"level": "369.79",
|
||||
"capacity": 87.1,
|
||||
"storageDiff": -0.31,
|
||||
"inflow": "10.8",
|
||||
"outflow": "1.3",
|
||||
"volume": 20.24,
|
||||
"maxVolume": 21.1,
|
||||
"lat": 49.183,
|
||||
"lng": 14.444,
|
||||
"sparkline": [
|
||||
14.18,
|
||||
14.18,
|
||||
18.46,
|
||||
14.28,
|
||||
5,
|
||||
1.25,
|
||||
1.25,
|
||||
1.25,
|
||||
1.25,
|
||||
1.25,
|
||||
1.25,
|
||||
1.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "VLKO|1",
|
||||
"name": "Kořensko",
|
||||
"river": "Vltava",
|
||||
"priority": false,
|
||||
"level": "352.43",
|
||||
"capacity": 28.7,
|
||||
"storageDiff": -0.17,
|
||||
"inflow": "14.1",
|
||||
"outflow": "19.0",
|
||||
"volume": 2.74,
|
||||
"maxVolume": 2.8,
|
||||
"lat": 49.255,
|
||||
"lng": 14.398,
|
||||
"sparkline": [
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01,
|
||||
19.01
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "VLOR|2",
|
||||
"name": "Orlík",
|
||||
"river": "Vltava",
|
||||
"priority": true,
|
||||
"level": "345.27",
|
||||
"capacity": 63.6,
|
||||
"storageDiff": -4.63,
|
||||
"inflow": "23.8",
|
||||
"outflow": "0.0",
|
||||
"volume": 522.12,
|
||||
"maxVolume": 716.5,
|
||||
"lat": 49.606,
|
||||
"lng": 14.17,
|
||||
"sparkline": [
|
||||
186.83,
|
||||
454.38,
|
||||
444.3,
|
||||
370.39,
|
||||
381.47,
|
||||
431.93,
|
||||
432.4,
|
||||
432.9,
|
||||
432.41,
|
||||
377.67,
|
||||
137.48,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "UHKA|2",
|
||||
"name": "Kamýk",
|
||||
"river": "Vltava",
|
||||
"priority": false,
|
||||
"level": "0.00",
|
||||
"capacity": 0,
|
||||
"storageDiff": 0,
|
||||
"inflow": "0.0",
|
||||
"outflow": "0.0",
|
||||
"volume": 12.8,
|
||||
"maxVolume": 12.8,
|
||||
"lat": 49.636,
|
||||
"lng": 14.254,
|
||||
"sparkline": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "VLSL|2",
|
||||
"name": "Slapy",
|
||||
"river": "Vltava",
|
||||
"priority": true,
|
||||
"level": "269.83",
|
||||
"capacity": 77.3,
|
||||
"storageDiff": -0.77,
|
||||
"inflow": "46.5",
|
||||
"outflow": "0.0",
|
||||
"volume": 260.21,
|
||||
"maxVolume": 269.3,
|
||||
"lat": 49.822,
|
||||
"lng": 14.436,
|
||||
"sparkline": [
|
||||
119.44,
|
||||
137.14,
|
||||
310.27,
|
||||
308.35,
|
||||
304.36,
|
||||
284.81,
|
||||
285.23,
|
||||
287.34,
|
||||
287.91,
|
||||
217.32,
|
||||
79.38,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "VLST|2",
|
||||
"name": "Štěchovice",
|
||||
"river": "Vltava",
|
||||
"priority": false,
|
||||
"level": "218.64",
|
||||
"capacity": 65.6,
|
||||
"storageDiff": -0.76,
|
||||
"inflow": "19.9",
|
||||
"outflow": "25.3",
|
||||
"volume": 9.68,
|
||||
"maxVolume": 11.2,
|
||||
"lat": 49.845,
|
||||
"lng": 14.412,
|
||||
"sparkline": [
|
||||
25.32,
|
||||
70.8,
|
||||
150.41,
|
||||
150.43,
|
||||
120.77,
|
||||
99.8,
|
||||
99.83,
|
||||
94.85,
|
||||
85.34,
|
||||
85.17,
|
||||
52.56,
|
||||
25.32
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "VRSN|2",
|
||||
"name": "Vrané",
|
||||
"river": "Vltava",
|
||||
"priority": false,
|
||||
"level": "0.00",
|
||||
"capacity": 0,
|
||||
"storageDiff": 0,
|
||||
"inflow": "0.0",
|
||||
"outflow": "0.0",
|
||||
"volume": 11.1,
|
||||
"maxVolume": 11.1,
|
||||
"lat": 49.934,
|
||||
"lng": 14.385,
|
||||
"sparkline": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "SVKR|2",
|
||||
"name": "Švihov",
|
||||
"river": "Želivka",
|
||||
"priority": true,
|
||||
"level": "0.00",
|
||||
"capacity": 0,
|
||||
"storageDiff": 0,
|
||||
"inflow": "0.0",
|
||||
"outflow": "0.0",
|
||||
"volume": 266.6,
|
||||
"maxVolume": 266.6,
|
||||
"lat": 49.718,
|
||||
"lng": 15.106,
|
||||
"sparkline": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MARI|1",
|
||||
"name": "Římov",
|
||||
"river": "Malše",
|
||||
"priority": true,
|
||||
"level": "467.72",
|
||||
"capacity": 74.8,
|
||||
"storageDiff": -2.93,
|
||||
"inflow": "2.9",
|
||||
"outflow": "0.0",
|
||||
"volume": 26.49,
|
||||
"maxVolume": 33.8,
|
||||
"lat": 48.847,
|
||||
"lng": 14.487,
|
||||
"sparkline": [
|
||||
0.7,
|
||||
0.7,
|
||||
0.7,
|
||||
0.7,
|
||||
0.7,
|
||||
0.7,
|
||||
0.7,
|
||||
0.7,
|
||||
0,
|
||||
0.7,
|
||||
0.7,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MZHR|3",
|
||||
"name": "Hracholusky",
|
||||
"river": "Mže",
|
||||
"priority": true,
|
||||
"level": "352.85",
|
||||
"capacity": 0,
|
||||
"storageDiff": -1.25,
|
||||
"inflow": "1.5",
|
||||
"outflow": "0.0",
|
||||
"volume": 32.37,
|
||||
"maxVolume": 56.7,
|
||||
"lat": 49.789,
|
||||
"lng": 13.155,
|
||||
"sparkline": [
|
||||
2.52,
|
||||
2.52,
|
||||
2.53,
|
||||
2.53,
|
||||
2.53,
|
||||
2.53,
|
||||
2.53,
|
||||
2.53,
|
||||
2.53,
|
||||
2.53,
|
||||
2.53,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
+637
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import https from 'https';
|
||||
import { lakesConfig } from './scripts/lakesConfig';
|
||||
|
||||
async function run() {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
for (const lake of lakesConfig) {
|
||||
const [internalId, oid] = lake.id.split('|');
|
||||
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
|
||||
try {
|
||||
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
const $ = cheerio.load(res.data);
|
||||
let temp = null;
|
||||
let precip = null;
|
||||
$('table').each((i, tbl) => {
|
||||
const text = $(tbl).text();
|
||||
if (text.includes('Aktuální hodnoty')) {
|
||||
const tempMatch = text.match(/Teplota vzduchu \[°C\]\s*([\d,]+)/);
|
||||
if (tempMatch) temp = tempMatch[1];
|
||||
const precipMatch = text.match(/Srážky \(24h\) \[mm\]\s*([\d,]+)/);
|
||||
if (precipMatch) precip = precipMatch[1];
|
||||
}
|
||||
});
|
||||
console.log(`[${internalId}] Temp: ${temp}, Precip: ${precip}`);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
run();
|
||||
@@ -0,0 +1,28 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import https from 'https';
|
||||
import { lakesConfig } from './scripts/lakesConfig';
|
||||
|
||||
async function run() {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
for (const lake of lakesConfig) {
|
||||
const [internalId, oid] = lake.id.split('|');
|
||||
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
|
||||
try {
|
||||
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
const $ = cheerio.load(res.data);
|
||||
let storage = 0;
|
||||
$('table').each((i, tbl) => {
|
||||
const text = $(tbl).text();
|
||||
const match = text.match(/Hladina z[aá]sobn[ií]ho prostoru:\s*([\d,]+)/i);
|
||||
if (match) {
|
||||
storage = parseFloat(match[1].replace(',', '.'));
|
||||
}
|
||||
});
|
||||
console.log(`{ id: "${lake.id}", storageLevel: ${storage} },`);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
run();
|
||||
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios';
|
||||
import { lakesConfig } from './scripts/lakesConfig';
|
||||
|
||||
async function testOpenMeteo() {
|
||||
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
|
||||
if (!lipno) return;
|
||||
const lat = lipno.coords[0];
|
||||
const lon = lipno.coords[1];
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,precipitation`;
|
||||
console.log('Fetching from:', url);
|
||||
try {
|
||||
const response = await axios.get(url);
|
||||
console.log(response.data.current);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
testOpenMeteo();
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import { lakesConfig } from './scripts/lakesConfig';
|
||||
|
||||
async function testHistory() {
|
||||
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
|
||||
if (!lipno) return;
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lipno.coords[0]}&longitude=${lipno.coords[1]}&past_days=7&hourly=temperature_2m,precipitation&timezone=GMT`;
|
||||
console.log('Fetching from:', url);
|
||||
try {
|
||||
const res = await axios.get(url);
|
||||
const hourly = res.data.hourly;
|
||||
console.log(`Received ${hourly.time.length} hourly records.`);
|
||||
console.log('Sample record at index 100:');
|
||||
console.log('Time:', hourly.time[100]);
|
||||
console.log('Temp:', hourly.temperature_2m[100]);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
testHistory();
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDateString } from '../scrapeLakes';
|
||||
|
||||
describe('scrapeLakes - parseDateString', () => {
|
||||
it('should parse valid date strings correctly', () => {
|
||||
// Note: JS Date parsing uses local timezone, so the output ISO string depends on where the test runs.
|
||||
// To make it deterministic, we just check if it returns a string and is not null.
|
||||
const result = parseDateString('05.06.2026 22:30');
|
||||
expect(result).not.toBeNull();
|
||||
// Assuming standard parsing, it should contain 2026
|
||||
expect(result).toContain('2026-06-05');
|
||||
});
|
||||
|
||||
it('should return null for invalid formats', () => {
|
||||
expect(parseDateString('')).toBeNull();
|
||||
expect(parseDateString('invalid date string')).toBeNull();
|
||||
expect(parseDateString('05.06.2026')).toBeNull(); // Missing time
|
||||
expect(parseDateString('22:30')).toBeNull(); // Missing date
|
||||
});
|
||||
|
||||
it('should return null for malformed parts', () => {
|
||||
expect(parseDateString('99.99.9999 99:99')).toBeNull(); // JS Date might parse this as valid overflow, but let's check
|
||||
expect(parseDateString('abc def ghi')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { lakesConfig } from './lakesConfig';
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), 'public/data');
|
||||
|
||||
async function backfill() {
|
||||
console.log('Starting weather backfill for past 7 days...');
|
||||
|
||||
for (const lake of lakesConfig) {
|
||||
const internalId = lake.id.split('|')[0];
|
||||
const filePath = path.join(DATA_DIR, `${internalId}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`Skipping ${internalId}, no data file.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lake.coords) {
|
||||
console.log(`Skipping ${internalId}, no coordinates.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const lat = lake.coords[0];
|
||||
const lon = lake.coords[1];
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&past_days=7&hourly=temperature_2m,precipitation&timezone=GMT`;
|
||||
|
||||
const res = await axios.get(url, { timeout: 10000 });
|
||||
const hourly = res.data.hourly;
|
||||
|
||||
// Build lookup map for O(1) matching: '2026-06-02T04:00' -> { temp, precip }
|
||||
const weatherMap = new Map();
|
||||
for (let i = 0; i < hourly.time.length; i++) {
|
||||
weatherMap.set(hourly.time[i], {
|
||||
temperature: hourly.temperature_2m[i],
|
||||
precipitation: hourly.precipitation[i]
|
||||
});
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const record of data) {
|
||||
// record.timestamp is like "2026-06-02T04:00:00.000Z"
|
||||
// Open-Meteo time is like "2026-06-02T04:00"
|
||||
const hourKey = record.timestamp.substring(0, 16); // Extract up to minutes
|
||||
|
||||
if (weatherMap.has(hourKey)) {
|
||||
const w = weatherMap.get(hourKey);
|
||||
if (w.temperature !== null && w.temperature !== undefined) {
|
||||
record.temperature = w.temperature;
|
||||
updatedCount++;
|
||||
}
|
||||
if (w.precipitation !== null && w.precipitation !== undefined) {
|
||||
record.precipitation = w.precipitation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
console.log(`[${internalId}] Backfilled ${updatedCount} records with historical Open-Meteo data.`);
|
||||
|
||||
// small delay to prevent rate limit
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(`Error processing ${internalId}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Backfill complete!');
|
||||
}
|
||||
|
||||
backfill();
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { lakesConfig } from './lakesConfig';
|
||||
|
||||
interface DataRecord {
|
||||
timestamp: string;
|
||||
level: number;
|
||||
flow: number;
|
||||
}
|
||||
|
||||
const lakes = lakesConfig.map(lake => {
|
||||
const [internalId, oid] = lake.id.split('|');
|
||||
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
|
||||
|
||||
let currentLevel = 0;
|
||||
let currentFlow = 0;
|
||||
let sparkline: number[] = Array(12).fill(0);
|
||||
|
||||
let capacity = 0;
|
||||
let volume = 0;
|
||||
let inflow = 0;
|
||||
|
||||
if (fs.existsSync(DATA_FILE)) {
|
||||
try {
|
||||
const data: any[] = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
|
||||
if (data.length > 0) {
|
||||
// Find latest valid record or just the last record
|
||||
const lastValidLevelData = [...data].reverse().find(d => d.level !== null && !isNaN(d.level));
|
||||
const lastValidFlowData = [...data].reverse().find(d => d.flow !== null && !isNaN(d.flow) && d.flow >= 0);
|
||||
|
||||
currentLevel = lastValidLevelData ? lastValidLevelData.level : 0;
|
||||
currentFlow = lastValidFlowData ? lastValidFlowData.flow : 0;
|
||||
|
||||
// Take up to 12 last records for sparkline
|
||||
const recentData = data.slice(-12);
|
||||
sparkline = recentData.map(d => (d.flow === null || isNaN(d.flow) ? 0 : d.flow));
|
||||
|
||||
// Pad with zeros if less than 12
|
||||
while (sparkline.length < 12) {
|
||||
sparkline.unshift(0);
|
||||
}
|
||||
|
||||
const latest = data[data.length - 1];
|
||||
if (latest.volume && latest.volume > 0) {
|
||||
volume = latest.volume;
|
||||
}
|
||||
if (latest.inflow !== undefined) {
|
||||
inflow = latest.inflow;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error reading data for ${internalId}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (lake.minLevel && lake.maxLevel && currentLevel > 0) {
|
||||
const percentage = ((currentLevel - lake.minLevel) / (lake.maxLevel - lake.minLevel)) * 100;
|
||||
capacity = Math.max(0, Math.min(100, Math.round(percentage * 10) / 10)); // Round to 1 decimal place
|
||||
if (volume === 0) {
|
||||
volume = Number(((capacity / 100) * (lake.maxVolume || 0)).toFixed(1));
|
||||
}
|
||||
} else {
|
||||
if (volume === 0) volume = lake.maxVolume || 0;
|
||||
}
|
||||
|
||||
let storageDiff = 0;
|
||||
if (lake.storageLevel && currentLevel > 0) {
|
||||
storageDiff = Number((currentLevel - lake.storageLevel).toFixed(2));
|
||||
}
|
||||
|
||||
return {
|
||||
id: lake.id,
|
||||
name: lake.text.replace('VD ', '').split('-')[0].trim(),
|
||||
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
|
||||
priority: lake.priority || false,
|
||||
level: currentLevel.toFixed(2),
|
||||
capacity: capacity,
|
||||
storageDiff: storageDiff,
|
||||
inflow: inflow.toFixed(1),
|
||||
outflow: currentFlow.toFixed(1),
|
||||
volume: volume,
|
||||
maxVolume: lake.maxVolume || 0,
|
||||
lat: lake.coords[0],
|
||||
lng: lake.coords[1],
|
||||
sparkline
|
||||
};
|
||||
});
|
||||
|
||||
const outputPath = path.resolve(process.cwd(), 'public/data/lakes_index.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(lakes, null, 2));
|
||||
console.log('Real lakes index generated:', lakes.length);
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), 'public/data');
|
||||
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
console.error("Data directory not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json') && f !== 'lakes_index.json');
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(DATA_DIR, file);
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (data.length === 0) return;
|
||||
|
||||
// The oldest record currently in DB
|
||||
const oldest = data[0];
|
||||
const oldestTime = new Date(oldest.timestamp).getTime();
|
||||
|
||||
const newRecords: any[] = [];
|
||||
|
||||
let currentLevel = oldest.level;
|
||||
let currentFlow = oldest.flow;
|
||||
let currentInflow = oldest.inflow || (Math.random() * 10 + 5);
|
||||
|
||||
// Generate 720 records (30 days * 24 hours) going BACKWARDS
|
||||
for (let i = 1; i <= 720; i++) {
|
||||
const d = new Date(oldestTime - i * 60 * 60 * 1000);
|
||||
|
||||
// random walk for level
|
||||
currentLevel = currentLevel + (Math.random() - 0.5) * 0.05;
|
||||
|
||||
// random walk for outflow and inflow
|
||||
currentFlow = Math.max(0, currentFlow + (Math.random() - 0.5) * 2);
|
||||
currentInflow = Math.max(0, currentInflow + (Math.random() - 0.5) * 2);
|
||||
|
||||
// Temperature: daily sine wave (colder at night, warmer in day) + noise
|
||||
const hour = d.getHours();
|
||||
const tempBase = 18; // base 18C
|
||||
const tempDay = Math.sin(((hour - 6) / 24) * Math.PI * 2) * 8; // cold morning, warm afternoon
|
||||
const randomTemp = tempBase + tempDay + (Math.random() - 0.5) * 2;
|
||||
|
||||
// Precipitation: rare spikes
|
||||
const randomPrecip = Math.random() > 0.95 ? Math.random() * 15 : 0;
|
||||
|
||||
newRecords.push({
|
||||
timestamp: d.toISOString(),
|
||||
level: currentLevel,
|
||||
flow: currentFlow,
|
||||
inflow: currentInflow,
|
||||
volume: oldest.volume, // volume changes too slow, keep constant for mock
|
||||
temperature: randomTemp,
|
||||
precipitation: randomPrecip
|
||||
});
|
||||
}
|
||||
|
||||
// Combine: newRecords are older, so reverse them to make chronological (oldest first), then add real data
|
||||
const allRecords = [...newRecords.reverse(), ...data];
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(allRecords, null, 2));
|
||||
console.log(`Generated 30 days of realistic mock history for ${file}`);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface LakeConfig {
|
||||
id: string;
|
||||
text: string;
|
||||
priority?: boolean;
|
||||
coords: [number, number];
|
||||
maxVolume?: number;
|
||||
minLevel?: number;
|
||||
maxLevel?: number;
|
||||
storageLevel?: number;
|
||||
}
|
||||
|
||||
export const lakesConfig: LakeConfig[] = [
|
||||
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306.0, minLevel: 715.00, maxLevel: 725.60, storageLevel: 724.9 },
|
||||
{ id: "VLL2|1", text: "VD Lipno II - Vltava", coords: [48.6250, 14.3180], maxVolume: 1.5, minLevel: 510.0, maxLevel: 511.5, storageLevel: 511.5 },
|
||||
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 365.0, maxLevel: 370.5, storageLevel: 370.1 },
|
||||
{ id: "VLKO|1", text: "VD Kořensko - Vltava", coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 352.0, maxLevel: 353.5, storageLevel: 352.6 },
|
||||
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 330.00, maxLevel: 354.00, storageLevel: 349.9 },
|
||||
{ id: "UHKA|2", text: "VD Kamýk - Vltava", coords: [49.6360, 14.2540], maxVolume: 12.8, minLevel: 283.0, maxLevel: 285.6, storageLevel: 285.6 },
|
||||
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 265.50, maxLevel: 271.10, storageLevel: 270.6 },
|
||||
{ id: "VLST|2", text: "VD Štěchovice - Vltava", coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 217.0, maxLevel: 219.5, storageLevel: 219.4 },
|
||||
{ id: "VRSN|2", text: "VD Vrané - Vltava", coords: [49.9340, 14.3850], maxVolume: 11.1, minLevel: 198.5, maxLevel: 200.5, storageLevel: 200.5 },
|
||||
{ id: "SVKR|2", text: "VD Švihov - Želivka", priority: true, coords: [49.7180, 15.1060], maxVolume: 266.6, minLevel: 343.0, maxLevel: 377.0, storageLevel: 377.0 },
|
||||
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 458.0, maxLevel: 471.0, storageLevel: 470.65 },
|
||||
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 360.0, maxLevel: 373.0, storageLevel: 354.1 }
|
||||
];
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cheerio from 'cheerio';
|
||||
import axios from 'axios';
|
||||
import https from 'https';
|
||||
import { lakesConfig } from './lakesConfig';
|
||||
|
||||
interface DataRecord {
|
||||
timestamp: string;
|
||||
level: number;
|
||||
flow: number;
|
||||
inflow?: number;
|
||||
volume?: number;
|
||||
temperature?: number | null;
|
||||
precipitation?: number | null;
|
||||
}
|
||||
|
||||
// Parse date from DD.MM.YYYY HH:MM to ISO
|
||||
export function parseDateString(dateStr: string): string | null {
|
||||
try {
|
||||
if (!dateStr || !dateStr.includes(' ')) return null;
|
||||
const [datePart, timePart] = dateStr.trim().split(' ');
|
||||
const [day, month, year] = datePart.split('.');
|
||||
const [hours, minutes] = timePart.split(':');
|
||||
|
||||
if (!year || !hours) return null;
|
||||
|
||||
const y = parseInt(year);
|
||||
const m = parseInt(month) - 1;
|
||||
const dDay = parseInt(day);
|
||||
const d = new Date(y, m, dDay, parseInt(hours), parseInt(minutes));
|
||||
if (isNaN(d.getTime())) return null;
|
||||
if (d.getFullYear() !== y || d.getMonth() !== m || d.getDate() !== dDay) return null;
|
||||
return d.toISOString();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
||||
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
|
||||
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
|
||||
|
||||
try {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
const response = await axios.get(URL, {
|
||||
httpsAgent: agent,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
let currentInflow = 0;
|
||||
let currentVolume = 0;
|
||||
let currentTemp: number | null = null;
|
||||
let currentPrecip: number | null = null;
|
||||
|
||||
$('table').each((i, tbl) => {
|
||||
const text = $(tbl).text();
|
||||
if (text.includes('Aktuální hodnoty') && text.includes('Přítok')) {
|
||||
$(tbl).find('tr').each((j, r) => {
|
||||
const label = $(r).find('td').eq(0).text().trim();
|
||||
const valStr = $(r).find('td').eq(1).text().trim().replace(/\s/g, '').replace(',', '.');
|
||||
if (label.includes('Přítok')) currentInflow = parseFloat(valStr) || 0;
|
||||
if (label.includes('Objem')) currentVolume = parseFloat(valStr) || 0;
|
||||
if (label.includes('Teplota')) {
|
||||
const v = parseFloat(valStr);
|
||||
if (!isNaN(v)) currentTemp = v;
|
||||
}
|
||||
if (label.includes('Srážky')) {
|
||||
const v = parseFloat(valStr);
|
||||
if (!isNaN(v)) currentPrecip = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const records: DataRecord[] = [];
|
||||
let dataTable = null;
|
||||
$('table').each((i, tbl) => {
|
||||
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {
|
||||
dataTable = $(tbl);
|
||||
}
|
||||
});
|
||||
|
||||
if (dataTable) {
|
||||
dataTable.find('tr').each((i, row) => {
|
||||
if (i === 0) return; // skip header
|
||||
const cols = $(row).find('td');
|
||||
if (cols.length >= 3) {
|
||||
const rawDate = $(cols[0]).text().trim();
|
||||
const levelStr = $(cols[1]).text().trim().replace(',', '.');
|
||||
let flowStr = $(cols[2]).text().trim().replace(',', '.');
|
||||
if (flowStr === '' && cols.length >= 4) {
|
||||
flowStr = $(cols[3]).text().trim().replace(',', '.');
|
||||
}
|
||||
|
||||
const parsedDateStr = parseDateString(rawDate);
|
||||
if (parsedDateStr) {
|
||||
records.push({
|
||||
timestamp: parsedDateStr,
|
||||
level: parseFloat(levelStr) || 0,
|
||||
flow: parseFloat(flowStr) || 0,
|
||||
inflow: 0,
|
||||
volume: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (records.length > 0) {
|
||||
records[0].inflow = currentInflow;
|
||||
records[0].volume = currentVolume;
|
||||
|
||||
// Override weather from PVL completely using Open-Meteo
|
||||
const config = lakesConfig.find(l => l.id.split('|')[0] === internalId);
|
||||
if (config && config.coords) {
|
||||
try {
|
||||
const lat = config.coords[0];
|
||||
const lon = config.coords[1];
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,precipitation`;
|
||||
const weatherRes = await axios.get(url, { timeout: 5000 });
|
||||
if (weatherRes.data && weatherRes.data.current) {
|
||||
records[0].temperature = weatherRes.data.current.temperature_2m;
|
||||
records[0].precipitation = weatherRes.data.current.precipitation;
|
||||
}
|
||||
// Small delay to prevent API rate limits
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to fetch weather for ${internalId}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let existingData: DataRecord[] = [];
|
||||
if (fs.existsSync(DATA_FILE)) {
|
||||
const fileContent = fs.readFileSync(DATA_FILE, 'utf-8');
|
||||
existingData = JSON.parse(fileContent);
|
||||
}
|
||||
|
||||
const dataMap = new Map<string, DataRecord>();
|
||||
existingData.forEach(item => dataMap.set(item.timestamp, item));
|
||||
records.forEach(item => dataMap.set(item.timestamp, item));
|
||||
|
||||
const mergedData = Array.from(dataMap.values()).sort((a, b) => {
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
});
|
||||
|
||||
// Propagate previous values if missing (user requested)
|
||||
let lastKnownTemp: number | null = null;
|
||||
let lastKnownPrecip: number | null = null;
|
||||
mergedData.forEach(item => {
|
||||
if (item.temperature !== undefined && item.temperature !== null) {
|
||||
lastKnownTemp = item.temperature;
|
||||
} else if (lastKnownTemp !== null) {
|
||||
item.temperature = lastKnownTemp;
|
||||
}
|
||||
|
||||
if (item.precipitation !== undefined && item.precipitation !== null) {
|
||||
lastKnownPrecip = item.precipitation;
|
||||
} else if (lastKnownPrecip !== null) {
|
||||
item.precipitation = lastKnownPrecip;
|
||||
}
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');
|
||||
|
||||
console.log(`[${internalId}] Scraped ${records.length} records. DB total: ${mergedData.length}`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[${internalId}] Error scraping data:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runScraper() {
|
||||
console.log(`Starting bulk scraper for ${lakesConfig.length} lakes...`);
|
||||
|
||||
for (const lake of lakesConfig) {
|
||||
// ID format: VLL1|1 -> internalId=VLL1, oid=1
|
||||
const [internalId, oid] = lake.id.split('|');
|
||||
await scrapeLake(lake.id, oid, internalId);
|
||||
// Add small delay to not hammer the server
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
console.log('Bulk scraping finished.');
|
||||
}
|
||||
|
||||
runScraper();
|
||||
@@ -0,0 +1,25 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const minutes = parseInt(args[0], 10) || 10;
|
||||
const intervalMs = minutes * 60 * 1000;
|
||||
|
||||
console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`);
|
||||
console.log(`Budu automaticky stahovat nová data každých ${minutes} minut.\n`);
|
||||
|
||||
function runUpdate() {
|
||||
const now = new Date().toLocaleTimeString('cs-CZ');
|
||||
console.log(`[${now}] 🔄 Spouštím npm run data:update...`);
|
||||
try {
|
||||
execSync('npm run data:update', { stdio: 'inherit' });
|
||||
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ✅ Úspěšně hotovo. Další kontrola za ${minutes} minut...\n`);
|
||||
} catch (error: any) {
|
||||
console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Spustit ihned po zapnutí
|
||||
runUpdate();
|
||||
|
||||
// A pak periodicky v zadaném intervalu
|
||||
setInterval(runUpdate, intervalMs);
|
||||
+444
-33
@@ -1,42 +1,453 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background-color: var(--bg-card);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: width 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 72px;
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-logo {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-logo svg {
|
||||
color: var(--color-cyan);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.sidebar-logo div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-logo span {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar-logo small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, var(--color-cyan) 0%, #0284c7 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.nav-item.active svg {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-card);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 400px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.search-bar svg {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-main);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--color-green);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--color-green);
|
||||
}
|
||||
|
||||
.kpi-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.kpi-title {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-subtitle {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kpi-trend.positive { color: var(--color-green); }
|
||||
.kpi-trend.negative { color: var(--color-red); }
|
||||
|
||||
.chart-card {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
.button-group {
|
||||
display: flex;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
.control-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.on {
|
||||
background-color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.toggle-switch.on::after {
|
||||
left: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.dashboard-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-footer a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
.mobile-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-only {
|
||||
display: flex !important;
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar-mobile-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar svg {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.kpi-container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.kpi-subtitle {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 0.5rem;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chart-legend-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.chart-legend-container > span {
|
||||
flex: 0 0 calc(50% - 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
+74
-24
@@ -1,31 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import LoadingScreen from './components/LoadingScreen'
|
||||
import Home from './components/Home'
|
||||
import Navbar from './components/Navbar'
|
||||
import TimeBreaker from './components/TimeBreaker'
|
||||
import { type Language } from './translations'
|
||||
import './App.css'
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom';
|
||||
import LakeDetail from './components/LakeDetail';
|
||||
import LakesOverview from './components/LakesOverview';
|
||||
import LakeMap from './components/LakeMap';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Topbar from './components/Topbar';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import { type Language } from './translations';
|
||||
import { lakesConfig } from '../scripts/lakesConfig';
|
||||
import { slugify } from './utils/slugify';
|
||||
import './App.css';
|
||||
|
||||
const LakeDetailWrapper = ({ language }: { language: Language }) => {
|
||||
const { slug } = useParams();
|
||||
const lake = lakesConfig.find(l => slugify(l.text) === slug);
|
||||
|
||||
if (!lake) return <Navigate to="/" replace />;
|
||||
|
||||
return <LakeDetail language={language} lakeId={lake.id} />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [language, setLanguage] = useState<Language>('en')
|
||||
const [language, setLanguage] = useState<Language>('en');
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'light') {
|
||||
document.body.classList.add('light-mode');
|
||||
} else {
|
||||
document.body.classList.remove('light-mode');
|
||||
}
|
||||
|
||||
// Clean up empty hash from URL (e.g. if the user clicked an empty anchor)
|
||||
if (window.location.href.endsWith('#')) {
|
||||
window.history.replaceState(null, '', window.location.href.slice(0, -1));
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<LoadingScreen onLoaded={() => setIsLoading(false)} />
|
||||
) : (
|
||||
<Router>
|
||||
<Navbar language={language} setLanguage={setLanguage} />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home language={language} />} />
|
||||
<Route path="/time-breaker" element={<TimeBreaker language={language} />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<div className="dashboard-container">
|
||||
{/* Mobile overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
<Sidebar
|
||||
language={language}
|
||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
onCloseMobileMenu={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="main-content">
|
||||
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
||||
<Routes>
|
||||
<Route path="/" element={<LakesOverview language={language} />} />
|
||||
<Route path="/map" element={<LakeMap language={language} />} />
|
||||
<Route path="/:slug" element={<LakeDetailWrapper language={language} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
{isSettingsOpen && (
|
||||
<SettingsModal
|
||||
language={language}
|
||||
setLanguage={setLanguage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { t } from '../translations';
|
||||
|
||||
describe('Translations', () => {
|
||||
it('should have exactly the same keys in English and Czech', () => {
|
||||
const enKeys = Object.keys(t.en).sort();
|
||||
const csKeys = Object.keys(t.cs).sort();
|
||||
|
||||
expect(enKeys).toEqual(csKeys);
|
||||
|
||||
// Deep check for nested keys
|
||||
for (const key of enKeys) {
|
||||
const enSubKeys = Object.keys((t.en as any)[key]).sort();
|
||||
const csSubKeys = Object.keys((t.cs as any)[key]).sort();
|
||||
|
||||
expect(enSubKeys).toEqual(csSubKeys);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not have empty translation strings', () => {
|
||||
const checkEmpty = (obj: any) => {
|
||||
for (const val of Object.values(obj)) {
|
||||
if (typeof val === 'string') {
|
||||
expect(val.length).toBeGreaterThan(0);
|
||||
} else if (typeof val === 'object') {
|
||||
checkEmpty(val);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkEmpty(t.en);
|
||||
checkEmpty(t.cs);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FaFacebookF, FaInstagram, FaLinkedinIn } from 'react-icons/fa';
|
||||
import { SiTypescript, SiReact, SiJavascript } from 'react-icons/si';
|
||||
import { translations, type Language } from '../translations';
|
||||
|
||||
interface HomeProps {
|
||||
language: Language;
|
||||
}
|
||||
|
||||
const Home: React.FC<HomeProps> = ({ language }) => {
|
||||
const t = translations[language];
|
||||
|
||||
return (
|
||||
<div className="home-container fade-in">
|
||||
<section id="home" className="hero fade-in">
|
||||
<div className="hero-content">
|
||||
<div className="hero-text">
|
||||
<span className="welcome-text">{t.welcome}</span>
|
||||
<h1>{t.hello} <span className="highlight">Davis</span></h1>
|
||||
<h2 className="job-title">{t.job}</h2>
|
||||
<p className="description">
|
||||
{t.desc}
|
||||
</p>
|
||||
|
||||
<div className="hero-footer">
|
||||
<div className="socials">
|
||||
<span className="footer-label">{t.findMe}</span>
|
||||
<div className="icon-group">
|
||||
<button className="icon-btn"><FaFacebookF /></button>
|
||||
<button className="icon-btn"><FaInstagram /></button>
|
||||
<button className="icon-btn"><FaLinkedinIn /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skills">
|
||||
<span className="footer-label">{t.bestSkill}</span>
|
||||
<div className="icon-group">
|
||||
<button className="icon-btn"><SiTypescript /></button>
|
||||
<button className="icon-btn"><SiReact /></button>
|
||||
<button className="icon-btn"><SiJavascript /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-image-container">
|
||||
<div className="hero-image-placeholder">
|
||||
<span>Photo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" className="section about fade-in">
|
||||
<h2>{t.aboutMe}</h2>
|
||||
<p>
|
||||
{t.aboutDesc}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="section contact fade-in">
|
||||
<h2>{t.getInTouch}</h2>
|
||||
<p>
|
||||
{t.contactDesc} <a href="mailto:hello@example.com">{t.emailMe}</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface KpiData {
|
||||
level: number;
|
||||
levelDiff24h?: number;
|
||||
levelDiff7d?: number;
|
||||
levelDiff30d?: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
storageDiff?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KpiData;
|
||||
language: Language;
|
||||
lakeName?: string;
|
||||
}
|
||||
|
||||
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const dict = t[language].kpi;
|
||||
const flowDiff = data.inflow - data.outflow;
|
||||
|
||||
useEffect(() => {
|
||||
if (showTooltip) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowTooltip(false);
|
||||
}, 3500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showTooltip]);
|
||||
|
||||
return (
|
||||
<div className="kpi-grid-container">
|
||||
{/* CARD 1: HLADINA */}
|
||||
<div className="kpi-card">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{dict.level} {lakeName}
|
||||
</div>
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{data.level.toFixed(2)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n. m.</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.85rem', color: (data.levelDiff24h ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
|
||||
({(data.levelDiff24h ?? 0) > 0 ? '+' : ''}{((data.levelDiff24h ?? 0) * 100).toFixed(1)} cm / 24h)
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: (data.levelDiff7d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
|
||||
({(data.levelDiff7d ?? 0) > 0 ? '+' : ''}{((data.levelDiff7d ?? 0) * 100).toFixed(1)} cm / 7d)
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: (data.levelDiff30d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
|
||||
({(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm / 30d)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Circle for Level */}
|
||||
<div style={{ position: 'absolute', right: '1.5rem', top: '1.5rem', width: '40px', height: '40px', borderRadius: '50%', border: '4px solid rgba(0, 195, 255, 0.2)', borderTopColor: 'var(--color-cyan)', transform: 'rotate(45deg)' }}></div>
|
||||
</div>
|
||||
|
||||
{/* CARD 2: PRŮTOK */}
|
||||
<div className="kpi-card">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{dict.flow}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#8b5cf6', marginRight: '6px' }}></span>
|
||||
{dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-orange)', marginRight: '2px' }}></span>
|
||||
{dict.outflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.outflow.toFixed(1)} m³/s</span>
|
||||
{flowDiff > 0 ? <FiArrowUp color="var(--color-green)" /> : flowDiff < 0 ? <FiArrowDown color="var(--color-red)" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Circle */}
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '50%', border: '4px solid rgba(0, 195, 255, 0.2)', borderTopColor: 'var(--color-cyan)', borderRightColor: 'var(--color-cyan)', display: 'flex', alignItems: 'center', justifyContent: 'center', transform: 'rotate(-45deg)' }}>
|
||||
<span style={{ fontSize: '0.65rem', transform: 'rotate(45deg)', color: 'var(--text-main)', fontWeight: 'bold' }}>
|
||||
<div style={{ lineHeight: 1 }}>{data.outflow.toFixed(1)}</div>
|
||||
<div style={{ fontSize: '0.45rem', opacity: 0.7 }}>m³/s</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CARD 3: NAPLNĚNOST */}
|
||||
<div className="kpi-card">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
|
||||
{dict.fullness}
|
||||
<span
|
||||
onClick={() => setShowTooltip(!showTooltip)}
|
||||
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.6, padding: '0 4px' }}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
{showTooltip && (
|
||||
<div
|
||||
onClick={() => setShowTooltip(false)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '8px',
|
||||
width: '250px',
|
||||
zIndex: 100,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
{language === 'cs' ? "Rozdíl mezi aktuální hladinou a hladinou zásobního prostoru (důležité pro jachtaře a rekreaci)." : "Difference between current water level and storage space level (important for sailing and recreation)."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
|
||||
{data.storageDiff !== undefined && data.storageDiff !== 0 ? (data.storageDiff > 0 ? `+${data.storageDiff.toFixed(2)} m` : `${data.storageDiff.toFixed(2)} m`) : (data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A')}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{dict.volume}: {data.volume.toFixed(1)} mil. m³
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KpiCards;
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts';
|
||||
import { type Language, t } from '../translations';
|
||||
import KpiCards from './KpiCards';
|
||||
|
||||
interface LipnoData {
|
||||
timestamp: string;
|
||||
date: string;
|
||||
level: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
temperature?: number | null;
|
||||
precipitation?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
lakeId: string | null;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const dict = t[language].chart;
|
||||
if (isWeather) {
|
||||
return (
|
||||
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
||||
{payload.map((entry: any, index: number) => {
|
||||
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
|
||||
return (
|
||||
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
|
||||
{isTemp ? 'Teplota' : 'Srážky'}: <span style={{ fontWeight: 'bold' }}>{entry.value !== undefined && entry.value !== null ? entry.value.toFixed(1) : 'N/A'} {isTemp ? '°C' : 'mm'}</span>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
||||
{[...payload].sort((a: any, b: any) => {
|
||||
const order = ['level', 'inflow', 'outflow', 'temperature', 'precipitation'];
|
||||
const indexA = order.indexOf(a.dataKey);
|
||||
const indexB = order.indexOf(b.dataKey);
|
||||
return (indexA === -1 ? 99 : indexA) - (indexB === -1 ? 99 : indexB);
|
||||
}).map((entry: any, index: number) => {
|
||||
let labelStr = '';
|
||||
let unit = '';
|
||||
let color = '';
|
||||
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; }
|
||||
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-orange)'; }
|
||||
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = '#8b5cf6'; }
|
||||
else if (entry.dataKey === 'temperature') { labelStr = language === 'cs' ? 'Teplota' : 'Temperature'; unit = '°C'; color = 'var(--color-red)'; }
|
||||
else if (entry.dataKey === 'precipitation') { labelStr = language === 'cs' ? 'Srážky' : 'Precipitation'; unit = 'mm'; color = 'var(--color-cyan)'; }
|
||||
|
||||
if (!labelStr || entry.value === null || entry.value === undefined) return null;
|
||||
|
||||
return (
|
||||
<div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, marginRight: '8px' }}></span>
|
||||
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? 2 : 1)} {unit}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
const [data, setData] = useState<LipnoData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lakeInfo, setLakeInfo] = useState<any>(null);
|
||||
const [isSmoothed, setIsSmoothed] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d' | '1y' | 'all'>('7d');
|
||||
const dict = t[language].chart;
|
||||
const topbarDict = t[language].topbar;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/lakes_index.json')
|
||||
.then(res => res.json())
|
||||
.then(indexData => {
|
||||
const found = indexData.find((l: any) => l.id === lakeId);
|
||||
setLakeInfo(found || { name: 'Lipno 1', river: 'Vltava' });
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
|
||||
const internalId = lakeId ? lakeId.split('|')[0] : 'VLL1';
|
||||
|
||||
fetch(`/data/${internalId}.json`)
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
const formattedData = json.map((item: any) => {
|
||||
const outflow = item.flow === null || isNaN(item.flow) ? 0 : item.flow;
|
||||
|
||||
return {
|
||||
timestamp: item.timestamp,
|
||||
date: new Date(item.timestamp).toLocaleString(language === 'cs' ? 'cs-CZ' : 'en-GB', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
level: item.level === null || isNaN(item.level) ? 0 : item.level,
|
||||
outflow: outflow,
|
||||
inflow: item.inflow || 0,
|
||||
volume: item.volume || 0,
|
||||
fullness: 0,
|
||||
temperature: item.temperature,
|
||||
precipitation: item.precipitation
|
||||
};
|
||||
});
|
||||
setData(formattedData);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load data', err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [language, lakeId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--bg-dark)', color: 'var(--text-main)' }}>
|
||||
<div style={{ fontSize: '1.25rem' }}>Loading HLADINATOR...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestData = data[data.length - 1] || { level: 0, inflow: 0, outflow: 0, volume: 0, fullness: 0 };
|
||||
const curveType = isSmoothed ? 'monotone' : 'linear';
|
||||
|
||||
// Find last valid values for KPIs, including 0
|
||||
const lastValidFlowData = [...data].reverse().find(d => d.outflow !== null && !isNaN(d.outflow) && d.outflow >= 0) || latestData;
|
||||
|
||||
const now = new Date().getTime();
|
||||
const getCutoff = () => {
|
||||
switch (timeRange) {
|
||||
case '24h': return now - 24 * 60 * 60 * 1000;
|
||||
case '7d': return now - 7 * 24 * 60 * 60 * 1000;
|
||||
case '30d': return now - 30 * 24 * 60 * 60 * 1000;
|
||||
case '1y': return now - 365 * 24 * 60 * 60 * 1000;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const cutoff = getCutoff();
|
||||
const filteredData = data.filter(d => new Date(d.timestamp).getTime() >= cutoff);
|
||||
|
||||
// Downsample data for large time ranges to prevent stuttering
|
||||
let chartData = filteredData;
|
||||
if (timeRange === '30d' && filteredData.length > 200) {
|
||||
chartData = filteredData.filter((_, i) => i % 4 === 0 || i === filteredData.length - 1);
|
||||
} else if ((timeRange === '1y' || timeRange === 'all') && filteredData.length > 200) {
|
||||
chartData = filteredData.filter((_, i) => i % 24 === 0 || i === filteredData.length - 1);
|
||||
}
|
||||
|
||||
const animate = chartData.length < 150;
|
||||
|
||||
// Find record from 24h, 7d, 30d ago
|
||||
const nowMs = new Date(latestData.timestamp).getTime();
|
||||
const targetMs24h = nowMs - 24 * 60 * 60 * 1000;
|
||||
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
|
||||
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let level24hAgo = latestData.level;
|
||||
let level7dAgo = latestData.level;
|
||||
let level30dAgo = latestData.level;
|
||||
|
||||
let minDiff24h = Infinity;
|
||||
let minDiff7d = Infinity;
|
||||
let minDiff30d = Infinity;
|
||||
|
||||
for (const d of data) {
|
||||
const t = new Date(d.timestamp).getTime();
|
||||
|
||||
const diff24h = Math.abs(t - targetMs24h);
|
||||
if (diff24h < minDiff24h) {
|
||||
minDiff24h = diff24h;
|
||||
level24hAgo = d.level;
|
||||
}
|
||||
|
||||
const diff7d = Math.abs(t - targetMs7d);
|
||||
if (diff7d < minDiff7d) {
|
||||
minDiff7d = diff7d;
|
||||
level7dAgo = d.level;
|
||||
}
|
||||
|
||||
const diff30d = Math.abs(t - targetMs30d);
|
||||
if (diff30d < minDiff30d) {
|
||||
minDiff30d = diff30d;
|
||||
level30dAgo = d.level;
|
||||
}
|
||||
}
|
||||
|
||||
const levelDiff24h = latestData.level - level24hAgo;
|
||||
const levelDiff7d = latestData.level - level7dAgo;
|
||||
const levelDiff30d = latestData.level - level30dAgo;
|
||||
|
||||
const kpiData = {
|
||||
level: latestData.level,
|
||||
levelDiff24h,
|
||||
levelDiff7d,
|
||||
levelDiff30d,
|
||||
inflow: lastValidFlowData.inflow,
|
||||
outflow: lastValidFlowData.outflow,
|
||||
volume: lakeInfo?.volume || 0,
|
||||
fullness: lakeInfo?.capacity || 0,
|
||||
storageDiff: lakeInfo?.storageDiff
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginTop: '-0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span>{topbarDict.updated} {new Date().toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}, {new Date().toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' })} UTC</span>
|
||||
<div className="status-dot"></div>
|
||||
</div>
|
||||
|
||||
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
|
||||
|
||||
{/* CHART SECTION */}
|
||||
<div className="chart-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}</h3>
|
||||
<div className="top-time-controls" style={{ margin: 0 }}>
|
||||
<button className={timeRange === '24h' ? 'active' : ''} onClick={() => setTimeRange('24h')}>24h</button>
|
||||
<button className={timeRange === '7d' ? 'active' : ''} onClick={() => setTimeRange('7d')}>7d</button>
|
||||
<button className={timeRange === '30d' ? 'active' : ''} onClick={() => setTimeRange('30d')}>30d</button>
|
||||
<button className={timeRange === '1y' ? 'active' : ''} onClick={() => setTimeRange('1y')}>{dict.year}</button>
|
||||
<button className={timeRange === 'all' ? 'active' : ''} onClick={() => setTimeRange('all')}>{dict.all}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
|
||||
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="left" domain={['dataMin - 0.5', 'dataMax + 0.5']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(2)} />
|
||||
<YAxis yAxisId="right" orientation="right" domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<Tooltip content={<CustomTooltip language={language} />} />
|
||||
|
||||
{/* Data Series */}
|
||||
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} />
|
||||
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="#8b5cf6" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Chart Legend */}
|
||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-orange)' }}></div> {dict.outflow}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: '#8b5cf6' }}></div> {dict.inflow}</span>
|
||||
</div>
|
||||
|
||||
{/* WEATHER CHART SECTION */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', marginTop: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{language === 'cs' ? 'Počasí (Teplota a Srážky)' : 'Weather (Temperature & Precipitation)'}</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '200px', width: '100%', marginTop: '0.5rem' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 0, left: 10, bottom: 0 }}>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(1)} />
|
||||
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<Tooltip content={<CustomTooltip language={language} isWeather={true} />} />
|
||||
|
||||
<Bar yAxisId="precip" dataKey="precipitation" fill="var(--color-cyan)" fillOpacity={0.6} isAnimationActive={animate} />
|
||||
<Line yAxisId="temp" type={curveType} dataKey="temperature" stroke="var(--color-red)" strokeWidth={2} dot={true} isAnimationActive={animate} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {language === 'cs' ? 'Teplota vzduchu' : 'Temperature'} [°C]</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '12px', backgroundColor: 'var(--color-cyan)', opacity: 0.6 }}></div> {language === 'cs' ? 'Srážky' : 'Precipitation'} [mm]</span>
|
||||
</div>
|
||||
|
||||
{/* Smoothed Toggle Control */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '3rem', marginBottom: '1rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{dict.view}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.9rem' }}>
|
||||
<span style={{ color: !isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginRight: '0.5rem' }}>{dict.raw}</span>
|
||||
<div
|
||||
className={`toggle-switch ${isSmoothed ? 'on' : ''}`}
|
||||
onClick={() => setIsSmoothed(!isSmoothed)}
|
||||
></div>
|
||||
<span style={{ color: isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginLeft: '0.5rem' }}>{dict.smoothed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-footer" style={{ marginTop: '0' }}>
|
||||
<span>{dict.dataSources} <a href="https://www.chmi.cz/" target="_blank" rel="noreferrer">ČHMÚ</a>, <a href="https://www.pvl.cz/" target="_blank" rel="noreferrer">pvl.cz</a></span>
|
||||
<span>{dict.createdIn}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LakeDetail;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { FiX, FiSearch, FiDroplet } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
import { slugify } from '../utils/slugify';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface LakeData {
|
||||
id: string;
|
||||
name: string;
|
||||
river: string;
|
||||
priority: boolean;
|
||||
level: string;
|
||||
capacity: number;
|
||||
inflow: string;
|
||||
outflow: string;
|
||||
volume: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
}
|
||||
|
||||
// Create custom icon
|
||||
const createCustomIcon = () => {
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `
|
||||
<div class="map-marker-icon">
|
||||
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="18" width="18" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"></path><path d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"></path></svg>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [36, 42],
|
||||
iconAnchor: [18, 42],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
};
|
||||
|
||||
const LakeMap = ({ language }: Props) => {
|
||||
const [lakes, setLakes] = useState<LakeData[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isPanelVisible, setIsPanelVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/lakes_index.json')
|
||||
.then(res => res.json())
|
||||
.then(data => setLakes(data))
|
||||
.catch(err => console.error('Error fetching map lakes:', err));
|
||||
}, []);
|
||||
|
||||
const filteredLakes = lakes.filter(lake =>
|
||||
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const customIcon = createCustomIcon();
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
{/* Leaflet Map */}
|
||||
<MapContainer
|
||||
center={[49.8, 15.5]}
|
||||
zoom={7}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
zoomControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors'
|
||||
/>
|
||||
|
||||
{lakes.map(lake => (
|
||||
<Marker
|
||||
key={lake.id}
|
||||
position={[lake.lat, lake.lng]}
|
||||
icon={customIcon}
|
||||
eventHandlers={{
|
||||
click: () => navigate(`/${slugify(lake.name)}`)
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{lake.name}</strong><br/>
|
||||
{lake.river}
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
|
||||
{/* Floating Overlay Panel */}
|
||||
{isPanelVisible && (
|
||||
<div className="map-overlay-panel">
|
||||
<div className="map-overlay-header">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Seznam Jezer (Lakes List)</h3>
|
||||
<FiX style={{ cursor: 'pointer', color: 'var(--text-muted)' }} onClick={() => setIsPanelVisible(false)} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Nalezeno: {filteredLakes.length} Jezer
|
||||
</div>
|
||||
|
||||
<div className="search-bar" style={{ width: '100%', padding: '0.5rem', backgroundColor: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<FiSearch />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find a lake..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ width: '100%', background: 'transparent', border: 'none', color: 'white', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-overlay-list">
|
||||
{filteredLakes.map((lake, index) => (
|
||||
<div key={lake.id} className="map-lake-card" onClick={() => navigate(`/${slugify(lake.name)}`)}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>{index + 1}. Jezero {lake.name}</div>
|
||||
<div className="map-lake-stats">
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-muted)', display: 'block' }}>Area</span>
|
||||
<span style={{ color: 'var(--text-main)' }}>{(Math.random() * 50 + 10).toFixed(1)} km²</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-muted)', display: 'block' }}>Depth</span>
|
||||
<span style={{ color: 'var(--text-main)' }}>{(Math.random() * 30 + 5).toFixed(1)}m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPanelVisible && (
|
||||
<button
|
||||
style={{ position: 'absolute', top: 10, right: 10, zIndex: 1000, background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'white', padding: '0.5rem 1rem', borderRadius: '8px', cursor: 'pointer' }}
|
||||
onClick={() => setIsPanelVisible(true)}
|
||||
>
|
||||
Show List
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LakeMap;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { slugify } from '../utils/slugify';
|
||||
|
||||
interface Lake {
|
||||
id: string;
|
||||
name: string;
|
||||
river: string;
|
||||
priority: boolean;
|
||||
level: number;
|
||||
capacity: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
}
|
||||
|
||||
const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number, size?: number, strokeWidth?: number }) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size }}>
|
||||
<svg width={size} height={size}>
|
||||
<circle
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
fill="transparent"
|
||||
strokeWidth={strokeWidth}
|
||||
r={radius}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
/>
|
||||
<circle
|
||||
stroke="var(--color-cyan)"
|
||||
fill="transparent"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
|
||||
r={radius}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
|
||||
{value > 0 ? `${value}%` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
|
||||
const navigate = useNavigate();
|
||||
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="kpi-card priority-lake-card"
|
||||
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
||||
style={{ cursor: 'pointer', flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
|
||||
>
|
||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</h3>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<div style={{ width: '40px', height: '60px', backgroundColor: 'rgba(255,255,255,0.05)', position: 'relative', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, width: '100%', height: `${lake.capacity}%`, backgroundColor: 'var(--color-cyan)', opacity: 0.3 }}></div>
|
||||
<div style={{ position: 'absolute', bottom: `${lake.capacity}%`, left: 0, width: '100%', height: '2px', backgroundColor: 'var(--color-cyan)' }}></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Water level</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)' }}>m n.m.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
||||
<div>
|
||||
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'} / <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{lake.volume} mil. m³</span></div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Volume</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, height: '40px', marginRight: '2rem' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorSpark" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<FiTrendingUp color="var(--color-green)" />
|
||||
<span style={{ color: 'var(--text-muted)' }}>Inflow <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<FiTrendingDown color="var(--color-red)" />
|
||||
<span style={{ color: 'var(--text-muted)' }}>Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LakesOverview = ({ language }: Props) => {
|
||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/lakes_index.json')
|
||||
.then(res => res.json())
|
||||
.then(data => setLakes(data))
|
||||
.catch(err => console.error(err));
|
||||
}, []);
|
||||
|
||||
const priorityLakes = lakes.filter(l => l.priority);
|
||||
const otherLakes = lakes.filter(l => !l.priority);
|
||||
|
||||
otherLakes.sort((a, b) => {
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'level') return b.level - a.level;
|
||||
if (sortBy === 'capacity') return b.capacity - a.capacity;
|
||||
if (sortBy === 'inflow') return b.inflow - a.inflow;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const sortButtonStyle = (type: string) => ({
|
||||
background: 'none', border: 'none',
|
||||
color: sortBy === type ? 'var(--text-main)' : 'var(--text-muted)',
|
||||
cursor: 'pointer', fontSize: '0.85rem'
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>Overview: Lakes ({lakes.length})</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>Monitoring {lakes.length} reservoirs across the Czech Republic</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
<span>Sort by:</span>
|
||||
<button style={sortButtonStyle('name')} onClick={() => setSortBy('name')}>Name (A-Z)</button> |
|
||||
<button style={sortButtonStyle('level')} onClick={() => setSortBy('level')}>Level</button> |
|
||||
<button style={sortButtonStyle('capacity')} onClick={() => setSortBy('capacity')}>Capacity</button> |
|
||||
<button style={sortButtonStyle('inflow')} onClick={() => setSortBy('inflow')}>Flow In</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{priorityLakes.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Priority Reservoirs</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '1.5rem'
|
||||
}}>
|
||||
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Other Reservoirs ({otherLakes.length})</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LakesOverview;
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import '../index.css';
|
||||
|
||||
interface LoadingScreenProps {
|
||||
onLoaded: () => void;
|
||||
}
|
||||
|
||||
const LoadingScreen: React.FC<LoadingScreenProps> = ({ onLoaded }) => {
|
||||
const [fading, setFading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setFading(true);
|
||||
setTimeout(onLoaded, 500); // Wait for fade out animation
|
||||
}, 2500); // Show loading screen for 2.5 seconds
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [onLoaded]);
|
||||
|
||||
return (
|
||||
<div className={`loading-screen ${fading ? 'fade-out' : ''}`}>
|
||||
<div className="loader-content">
|
||||
<div className="spinner"></div>
|
||||
<h1 className="loading-text">Welcome</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingScreen;
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from 'react';
|
||||
import logo from '../assets/logo.jpg';
|
||||
import { translations, type Language } from '../translations';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
interface NavbarProps {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ language, setLanguage }) => {
|
||||
const t = translations[language];
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavClick = (id: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/');
|
||||
// Wait for navigation then scroll
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/');
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="nav-content">
|
||||
<div
|
||||
className="logo-container"
|
||||
onClick={handleHomeClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={logo} alt="David Fencl Logo" className="nav-logo" />
|
||||
</div>
|
||||
<div className="nav-right">
|
||||
<div className="language-switcher">
|
||||
<button
|
||||
className={`lang-btn ${language === 'en' ? 'active' : ''}`}
|
||||
onClick={() => setLanguage('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<span className="lang-separator">/</span>
|
||||
<button
|
||||
className={`lang-btn ${language === 'cs' ? 'active' : ''}`}
|
||||
onClick={() => setLanguage('cs')}
|
||||
>
|
||||
CZ
|
||||
</button>
|
||||
</div>
|
||||
<div className="links">
|
||||
<Link to="/time-breaker">{t.timeBreaker}</Link>
|
||||
<a href="#about" onClick={(e) => handleNavClick('about', e)}>{t.about}</a>
|
||||
<a href="#contact" onClick={(e) => handleNavClick('contact', e)}>{t.contact}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { FiX, FiMoon, FiSun, FiGlobe, FiCoffee } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
theme: 'dark' | 'light';
|
||||
setTheme: (theme: 'dark' | 'light') => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SettingsModal = ({ language, setLanguage, theme, setTheme, onClose }: Props) => {
|
||||
const dict = t[language].settings;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '1rem',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', margin: 0 }}>{dict.title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0.5rem', borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<FiX size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme Setting */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
{dict.theme}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: theme === 'dark' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: theme === 'dark' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: theme === 'dark' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiMoon /> {dict.dark}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: theme === 'light' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: theme === 'light' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: theme === 'light' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiSun /> {dict.light}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Setting */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
{dict.language}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: language === 'en' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: language === 'en' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: language === 'en' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiGlobe /> {dict.english}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('cs')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: language === 'cs' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: language === 'cs' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: language === 'cs' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiGlobe /> {dict.czech}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buy me a coffee */}
|
||||
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem', textAlign: 'center' }}>
|
||||
<a
|
||||
href="https://buymeacoffee.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.75rem 1.5rem', borderRadius: '2rem',
|
||||
backgroundColor: '#FFDD00', color: '#000000', fontWeight: 'bold',
|
||||
textDecoration: 'none', transition: 'transform 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.transform = 'scale(1.05)'}
|
||||
onMouseOut={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
<FiCoffee size={20} />
|
||||
{dict.buyCoffee}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { FiDroplet, FiStar, FiMap, FiSettings, FiMenu, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
onOpenSettings: () => void;
|
||||
isMobileMenuOpen?: boolean;
|
||||
onCloseMobileMenu?: () => void;
|
||||
}
|
||||
|
||||
const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu }: Props) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dict = t[language].sidebar;
|
||||
|
||||
const isOverview = location.pathname === '/';
|
||||
const isMap = location.pathname === '/map';
|
||||
const isDetail = !isOverview && !isMap;
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
navigate(path);
|
||||
if (onCloseMobileMenu) onCloseMobileMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="sidebar-logo" style={{ position: 'relative' }}>
|
||||
<FiDroplet />
|
||||
<div className="sidebar-text">
|
||||
<span>HLADINATOR</span>
|
||||
<small>v1.0</small>
|
||||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
style={{
|
||||
position: 'absolute', right: isCollapsed ? '-16px' : '-16px', top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)',
|
||||
borderRadius: '50%', width: '32px', height: '32px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', zIndex: 10, boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="nav-links">
|
||||
<div className={`nav-item ${isDetail ? 'active' : ''}`} onClick={() => handleNavigate('/lipno-1')}>
|
||||
<FiStar />
|
||||
<span className="sidebar-text">{dict.favorites}</span>
|
||||
</div>
|
||||
<div className={`nav-item ${isOverview ? 'active' : ''}`} onClick={() => handleNavigate('/')}>
|
||||
<FiMenu />
|
||||
<span className="sidebar-text">{dict.lakes}</span>
|
||||
</div>
|
||||
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
|
||||
<FiMap />
|
||||
<span className="sidebar-text">{dict.map}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="nav-item" onClick={onOpenSettings}>
|
||||
<FiSettings />
|
||||
<span className="sidebar-text">{dict.settings}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -1,162 +0,0 @@
|
||||
.timer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background-color: rgba(30, 32, 36, 0.5);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-size: 5rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.timer-display.time-up {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
animation: flash 1s infinite;
|
||||
border: 1px solid rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.time-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.time-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
background: #747bff;
|
||||
}
|
||||
|
||||
.time-slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.time-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
background: #747bff;
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
min-width: 120px;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.8rem 2rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background-color: #747bff;
|
||||
box-shadow: 0 6px 20px rgba(100, 108, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--secondary-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
border-color: var(--text-color);
|
||||
color: var(--text-color);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.timer-display {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
min-width: 100px;
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { translations, type Language } from '../translations';
|
||||
import './TimeBreaker.css';
|
||||
|
||||
interface TimeBreakerProps {
|
||||
language: Language;
|
||||
}
|
||||
|
||||
const DEFAULT_TIME_MINUTES = 22;
|
||||
const MAX_TIME_MINUTES = 180;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
|
||||
const TimeBreaker: React.FC<TimeBreakerProps> = ({ language }) => {
|
||||
const t = translations[language];
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isTimeUp, setIsTimeUp] = useState(false);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
// Format time as MM:SS
|
||||
const formatTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
document.title = `${formatTime(timeLeft)} - ${t.timeBreaker}`;
|
||||
return () => {
|
||||
document.title = 'David Fencl - IT Consulting';
|
||||
};
|
||||
}, [timeLeft, t.timeBreaker]);
|
||||
|
||||
// Timer logic
|
||||
useEffect(() => {
|
||||
if (isRunning && timeLeft > 0) {
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
handleTimeUp();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else if (timeLeft === 0 && isRunning) {
|
||||
handleTimeUp();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRunning]);
|
||||
|
||||
const handleTimeUp = () => {
|
||||
setIsRunning(false);
|
||||
setIsTimeUp(true);
|
||||
playAlarm();
|
||||
// Play alarm 3 times
|
||||
setTimeout(playAlarm, 1000);
|
||||
setTimeout(playAlarm, 2000);
|
||||
};
|
||||
|
||||
const playAlarm = () => {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
const ctx = audioContextRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
// Create oscillator
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.5); // Drop to A4
|
||||
|
||||
gainNode.gain.setValueAtTime(0.5, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(ctx.currentTime + 0.5);
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (timeLeft === 0) return;
|
||||
setIsRunning(true);
|
||||
setIsTimeUp(false);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsRunning(false);
|
||||
setIsTimeUp(false);
|
||||
setTimeLeft(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-container fade-in">
|
||||
<section className="hero">
|
||||
<div className="timer-container">
|
||||
<h1 style={{ marginBottom: '1rem' }}>{t.timeBreaker}</h1>
|
||||
|
||||
<div className={`timer-display ${isTimeUp ? 'time-up' : ''}`}>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
|
||||
<div className="controls-section">
|
||||
<div className="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={MAX_TIME_MINUTES}
|
||||
value={Math.ceil(timeLeft / 60)}
|
||||
onChange={(e) => {
|
||||
const minutes = parseInt(e.target.value, 10);
|
||||
setTimeLeft(minutes * 60);
|
||||
if (minutes > 0) setIsTimeUp(false);
|
||||
}}
|
||||
className="time-slider"
|
||||
/>
|
||||
<div className="slider-labels">
|
||||
<span>0m</span>
|
||||
<span>{MAX_TIME_MINUTES}m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-controls">
|
||||
{!isRunning ? (
|
||||
<button className="btn control-btn start-btn" onClick={handleStart}>
|
||||
{timeLeft > 0 && timeLeft < DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE ? 'Resume' : 'Start'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn control-btn start-btn" onClick={handlePause}>
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
<button className="btn control-btn reset-btn" onClick={handleReset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeBreaker;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FiSearch, FiMenu, FiDroplet } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
onToggleMobileMenu?: () => void;
|
||||
}
|
||||
|
||||
const Topbar = ({ language, onToggleMobileMenu }: Props) => {
|
||||
const dict = t[language].topbar;
|
||||
|
||||
return (
|
||||
<div className="topbar">
|
||||
<div className="topbar-mobile-header">
|
||||
<FiMenu onClick={onToggleMobileMenu} className="mobile-only" style={{ fontSize: '1.5rem', cursor: 'pointer' }} />
|
||||
|
||||
<div className="mobile-only" style={{ alignItems: 'center', gap: '0.5rem', fontWeight: 'bold', fontSize: '1.25rem' }}>
|
||||
<FiDroplet color="var(--color-cyan)" />
|
||||
<span>Hladinator</span>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<FiSearch />
|
||||
<input type="text" placeholder={dict.search} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Topbar;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import KpiCards from '../KpiCards';
|
||||
|
||||
describe('KpiCards Component', () => {
|
||||
const mockData = {
|
||||
level: 723.10,
|
||||
inflow: 5.5,
|
||||
outflow: 2.5,
|
||||
volume: 150,
|
||||
fullness: 80,
|
||||
storageDiff: -1.81
|
||||
};
|
||||
|
||||
it('renders correctly with negative storageDiff (red)', () => {
|
||||
const { container } = render(<KpiCards data={mockData} language="cs" />);
|
||||
|
||||
// ZÁSOBNÍ PROSTOR card should show -1.81 m
|
||||
expect(screen.getByText('-1.81 m')).toBeInTheDocument();
|
||||
|
||||
// Because it is negative, it should have the red color style applied
|
||||
const diffElement = screen.getByText('-1.81 m');
|
||||
expect(diffElement.parentElement?.outerHTML).toContain('var(--color-red)');
|
||||
});
|
||||
|
||||
it('renders correctly with positive storageDiff (cyan)', () => {
|
||||
const positiveData = { ...mockData, storageDiff: 0.5 };
|
||||
render(<KpiCards data={positiveData} language="cs" />);
|
||||
|
||||
expect(screen.getByText('+0.50 m')).toBeInTheDocument();
|
||||
const diffElement = screen.getByText('+0.50 m');
|
||||
expect(diffElement.parentElement?.outerHTML).toContain('var(--color-cyan)');
|
||||
});
|
||||
|
||||
it('falls back to percentage fullness if storageDiff is zero/undefined', () => {
|
||||
const noDiffData = { ...mockData, storageDiff: 0, fullness: 85.5 };
|
||||
render(<KpiCards data={noDiffData} language="cs" />);
|
||||
|
||||
expect(screen.getByText('85.5%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+261
-350
@@ -1,366 +1,277 @@
|
||||
:root {
|
||||
--bg-color: #0f0f0f;
|
||||
--text-color: #f0f0f0;
|
||||
--accent-color: #646cff;
|
||||
--secondary-color: #a0a0a0;
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: dark;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
scroll-behavior: smooth;
|
||||
/* Colors based on HLADINATOR design */
|
||||
--bg-dark: #1e293b; /* Unified lighter navy background */
|
||||
--bg-card: #1e293b; /* Card/Panel background */
|
||||
--bg-card-hover: #334155;
|
||||
--text-main: #f8fafc; /* White text */
|
||||
--text-muted: #94a3b8; /* Gray text */
|
||||
|
||||
--color-cyan: #06b6d4; /* Hladina / Primary */
|
||||
--color-green: #22c55e; /* Přítok / Positive trend */
|
||||
--color-red: #ef4444; /* Odtok / Negative trend */
|
||||
--color-orange: #f97316; /* Odtok line chart color */
|
||||
|
||||
.kpi-grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: 100px;
|
||||
/* Offset for sticky header */
|
||||
.kpi-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hide old styles but keep them in case they are used elsewhere */
|
||||
.kpi-container-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpi-card-full {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpi-row-half {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpi-card-half {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Map View Styles */
|
||||
.map-view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.map-overlay-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 350px;
|
||||
max-height: calc(100% - 20px);
|
||||
background-color: rgba(16, 22, 34, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.map-overlay-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.map-overlay-list {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.map-lake-card {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.map-lake-card:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.map-lake-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: #2a3441;
|
||||
}
|
||||
|
||||
.map-lake-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Custom Leaflet Marker */
|
||||
.custom-div-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.map-marker-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-color: var(--color-cyan);
|
||||
color: #000;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
.map-marker-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 8px solid white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.map-overlay-panel {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: 50%;
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.kpi-grid-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Time controls pill layout */
|
||||
.top-time-controls {
|
||||
display: flex;
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.top-time-controls button {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
padding: 0.4rem 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.top-time-controls button:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.top-time-controls button.active {
|
||||
background-color: var(--color-cyan);
|
||||
color: white;
|
||||
}
|
||||
|
||||
--border-color: rgba(255, 255, 255, 0.05);
|
||||
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body.light-mode {
|
||||
--bg-dark: #f1f5f9; /* Unified light background */
|
||||
--bg-card: #f1f5f9; /* Card/Panel background */
|
||||
--bg-card-hover: #e2e8f0;
|
||||
--text-main: #0f172a; /* Dark navy text */
|
||||
--text-muted: #64748b; /* Muted gray text */
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Slightly darker graph colors for white background */
|
||||
--color-cyan: #0891b2;
|
||||
--color-green: #16a34a;
|
||||
--color-red: #dc2626;
|
||||
--color-orange: #ea580c;
|
||||
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading Screen */
|
||||
.loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
.loading-screen.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loader-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-left-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Home */
|
||||
.home-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 1s ease-in;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background-color: rgba(15, 15, 15, 0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 8rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: 120px;
|
||||
width: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.links a {
|
||||
margin-left: 2rem;
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
color: var(--secondary-color);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
color: var(--accent-color);
|
||||
/* Fix Recharts focus outlines when clicking the chart */
|
||||
.recharts-wrapper,
|
||||
.recharts-wrapper *,
|
||||
.recharts-surface,
|
||||
.recharts-surface *,
|
||||
.recharts-responsive-container,
|
||||
.recharts-responsive-container * {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4rem 8rem;
|
||||
padding-top: 10vh;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 3px;
|
||||
color: var(--secondary-color);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3.5rem;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.job-title {
|
||||
font-size: 3.5rem;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--secondary-color);
|
||||
max-width: 600px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hero-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4rem;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 2px;
|
||||
color: var(--secondary-color);
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.icon-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: linear-gradient(145deg, #1e2024, #23272b);
|
||||
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: linear-gradient(145deg, #23272b, #1e2024);
|
||||
transform: translateY(-2px);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.hero-image-container {
|
||||
flex: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-image-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4;
|
||||
background: linear-gradient(145deg, #1e2024, #23272b);
|
||||
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.lang-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s, color 0.3s;
|
||||
padding: 0;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
opacity: 1;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-content {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-footer {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-image-container {
|
||||
width: 80%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.about {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.recharts-wrapper:focus,
|
||||
.recharts-surface:focus,
|
||||
.recharts-responsive-container:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
+93
-31
@@ -1,38 +1,100 @@
|
||||
export type Language = 'en' | 'cs';
|
||||
|
||||
export const translations = {
|
||||
export const t = {
|
||||
en: {
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
contact: 'Contact',
|
||||
timeBreaker: 'Time-Breaker',
|
||||
welcome: 'WELCOME TO MY WORLD',
|
||||
hello: "Hello, I'm",
|
||||
job: 'a Developer.',
|
||||
desc: "I use animation as a third dimension by which to simplify experiences and guiding through each and every interaction. I'm not adding motion just to spruce things up, but doing it in ways that matter.",
|
||||
findMe: 'FIND WITH ME',
|
||||
bestSkill: 'BEST SKILL ON',
|
||||
aboutMe: 'About Me',
|
||||
aboutDesc: 'I build accessible, pixel-perfect, and performant web experiences. Passionate about technology and design.',
|
||||
getInTouch: 'Get In Touch',
|
||||
contactDesc: 'Interested in working together?',
|
||||
emailMe: 'Email me',
|
||||
sidebar: {
|
||||
favorites: 'Favorites',
|
||||
lakes: 'Lakes',
|
||||
map: 'Map',
|
||||
settings: 'Settings'
|
||||
},
|
||||
topbar: {
|
||||
search: 'Search river or reservoir (e.g. Lipno)...',
|
||||
updated: 'Last updated:'
|
||||
},
|
||||
kpi: {
|
||||
level: 'WATER LEVEL',
|
||||
flow: 'FLOW RATE',
|
||||
inflow: 'Inflow',
|
||||
outflow: 'Outflow',
|
||||
fullness: 'STORAGE LEVEL',
|
||||
volume: 'Volume'
|
||||
},
|
||||
chart: {
|
||||
title: 'Long-term development',
|
||||
timeframe: 'Timeframe',
|
||||
timeframeMobile: 'Time',
|
||||
view: 'View',
|
||||
raw: 'Raw data',
|
||||
smoothed: 'Smoothed',
|
||||
calendar: 'Calendar',
|
||||
all: 'All',
|
||||
year: 'Year',
|
||||
level: 'Water level',
|
||||
inflow: 'Inflow',
|
||||
outflow: 'Outflow',
|
||||
maxLevel: 'Max retention level',
|
||||
storageLevel: 'Storage space level',
|
||||
dataSources: 'Data sources:',
|
||||
createdIn: 'Created with ♥ in the Czech Republic'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
theme: 'Theme',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
language: 'Language',
|
||||
english: 'English',
|
||||
czech: 'Čeština',
|
||||
buyCoffee: 'Buy Me a Coffee'
|
||||
}
|
||||
},
|
||||
cs: {
|
||||
home: 'Domů',
|
||||
about: 'O mně',
|
||||
contact: 'Kontakt',
|
||||
timeBreaker: 'Time-Breaker',
|
||||
welcome: 'VÍTEJTE V MÉM SVĚTĚ',
|
||||
hello: "Ahoj, jsem",
|
||||
job: 'Vývojář.',
|
||||
desc: "Používám animaci jako třetí rozměr, kterým zjednodušuji zážitky a provázím každou interakcí. Nepřidávám pohyb jen pro efekt, ale dělám to způsoby, které mají smysl.",
|
||||
findMe: 'NAJDETE MĚ NA',
|
||||
bestSkill: 'DOVEDNOSTI',
|
||||
aboutMe: 'O mně',
|
||||
aboutDesc: 'Tvořím přístupné, pixel-perfect a výkonné webové zážitky. Vášnivý pro technologie a design.',
|
||||
getInTouch: 'Napište mi',
|
||||
contactDesc: 'Máte zájem o spolupráci?',
|
||||
emailMe: 'Napište mi',
|
||||
sidebar: {
|
||||
favorites: 'Oblíbené',
|
||||
lakes: 'Jezera',
|
||||
map: 'Mapa',
|
||||
settings: 'Nastavení'
|
||||
},
|
||||
topbar: {
|
||||
search: 'Hledat tok nebo nádrž (např. Lipno)...',
|
||||
updated: 'Aktualizováno:'
|
||||
},
|
||||
kpi: {
|
||||
level: 'HLADINA',
|
||||
flow: 'PRŮTOK',
|
||||
inflow: 'Přítok',
|
||||
outflow: 'Odtok',
|
||||
fullness: 'ZÁSOBNÍ PROSTOR',
|
||||
volume: 'Objem'
|
||||
},
|
||||
chart: {
|
||||
title: 'Dlouhodobý vývoj',
|
||||
timeframe: 'Časové období',
|
||||
timeframeMobile: 'Časové',
|
||||
view: 'Zobrazení',
|
||||
raw: 'Syrová data',
|
||||
smoothed: 'Vyhlazená',
|
||||
calendar: 'Kalendář',
|
||||
all: 'Vše',
|
||||
year: 'Rok',
|
||||
level: 'Hladina',
|
||||
inflow: 'Přítok',
|
||||
outflow: 'Odtok',
|
||||
maxLevel: 'Max. retenční hladina',
|
||||
storageLevel: 'Hladina zásobního prostoru',
|
||||
dataSources: 'Zdroje dat:',
|
||||
createdIn: 'Vytvořeno s ♥ v České republice'
|
||||
},
|
||||
settings: {
|
||||
title: 'Nastavení',
|
||||
theme: 'Vzhled',
|
||||
dark: 'Tmavý',
|
||||
light: 'Světlý',
|
||||
language: 'Jazyk',
|
||||
english: 'English',
|
||||
czech: 'Čeština',
|
||||
buyCoffee: 'Kup mi kávu'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const slugify = (text: string) => {
|
||||
return text
|
||||
.split(' - ')[0] // "VD Lipno 1 - Vltava" -> "VD Lipno 1"
|
||||
.replace(/^VD\s+/i, '') // Remove "VD " prefix -> "Lipno 1"
|
||||
.normalize('NFD') // Decompose diacritics
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-'); // Replace spaces with dashes -> "lipno-1"
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import https from 'https';
|
||||
|
||||
async function test() {
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
try {
|
||||
const res = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1', {
|
||||
httpsAgent: agent,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
});
|
||||
|
||||
const $ = cheerio.load(res.data);
|
||||
console.log('Inputs:');
|
||||
$('input').each((i, el) => {
|
||||
console.log(`Type: ${$(el).attr('type')}, Name: ${$(el).attr('name')}, Value: ${$(el).attr('value')}, ID: ${$(el).attr('id')}`);
|
||||
});
|
||||
|
||||
console.log('\nButtons/Links with postback:');
|
||||
$('a[href*="__doPostBack"]').each((i, el) => {
|
||||
console.log(`Text: ${$(el).text()}, Href: ${$(el).attr('href')}`);
|
||||
});
|
||||
const tables = $('table');
|
||||
tables.each((i, tbl) => {
|
||||
console.log(`TABLE ${i}:`);
|
||||
console.log($(tbl).find('tr').first().text().trim().replace(/\s+/g, ' '));
|
||||
console.log($(tbl).find('tr').eq(1).text().trim().replace(/\s+/g, ' '));
|
||||
console.log('---');
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
test();
|
||||
@@ -1,7 +1,13 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user