Compare commits

...

10 Commits

Author SHA1 Message Date
David Fencl a67a2247c3 feat: import new reservoir data, add lake management scripts, and update overview UI components
continuous-integration/drone/push Build encountered an error
2026-06-06 20:14:36 +02:00
David Fencl cf05e844d8 feat: update water level metrics and optimize sidebar UI layout 2026-06-06 18:38:18 +02:00
David Fencl 6395df1992 feat: implement multilingual SEO support and enhance map UI with data synchronization updates 2026-06-06 17:24:30 +02:00
David Fencl 66021e001e refactor: remove unused lake JSON files and truncate excessive historical data in index files 2026-06-06 12:41:42 +02:00
David Fencl db1aadcc8d feat: add automatic data polling, conditional search visibility, and extended scraper functionality for monthly lake records 2026-06-06 12:34:20 +02:00
David Fencl dbb22e7972 refactor: centralize lake metrics calculations into a utility module with comprehensive unit tests 2026-06-06 11:45:56 +02:00
David Fencl 6d77c20c84 refactor: remove coverage report and add weather widget and navigation utility files 2026-06-06 11:41:13 +02:00
David Fencl a3b3d40769 feat: add circular progress component and update historical lake data indices 2026-06-06 10:38:43 +02:00
David Fencl 27551f9183 feat: implement Favorites feature with persistent storage and sidebar integration and update lake data. 2026-06-05 23:57:17 +02:00
David Fencl b660f0f6c3 feat: add contact link to settings, update lake labels and sidebar icons, and enhance KPI flow visualization 2026-06-05 23:40:56 +02:00
101 changed files with 241429 additions and 32711 deletions
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import fs from 'fs';
import https from 'https';
async function compare() {
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
const agent = new https.Agent({ rejectUnauthorized: false });
const response = await axios.get(URL, { httpsAgent: agent });
const $ = cheerio.load(response.data);
let tblFound = null;
$('table').each((i, tbl) => {
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {
tblFound = $(tbl);
}
});
const pvlRows = [];
if (tblFound) {
tblFound.find('tr').each((i, row) => {
if (i === 0) return;
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(',', '.');
}
pvlRows.push({
date: rawDate,
level: parseFloat(levelStr),
flow: parseFloat(flowStr)
});
}
});
}
const localData = JSON.parse(fs.readFileSync('public/data/VLL1.json', 'utf-8'));
// Sort local data descending (newest first) to match PVL which is newest first
const sortedLocal = localData.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
console.log('--- POROVNÁNÍ DAT: LIPNO 1 ---');
console.log(String('PVL.CZ').padEnd(40) + ' | ' + 'NAŠE LOKÁLNÍ DATABÁZE');
console.log('-'.repeat(85));
for (let i = 0; i < Math.min(10, pvlRows.length); i++) {
const p = pvlRows[i];
const l = sortedLocal[i];
// Format our local UTC timestamp back to something readable
const d = new Date(l.timestamp);
const localDateStr = `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth()+1).toString().padStart(2, '0')}.${d.getFullYear()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
const pvlStr = `[${p.date}] H: ${p.level} m, O: ${p.flow} m3/s`.padEnd(40);
const locStr = `[${localDateStr}] H: ${l.level} m, O: ${l.flow} m3/s, P: ${l.inflow} m3/s`;
console.log(`${pvlStr} | ${locStr}`);
}
}
compare().catch(console.error);
-224
View File
@@ -1,224 +0,0 @@
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;
}
-87
View File
@@ -1,87 +0,0 @@
/* 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);
-133
View File
@@ -1,133 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 445 B

-146
View File
@@ -1,146 +0,0 @@
<!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>
-1
View File
@@ -1 +0,0 @@
.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
-131
View File
@@ -1,131 +0,0 @@
<!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>
-160
View File
@@ -1,160 +0,0 @@
<!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">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</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;
}
&nbsp;
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 }
];
&nbsp;</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>
-616
View File
@@ -1,616 +0,0 @@
<!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">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</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">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</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">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</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">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</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';
&nbsp;
interface DataRecord {
timestamp: string;
level: number;
flow: number;
inflow?: number;
volume?: number;
temperature?: number | null;
precipitation?: number | null;
}
&nbsp;
// 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>
}
}
&nbsp;
async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&amp;id=${internalId}`;
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
&nbsp;
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'
}
});
&nbsp;
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) =&gt; {</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') &amp;&amp; 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) =&gt; {</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>
}
});
}
});
&nbsp;
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) =&gt; {</span>
<span class="cstat-no" title="statement not covered" > if ($(tbl).text().includes('Datum') &amp;&amp; $(tbl).text().includes('Odtok')) {</span>
<span class="cstat-no" title="statement not covered" > dataTable = $(tbl);</span>
}
});
&nbsp;
<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) =&gt; {</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 &gt;= 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 === '' &amp;&amp; cols.length &gt;= 4) {</span>
<span class="cstat-no" title="statement not covered" > flowStr = $(cols[3]).text().trim().replace(',', '.');</span>
}
&nbsp;
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
});
}
}
});
}
&nbsp;
<span class="cstat-no" title="statement not covered" > if (records.length &gt; 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>
}
&nbsp;
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>
}
&nbsp;
const dataMap = <span class="cstat-no" title="statement not covered" >new Map&lt;string, DataRecord&gt;();</span>
<span class="cstat-no" title="statement not covered" > existingData.forEach(<span class="fstat-no" title="function not covered" >item =&gt; <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 =&gt; <span class="cstat-no" title="statement not covered" >d</span>ataMap.set(item.timestamp, item))</span>;</span>
&nbsp;
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) =&gt; {</span>
<span class="cstat-no" title="statement not covered" > return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();</span>
});
&nbsp;
// 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 =&gt; {</span></span>
<span class="cstat-no" title="statement not covered" > if (item.temperature !== undefined &amp;&amp; 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>
}
&nbsp;
<span class="cstat-no" title="statement not covered" > if (item.precipitation !== undefined &amp;&amp; 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>
}
});
&nbsp;
<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>
&nbsp;
} catch (error: any) {
<span class="cstat-no" title="statement not covered" > console.error(`[${internalId}] Error scraping data:`, error.message);</span>
}
}
&nbsp;
async function runScraper() {
console.log(`Starting bulk scraper for ${lakesConfig.length} lakes...`);
for (const lake of lakesConfig) {
// ID format: VLL1|1 -&gt; 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 =&gt; <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>
}
&nbsp;
runScraper();
&nbsp;</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.

Before

Width:  |  Height:  |  Size: 138 B

-210
View File
@@ -1,210 +0,0 @@
/* 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);
-466
View File
@@ -1,466 +0,0 @@
<!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">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</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">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</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';
&nbsp;
interface KpiData {
level: number;
inflow: number;
outflow: number;
outflow: number;
volume: number;
fullness: number;
storageDiff?: number;
}
&nbsp;
interface Props {
data: KpiData;
language: Language;
lakeName?: string;
}
&nbsp;
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) =&gt; {
const [showTooltip, setShowTooltip] = useState(false);
const dict = t[language].kpi;
const flowDiff = data.inflow - data.outflow;
&nbsp;
useEffect(() =&gt; {
<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" >() =&gt; {</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" >() =&gt; <span class="cstat-no" title="statement not covered" >c</span>learTimeout(timer);</span></span>
}
}, [showTooltip]);
&nbsp;
return (
&lt;div className="kpi-grid-container"&gt;
{/* CARD 1: HLADINA */}
&lt;div className="kpi-card"&gt;
&lt;div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}&gt;
{dict.level} {lakeName}
&lt;/div&gt;
&lt;div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, marginBottom: '0.5rem' }}&gt;
{data.level.toFixed(2)} &lt;span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}&gt;m n. m.&lt;/span&gt;
&lt;/div&gt;
&lt;div style={{ fontSize: '0.85rem', color: 'var(--color-green)' }}&gt;
(+0.02 m / 24h)
&lt;/div&gt;
{/* Decorative Circle for Level */}
&lt;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)' }}&gt;&lt;/div&gt;
&lt;/div&gt;
&nbsp;
{/* CARD 2: PRŮTOK */}
&lt;div className="kpi-card"&gt;
&lt;div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}&gt;
{dict.flow}
&lt;/div&gt;
&lt;div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '100%' }}&gt;
&lt;div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}&gt;
&lt;div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}&gt;
&lt;span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#8b5cf6', marginRight: '6px' }}&gt;&lt;/span&gt;
{dict.inflow}: &lt;span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}&gt;{data.inflow.toFixed(1)} m³/s&lt;/span&gt;
&lt;/div&gt;
&lt;div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem' }}&gt;
&lt;span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-orange)', marginRight: '2px' }}&gt;&lt;/span&gt;
{dict.outflow}: &lt;span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}&gt;{data.outflow.toFixed(1)} m³/s&lt;/span&gt;
{flowDiff &gt; 0 ? &lt;FiArrowUp color="var(--color-green)" /&gt; : <span class="branch-1 cbranch-no" title="branch not covered" >flowDiff &lt; 0 ? &lt;FiArrowDown color="var(--color-red)" /&gt; : null}</span>
&lt;/div&gt;
&lt;/div&gt;
{/* Flow Circle */}
&lt;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)' }}&gt;
&lt;span style={{ fontSize: '0.65rem', transform: 'rotate(45deg)', color: 'var(--text-main)', fontWeight: 'bold' }}&gt;
&lt;div style={{ lineHeight: 1 }}&gt;{data.outflow.toFixed(1)}&lt;/div&gt;
&lt;div style={{ fontSize: '0.45rem', opacity: 0.7 }}&gt;m³/s&lt;/div&gt;
&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&nbsp;
{/* CARD 3: NAPLNĚNOST */}
&lt;div className="kpi-card"&gt;
&lt;div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}&gt;
{dict.fullness}
&lt;span
onClick={<span class="fstat-no" title="function not covered" >() =&gt; <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' }}
&gt;
&lt;/span&gt;
{showTooltip &amp;&amp; (
<span class="branch-1 cbranch-no" title="branch not covered" > &lt;div </span>
onClick={<span class="fstat-no" title="function not covered" >() =&gt; <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'
}}&gt;
{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)."}
&lt;/div&gt;
)}
&lt;/div&gt;
&lt;div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', color: data.storageDiff &amp;&amp; data.storageDiff &lt; 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}&gt;
{data.storageDiff !== undefined &amp;&amp; data.storageDiff !== 0 ? (data.storageDiff &gt; 0 ? `+${data.storageDiff.toFixed(2)} m` : `${data.storageDiff.toFixed(2)} m`) : (data.fullness &gt; 0 ? `${data.fullness.toFixed(1)}%` : <span class="branch-1 cbranch-no" title="branch not covered" >'N/A')}</span>
&lt;/div&gt;
&lt;div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}&gt;
{dict.volume}: {data.volume.toFixed(1)} mil. m³
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
);
};
&nbsp;
export default KpiCards;
&nbsp;</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>
-116
View File
@@ -1,116 +0,0 @@
<!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>
-116
View File
@@ -1,116 +0,0 @@
<!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>
-385
View File
@@ -1,385 +0,0 @@
<!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">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">export type Language = 'en' | 'cs';
&nbsp;
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'
}
}
};
&nbsp;</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>
+6 -1
View File
@@ -4,7 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Davis Fencl</title>
<title>Hladinátor - Aktuální stav přehrad a nádrží</title>
<meta name="description" content="Sledujte aktuální vodní stav, průtok a vývoj počasí na českých přehradách a nádržích v reálném čase." />
<meta property="og:title" content="Hladinátor - Aktuální stav přehrad a nádrží" />
<meta property="og:description" content="Sledujte aktuální vodní stav, průtok a vývoj počasí na českých přehradách a nádržích v reálném čase." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hladinator.cz" />
</head>
<body>
<div id="root"></div>
+48 -1
View File
@@ -14,6 +14,7 @@
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-helmet-async": "^3.0.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.9.6",
@@ -3936,6 +3937,15 @@
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -4016,7 +4026,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4219,6 +4228,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4688,6 +4709,26 @@
"react": "^19.2.0"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-helmet-async": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz",
"integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==",
"license": "Apache-2.0",
"dependencies": {
"invariant": "^2.2.4",
"react-fast-compare": "^3.2.2",
"shallowequal": "^1.1.0"
},
"peerDependencies": {
"react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -4957,6 +4998,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+1
View File
@@ -24,6 +24,7 @@
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-helmet-async": "^3.0.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.9.6",
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
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
+3545 -3248
View File
File diff suppressed because it is too large Load Diff
+3577 -3280
View File
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
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
-1
View File
@@ -1 +0,0 @@
[]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
[]
File diff suppressed because it is too large Load Diff
+3538 -3241
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3554 -3257
View File
File diff suppressed because it is too large Load Diff
+3549 -3252
View File
File diff suppressed because it is too large Load Diff
+3558 -3261
View File
File diff suppressed because it is too large Load Diff
+3559 -3262
View File
File diff suppressed because it is too large Load Diff
+3590 -3284
View File
File diff suppressed because it is too large Load Diff
+3575 -3269
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
[]
File diff suppressed because it is too large Load Diff
+1026 -243
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://hladinator.cz/sitemap.xml
+1449
View File
File diff suppressed because one or more lines are too long
+629
View File
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
import axios from 'axios';
import fs from 'fs';
import https from 'https';
import * as cheerio from 'cheerio';
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
async function testPostback() {
const agent = new https.Agent({ rejectUnauthorized: false });
const res = await axios.get(URL, { httpsAgent: agent, timeout: 10000 });
const $ = cheerio.load(res.data);
const viewstate = $('#__VIEWSTATE').val();
const viewstategenerator = $('#__VIEWSTATEGENERATOR').val();
const eventvalidation = $('#__EVENTVALIDATION').val();
// Try to POST for monthly data
const postData = new URLSearchParams();
postData.append('__EVENTTARGET', 'ctl00$ObsahCPH$PrechodNaBilancniData');
postData.append('__EVENTARGUMENT', '');
postData.append('__VIEWSTATE', viewstate as string);
postData.append('__VIEWSTATEGENERATOR', viewstategenerator as string);
postData.append('__EVENTVALIDATION', eventvalidation as string);
const postRes = await axios.post(URL, postData.toString(), {
httpsAgent: agent,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
fs.writeFileSync('pvl_raw_month.html', postRes.data);
console.log('Saved monthly data to pvl_raw_month.html');
const $post = cheerio.load(postRes.data);
const rows = $post('table.tabulka-seznam tr:not(:first-child)');
console.log(`Found ${rows.length} rows in the table.`);
if (rows.length > 0) {
const firstRow = rows.first().find('td').first().text().trim();
const lastRow = rows.last().find('td').first().text().trim();
console.log(`Date range: ${firstRow} to ${lastRow}`);
}
}
testPostback().catch(console.error);
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { calculateLakeMetrics, LakeCalculationConfig } from '../utils/calculations';
describe('calculateLakeMetrics', () => {
const config: LakeCalculationConfig = {
minLevel: 100,
maxLevel: 110,
storageLevel: 108,
maxVolume: 50,
};
it('should calculate capacity based on reported volume when available', () => {
// 25 / 50 = 50%
const result = calculateLakeMetrics(105, 25, config);
expect(result.capacity).toBe(50);
expect(result.volume).toBe(25);
});
it('should cap capacity at 100% when volume exceeds maxVolume', () => {
const result = calculateLakeMetrics(111, 55, config);
expect(result.capacity).toBe(100);
expect(result.volume).toBe(55);
});
it('should floor capacity at 0% when volume is negative', () => {
const result = calculateLakeMetrics(99, -5, config);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(-5);
});
it('should estimate capacity and volume from level when reported volume is 0', () => {
// Level 105 is exactly halfway between 100 and 110 -> 50%
// 50% of 50 maxVolume = 25
const result = calculateLakeMetrics(105, 0, config);
expect(result.capacity).toBe(50);
expect(result.volume).toBe(25);
});
it('should cap estimated capacity at 100% when level exceeds maxLevel', () => {
const result = calculateLakeMetrics(115, 0, config);
expect(result.capacity).toBe(100);
expect(result.volume).toBe(50); // 100% of 50
});
it('should floor estimated capacity at 0% when level is below minLevel', () => {
const result = calculateLakeMetrics(90, 0, config);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(0); // 0% of 50
});
it('should correctly calculate storageDiff', () => {
const result = calculateLakeMetrics(106, 25, config);
// 106 - 108 = -2.00
expect(result.storageDiff).toBe(-2);
});
it('should calculate positive storageDiff when above storageLevel', () => {
const result = calculateLakeMetrics(109, 25, config);
// 109 - 108 = 1.00
expect(result.storageDiff).toBe(1);
});
it('should handle missing config gracefully', () => {
const emptyConfig: LakeCalculationConfig = {};
const result = calculateLakeMetrics(105, 0, emptyConfig);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(0);
expect(result.storageDiff).toBe(0);
});
});
+99
View File
@@ -0,0 +1,99 @@
import fs from 'fs';
import path from 'path';
export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
}
// Preserve existing minLevel, maxLevel, storageLevel that were scraped from PVL.
// Only update maxVolume, coords, and navigationForbidden.
import { lakesConfig as oldConfig } from './lakesConfig';
const exactData: Record<string, Partial<LakeConfig>> = {
"VLL1|1": { maxVolume: 306.0, coords: [48.6322, 14.2215], navigationForbidden: false },
"VLL2|1": { maxVolume: 1.6, coords: [48.6250, 14.3180], navigationForbidden: false },
"VLHN|1": { maxVolume: 21.1, coords: [49.1830, 14.4440], navigationForbidden: false },
"VLKO|1": { maxVolume: 2.8, coords: [49.2550, 14.3980], navigationForbidden: false },
"VLOR|2": { maxVolume: 716.5, coords: [49.6060, 14.1700], navigationForbidden: false },
"VLSL|2": { maxVolume: 269.3, coords: [49.8220, 14.4360], navigationForbidden: false },
"VLST|2": { maxVolume: 11.2, coords: [49.8450, 14.4120], navigationForbidden: false },
"MARI|1": { maxVolume: 33.8, coords: [48.8470, 14.4870], navigationForbidden: true },
"MZHR|3": { maxVolume: 56.7, coords: [49.7890, 13.1550], navigationForbidden: false },
"ZESV|2": { maxVolume: 266.6, coords: [49.7040, 15.1150], navigationForbidden: true },
"VLKA|2": { maxVolume: 12.8, coords: [49.6380, 14.2580], navigationForbidden: false },
"VLVE|2": { maxVolume: 11.1, coords: [49.9390, 14.3910], navigationForbidden: false },
"BLHU|1": { maxVolume: 5.7, coords: [49.0270, 13.9870], navigationForbidden: true },
"UHNY|3": { maxVolume: 16.0, coords: [49.2610, 13.1230], navigationForbidden: true },
"KCKC|3": { maxVolume: 9.3, coords: [50.0630, 13.9310], navigationForbidden: true },
"KLKL|3": { maxVolume: 1.5, coords: [49.7540, 13.5640], navigationForbidden: false },
"RACU|3": { maxVolume: 5.5, coords: [49.7150, 13.3640], navigationForbidden: false },
"TRTR|2": { maxVolume: 4.1, coords: [49.5260, 15.1950], navigationForbidden: false },
"HESE|2": { maxVolume: 1.9, coords: [49.5070, 15.2630], navigationForbidden: false },
"MZLU|3": { maxVolume: 2.3, coords: [49.8050, 12.6390], navigationForbidden: true },
"STZL|3": { maxVolume: 14.5, coords: [50.0930, 13.1360], navigationForbidden: true },
"PPPI|3": { maxVolume: 1.6, coords: [49.6910, 13.9570], navigationForbidden: true },
"LILA|3": { maxVolume: 0.8, coords: [49.6640, 13.8820], navigationForbidden: true },
"OPOB|3": { maxVolume: 0.6, coords: [49.7110, 13.9370], navigationForbidden: true },
"STST|2": { maxVolume: 1.0, coords: [49.7910, 14.0040], navigationForbidden: false },
"HEVR|2": { maxVolume: 0.5, coords: [49.5070, 15.2440], navigationForbidden: false },
"CRSO|1": { maxVolume: 1.4, coords: [48.7750, 14.5360], navigationForbidden: false },
"SCHU|1": { maxVolume: 0.8, coords: [48.7840, 14.7350], navigationForbidden: false },
"SVSV|2": { maxVolume: 1.2, coords: [49.5750, 15.9520], navigationForbidden: true },
"SAPI|2": { maxVolume: 1.5, coords: [49.5930, 15.9320], navigationForbidden: false },
"SMSM|3": { maxVolume: 0.7, coords: [49.8970, 14.0580], navigationForbidden: false },
"CPZA|3": { maxVolume: 0.5, coords: [49.8050, 13.8510], navigationForbidden: false },
"BIBI|1": { maxVolume: 0.3, coords: [49.1670, 14.0410], navigationForbidden: false },
"SPKA|1": { maxVolume: 0.3, coords: [48.9740, 14.5450], navigationForbidden: false },
"SPNE|2": { maxVolume: 0.4, coords: [49.7710, 15.1760], navigationForbidden: false },
"SPZH|1": { maxVolume: 0.2, coords: [49.2310, 15.3120], navigationForbidden: true },
"KLDP|3": { maxVolume: 0.5, coords: [49.6640, 13.7530], navigationForbidden: true },
"KLHP|3": { maxVolume: 0.7, coords: [49.6550, 13.7610], navigationForbidden: true },
"CPDR|3": { maxVolume: 0.1, coords: [49.8050, 13.8550], navigationForbidden: false },
};
function main() {
const updated = oldConfig.map(lake => {
const fresh = exactData[lake.id];
if (fresh) {
return {
...lake,
maxVolume: fresh.maxVolume,
coords: fresh.coords,
navigationForbidden: fresh.navigationForbidden
};
}
return lake;
});
let newContent = `export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
}
export const lakesConfig: LakeConfig[] = [
`;
updated.forEach((l, idx) => {
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, storageLevel: ${l.storageLevel}, navigationForbidden: ${l.navigationForbidden} }${idx === updated.length - 1 ? '' : ','}\n`;
});
newContent += `];\n`;
fs.writeFileSync(path.resolve('./scripts/lakesConfig.ts'), newContent);
console.log("lakesConfig.ts updated with precise static data and navigation limits!");
}
main();
+6 -18
View File
@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { lakesConfig } from './lakesConfig';
import { calculateLakeMetrics } from './utils/calculations';
interface DataRecord {
timestamp: string;
@@ -33,7 +34,7 @@ const lakes = lakesConfig.map(lake => {
// 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));
sparkline = recentData.map(d => (d.level === null || isNaN(d.level) ? 0 : d.level));
// Pad with zeros if less than 12
while (sparkline.length < 12) {
@@ -53,20 +54,7 @@ const lakes = lakesConfig.map(lake => {
}
}
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));
}
const metrics = calculateLakeMetrics(currentLevel, volume, lake);
return {
id: lake.id,
@@ -74,11 +62,11 @@ const lakes = lakesConfig.map(lake => {
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
priority: lake.priority || false,
level: currentLevel.toFixed(2),
capacity: capacity,
storageDiff: storageDiff,
capacity: metrics.capacity,
storageDiff: metrics.storageDiff,
inflow: inflow.toFixed(1),
outflow: currentFlow.toFixed(1),
volume: volume,
volume: metrics.volume,
maxVolume: lake.maxVolume || 0,
lat: lake.coords[0],
lng: lake.coords[1],
+79
View File
@@ -0,0 +1,79 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
const ALL_LAKES = [
{"href": "Mereni.aspx?id=BIBI&oid=1", "text": "VD Bílsko"},
{"href": "Mereni.aspx?id=RACU&oid=3", "text": "VD České Údolí"},
{"href": "Mereni.aspx?id=KLDP&oid=3", "text": "VD Dolejší Padrťský rybník"},
{"href": "Mereni.aspx?id=CPDR&oid=3", "text": "VD Dráteník"},
{"href": "Mereni.aspx?id=KLHP&oid=3", "text": "VD Hořejší Padrťský rybník"},
{"href": "Mereni.aspx?id=SCHU&oid=1", "text": "VD Humenice"},
{"href": "Mereni.aspx?id=BLHU&oid=1", "text": "VD Husinec"},
{"href": "Mereni.aspx?id=VLKA&oid=2", "text": "VD Kamýk"},
{"href": "Mereni.aspx?id=SPKA&oid=1", "text": "VD Karhof"},
{"href": "Mereni.aspx?id=KLKL&oid=3", "text": "VD Klabava"},
{"href": "Mereni.aspx?id=KCKC&oid=3", "text": "VD Klíčava"},
{"href": "Mereni.aspx?id=LILA&oid=3", "text": "VD Láz"},
{"href": "Mereni.aspx?id=MZLU&oid=3", "text": "VD Lučina"},
{"href": "Mereni.aspx?id=SPNE&oid=2", "text": "VD Němčice"},
{"href": "Mereni.aspx?id=UHNY&oid=3", "text": "VD Nýrsko"},
{"href": "Mereni.aspx?id=OPOB&oid=3", "text": "VD Obecnice"},
{"href": "Mereni.aspx?id=PPPI&oid=3", "text": "VD Pilská (u Příbramě)"},
{"href": "Mereni.aspx?id=SAPI&oid=2", "text": "VD Pilská u Žďáru"},
{"href": "Mereni.aspx?id=HESE&oid=2", "text": "VD Sedlice"},
{"href": "Mereni.aspx?id=CRSO&oid=1", "text": "VD Soběnov"},
{"href": "Mereni.aspx?id=SVSV&oid=2", "text": "VD Staviště"},
{"href": "Mereni.aspx?id=STST&oid=2", "text": "VD Strž"},
{"href": "Mereni.aspx?id=SMSM&oid=3", "text": "VD Suchomasty"},
{"href": "Mereni.aspx?id=ZESV&oid=2", "text": "VD Švihov (Želivka)"},
{"href": "Mereni.aspx?id=TRTR&oid=2", "text": "VD Trnávka"},
{"href": "Mereni.aspx?id=VLVE&oid=2", "text": "VD Vrané"},
{"href": "Mereni.aspx?id=HEVR&oid=2", "text": "VD Vřesník"},
{"href": "Mereni.aspx?id=CPZA&oid=3", "text": "VD Záskalská"},
{"href": "Mereni.aspx?id=SPZH&oid=1", "text": "VD Zhejral"},
{"href": "Mereni.aspx?id=STZL&oid=3", "text": "VD Žlutice"}
];
async function checkLakes() {
const agent = new https.Agent({ rejectUnauthorized: false });
const validLakes: any[] = [];
for (const lake of ALL_LAKES) {
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/${lake.href}`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
let hasHistory = false;
let hasInflow = false;
$('table').each((i, tbl) => {
const text = $(tbl).text();
if (text.includes('Aktuální hodnoty') && text.includes('Přítok')) {
hasInflow = true;
}
if (text.includes('Datum') && text.includes('Odtok')) {
const rows = $(tbl).find('tr').length;
if (rows > 2) hasHistory = true;
}
});
if (hasHistory && hasInflow) {
validLakes.push(lake);
console.log(`[VALID] ${lake.text}`);
} else {
console.log(`[INVALID] ${lake.text} (Hist:${hasHistory}, In:${hasInflow})`);
}
} catch (err: any) {
console.error(`[ERROR] ${lake.text}: ${err.message}`);
}
}
console.log('\\n--- SUMMARY OF VALID LAKES ---');
console.log(JSON.stringify(validLakes, null, 2));
}
checkLakes();
+29
View File
@@ -0,0 +1,29 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function checkMap() {
const agent = new https.Agent({ rejectUnauthorized: false });
try {
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const html = response.data;
// Look for variables or inline JSON with coordinates
const scriptMatches = html.match(/<script\\b[^>]*>([\\s\\S]*?)<\\/script>/gi);
if (scriptMatches) {
scriptMatches.forEach((m: string, i: number) => {
if (m.includes('lat') || m.includes('Lng') || m.includes('Points') || m.includes('Markers')) {
console.log("Found something in script " + i);
console.log(m.substring(0, 500)); // preview
}
});
}
} catch (e: any) {
console.error(e.message);
}
}
checkMap();
+33
View File
@@ -0,0 +1,33 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function fetchLakes() {
const agent = new https.Agent({ rejectUnauthorized: false });
try {
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
httpsAgent: agent,
headers: {
'User-Agent': 'Mozilla/5.0'
}
});
const $ = cheerio.load(response.data);
const lakes: any[] = [];
// Links to lakes usually look like Mereni.aspx?oid=xxx&id=yyy
$('a[href^="Mereni.aspx"]').each((i, el) => {
const href = $(el).attr('href');
const text = $(el).text().trim();
if (href && text) {
lakes.push({ href, text });
}
});
console.log(JSON.stringify(lakes, null, 2));
} catch (err: any) {
console.error('Error:', err.message);
}
}
fetchLakes();
+103
View File
@@ -0,0 +1,103 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import fs from 'fs';
import path from 'path';
import { lakesConfig } from './lakesConfig';
async function fixLevels() {
const agent = new https.Agent({ rejectUnauthorized: false });
const updatedConfig = [...lakesConfig];
for (let i = 0; i < updatedConfig.length; i++) {
const lake = updatedConfig[i];
// id is like SPKA|1 -> internalId is SPKA, oid is 1
const parts = lake.id.split('|');
if (parts.length !== 2) continue;
const internalId = parts[0];
const oid = parts[1];
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?id=${internalId}&oid=${oid}`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
let maxRet: number | null = null;
let minStale: number | null = null;
let maxVol: number | null = null;
$('table').each((_, tbl) => {
const text = $(tbl).text();
// Parse levels
if (text.includes('Maximální retenční hladina:')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Maximální retenční hladina:')) {
const val = parseFloat(rowText.replace('Maximální retenční hladina:', '').replace('[m n.m.]', '').replace(',', '.').trim());
if (!isNaN(val)) maxRet = val;
}
if (rowText.includes('Hladina stálého nadržení:')) {
const val = parseFloat(rowText.replace('Hladina stálého nadržení:', '').replace('[m n.m.]', '').replace(',', '.').trim());
if (!isNaN(val)) minStale = val;
}
});
}
// Parse volume (this is current volume, wait, does PVL show max volume? Usually no, but current volume might be bigger than our guessed maxVolume)
if (text.includes('Aktuální hodnoty') && text.includes('Objem')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Objem [mil. m3]')) {
const valStr = $(row).find('td').eq(1).text().trim().replace(',', '.');
const val = parseFloat(valStr);
if (!isNaN(val)) maxVol = val; // We will just use the current volume as a baseline if it's bigger than our maxVolume
}
});
}
});
if (maxRet) updatedConfig[i].maxLevel = maxRet;
if (minStale) updatedConfig[i].minLevel = minStale;
// For volume, if the current volume is larger than the configured maxVolume, increase maxVolume
if (maxVol && updatedConfig[i].maxVolume && maxVol > updatedConfig[i].maxVolume!) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.2 * 10) / 10; // add 20% buffer
} else if (maxVol && !updatedConfig[i].maxVolume) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.5 * 10) / 10;
}
console.log(`Updated ${lake.text}: min=${minStale}, max=${maxRet}, vol=${maxVol} -> newMaxVol=${updatedConfig[i].maxVolume}`);
} catch (err: any) {
console.error(`Failed for ${lake.text}: ${err.message}`);
}
}
// Generate new file content
let newContent = `export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
}
export const lakesConfig: LakeConfig[] = [
`;
updatedConfig.forEach((l, idx) => {
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, storageLevel: ${l.storageLevel} }${idx === updatedConfig.length - 1 ? '' : ','}\\n`;
});
newContent += `];\\n`;
fs.writeFileSync(path.resolve('./scripts/lakesConfig.ts'), newContent);
console.log("lakesConfig.ts updated!");
}
fixLevels();
+101
View File
@@ -0,0 +1,101 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import fs from 'fs';
import path from 'path';
import { lakesConfig } from './lakesConfig';
async function fixStorageLevels() {
const agent = new https.Agent({ rejectUnauthorized: false });
const updatedConfig = [...lakesConfig];
for (let i = 0; i < updatedConfig.length; i++) {
const lake = updatedConfig[i];
const parts = lake.id.split('|');
if (parts.length !== 2) continue;
const internalId = parts[0];
const oid = parts[1];
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?id=${internalId}&oid=${oid}`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
let storageLevelFound: number | null = null;
let maxVol: number | null = null;
$('table').each((_, tbl) => {
const text = $(tbl).text();
// Parse storage level
if (text.includes('Hladina zásobního prostoru:')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Hladina zásobního prostoru:')) {
const val = parseFloat(rowText.replace('Hladina zásobního prostoru:', '').replace('[m n.m.]', '').replace(',', '.').trim());
if (!isNaN(val)) storageLevelFound = val;
}
});
}
// Parse current volume
if (text.includes('Aktuální hodnoty') && text.includes('Objem')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Objem [mil. m3]')) {
const valStr = $(row).find('td').eq(1).text().trim().replace(',', '.');
const val = parseFloat(valStr);
if (!isNaN(val)) maxVol = val;
}
});
}
});
if (storageLevelFound !== null) {
updatedConfig[i].storageLevel = storageLevelFound;
} else {
// if PVL doesn't have it, remove our fake guess so we fallback to maxLevel
delete updatedConfig[i].storageLevel;
}
// Fix maxVolume if current volume exceeds it
if (maxVol && updatedConfig[i].maxVolume && maxVol > updatedConfig[i].maxVolume!) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.05 * 10) / 10;
} else if (maxVol && !updatedConfig[i].maxVolume) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.05 * 10) / 10;
}
console.log(`Updated ${lake.text}: storageLevel=${storageLevelFound}, currVol=${maxVol} -> newMaxVol=${updatedConfig[i].maxVolume}`);
} catch (err: any) {
console.error(`Failed for ${lake.text}: ${err.message}`);
}
}
let newContent = `export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
}
export const lakesConfig: LakeConfig[] = [
`;
updatedConfig.forEach((l, idx) => {
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, ${l.storageLevel ? 'storageLevel: ' + l.storageLevel + ', ' : ''}navigationForbidden: ${l.navigationForbidden} }${idx === updatedConfig.length - 1 ? '' : ','}\n`;
});
newContent += `];\n`;
fs.writeFileSync(path.resolve('./scripts/lakesConfig.ts'), newContent);
console.log("lakesConfig.ts updated with precise storage levels!");
}
fixStorageLevels();
+40 -12
View File
@@ -7,19 +7,47 @@ export interface LakeConfig {
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
}
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 }
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306, minLevel: 716.1, maxLevel: 725.6, storageLevel: 724.9, navigationForbidden: false },
{ id: "VLL2|1", text: "VD Lipno II - Vltava", priority: true, coords: [48.6250, 14.3180], maxVolume: 1.6, minLevel: 557.6, maxLevel: 563.35, storageLevel: 562.7, navigationForbidden: false },
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 364.6, maxLevel: 370.1, storageLevel: 370.1, navigationForbidden: false },
{ id: "VLKO|1", text: "VD Kořensko - Vltava", priority: true, coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 347.8, maxLevel: 353.6, storageLevel: 352.6, navigationForbidden: false },
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 329.6, maxLevel: 353.6, storageLevel: 349.9, navigationForbidden: false },
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 246.6, maxLevel: 270.6, storageLevel: 270.6, navigationForbidden: false },
{ id: "VLST|2", text: "VD Štěchovice - Vltava", priority: true, coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 215.8, maxLevel: 219.4, storageLevel: 219.4, navigationForbidden: false },
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 442.5, maxLevel: 471.48, storageLevel: 470.65, navigationForbidden: true },
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 339.6, maxLevel: 357.97, storageLevel: 354.1, navigationForbidden: false },
{ id: "ZESV|2", text: "VD Švihov (Želivka)", priority: true, coords: [49.7040, 15.1150], maxVolume: 266.6, minLevel: 343.1, maxLevel: 379.8, storageLevel: 377, navigationForbidden: true },
{ id: "VLKA|2", text: "VD Kamýk", coords: [49.6380, 14.2580], maxVolume: 12.8, minLevel: 282.1, maxLevel: 284.6, storageLevel: 284.6, navigationForbidden: false },
{ id: "VLVE|2", text: "VD Vrané", coords: [49.9390, 14.3910], maxVolume: 11.1, minLevel: 199.1, maxLevel: 200.1, storageLevel: 200.1, navigationForbidden: false },
{ id: "BLHU|1", text: "VD Husinec", coords: [49.0270, 13.9870], maxVolume: 5.7, minLevel: 515.33, maxLevel: 529.88, storageLevel: 522.33, navigationForbidden: true },
{ id: "UHNY|3", text: "VD Nýrsko", coords: [49.2610, 13.1230], maxVolume: 16, minLevel: 501.2, maxLevel: 524.25, storageLevel: 521.55, navigationForbidden: true },
{ id: "KCKC|3", text: "VD Klíčava", coords: [50.0630, 13.9310], maxVolume: 9.3, minLevel: 267.6, maxLevel: 296.91, storageLevel: 293.7, navigationForbidden: true },
{ id: "KLKL|3", text: "VD Klabava", coords: [49.7540, 13.5640], maxVolume: 1.5, minLevel: 344.4, maxLevel: 351.1, storageLevel: 345.7, navigationForbidden: false },
{ id: "RACU|3", text: "VD České Údolí", coords: [49.7150, 13.3640], maxVolume: 5.5, minLevel: 310.6, maxLevel: 315.2, storageLevel: 313.6, navigationForbidden: false },
{ id: "TRTR|2", text: "VD Trnávka", coords: [49.5260, 15.1950], maxVolume: 5.6, minLevel: 412, maxLevel: 414.5, storageLevel: 413.2, navigationForbidden: false },
{ id: "HESE|2", text: "VD Sedlice", coords: [49.5070, 15.2630], maxVolume: 1.9, minLevel: 443.9, maxLevel: 448.64, storageLevel: 447.4, navigationForbidden: false },
{ id: "MZLU|3", text: "VD Lučina", coords: [49.8050, 12.6390], maxVolume: 2.3, minLevel: 523, maxLevel: 534.68, storageLevel: 532.1, navigationForbidden: true },
{ id: "STZL|3", text: "VD Žlutice", coords: [50.0930, 13.1360], maxVolume: 14.5, minLevel: 493.6, maxLevel: 509.72, storageLevel: 507.05, navigationForbidden: true },
{ id: "PPPI|3", text: "VD Pilská (u Příbramě)", coords: [49.6910, 13.9570], maxVolume: 1.6, minLevel: 661.7, maxLevel: 672.7, storageLevel: 671.4, navigationForbidden: true },
{ id: "LILA|3", text: "VD Láz", coords: [49.6640, 13.8820], maxVolume: 0.8, minLevel: 630, maxLevel: 642.15, storageLevel: 641.35, navigationForbidden: true },
{ id: "OPOB|3", text: "VD Obecnice", coords: [49.7110, 13.9370], maxVolume: 0.6, minLevel: 555.65, maxLevel: 565.87, storageLevel: 564.55, navigationForbidden: true },
{ id: "STST|2", text: "VD Strž", coords: [49.7910, 14.0040], maxVolume: 1, minLevel: 586.6, maxLevel: 589.2, storageLevel: 588.6, navigationForbidden: false },
{ id: "HEVR|2", text: "VD Vřesník", coords: [49.5070, 15.2440], maxVolume: 0.5, minLevel: 406.85, maxLevel: 409.08, storageLevel: 407.6, navigationForbidden: false },
{ id: "CRSO|1", text: "VD Soběnov", coords: [48.7750, 14.5360], maxVolume: 1.4, minLevel: 579.81, maxLevel: 583.26, storageLevel: 582.21, navigationForbidden: false },
{ id: "SCHU|1", text: "VD Humenice", coords: [48.7840, 14.7350], maxVolume: 0.8, minLevel: 531, maxLevel: 544, storageLevel: 536, navigationForbidden: false },
{ id: "SVSV|2", text: "VD Staviště", coords: [49.5750, 15.9520], maxVolume: 1.2, minLevel: 574.6, maxLevel: 581.6, storageLevel: 580.6, navigationForbidden: true },
{ id: "SAPI|2", text: "VD Pilská u Žďáru", coords: [49.5930, 15.9320], maxVolume: 1.5, minLevel: 571.8, maxLevel: 577.3, storageLevel: 576.6, navigationForbidden: false },
{ id: "SMSM|3", text: "VD Suchomasty", coords: [49.8970, 14.0580], maxVolume: 0.7, minLevel: 249.8, maxLevel: 260.9, storageLevel: 260.1, navigationForbidden: false },
{ id: "CPZA|3", text: "VD Záskalská", coords: [49.8050, 13.8510], maxVolume: 0.5, minLevel: 440.54, maxLevel: 449.39, storageLevel: 448.79, navigationForbidden: false },
{ id: "BIBI|1", text: "VD Bílsko", coords: [49.1670, 14.0410], maxVolume: 0.3, minLevel: 463.03, maxLevel: 471.6, storageLevel: 464.03, navigationForbidden: false },
{ id: "SPKA|1", text: "VD Karhof", coords: [48.9740, 14.5450], maxVolume: 0.3, minLevel: 666.8, maxLevel: 669.1, storageLevel: 668.4, navigationForbidden: false },
{ id: "SPNE|2", text: "VD Němčice", coords: [49.7710, 15.1760], maxVolume: 0.4, minLevel: 384.5, maxLevel: 386.4, storageLevel: 385, navigationForbidden: false },
{ id: "SPZH|1", text: "VD Zhejral", coords: [49.2310, 15.3120], maxVolume: 0.2, minLevel: 675.2, maxLevel: 679.7, storageLevel: 678.6, navigationForbidden: true },
{ id: "KLDP|3", text: "VD Dolejší Padrťský rybník", coords: [49.6640, 13.7530], maxVolume: 0.5, minLevel: 632.69, maxLevel: 634.29, storageLevel: 632.89, navigationForbidden: true },
{ id: "KLHP|3", text: "VD Hořejší Padrťský rybník", coords: [49.6550, 13.7610], maxVolume: 0.7, minLevel: 635.76, maxLevel: 637.56, storageLevel: 636.36, navigationForbidden: true },
{ id: "CPDR|3", text: "VD Dráteník", coords: [49.8050, 13.8550], maxVolume: 0.1, minLevel: 413.75, maxLevel: 417.91, storageLevel: 416.68, navigationForbidden: false }
];
+32
View File
@@ -0,0 +1,32 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function checkLake() {
const agent = new https.Agent({ rejectUnauthorized: false });
// Check Lipno 1
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?id=VLL1&oid=1`;
try {
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 hasData = false;
$('table').each((i, tbl) => {
const firstRowText = $(tbl).find('tr').first().text();
console.log(`Table ${i} first row:`, firstRowText.trim().replace(/\\s+/g, ' '));
if (firstRowText.includes('Datum a čas') || firstRowText.includes('Hladina')) {
hasData = true;
}
});
console.log(`hasData=${hasData}`);
} catch (err: any) {
console.error(err.message);
}
}
checkLake();
+52
View File
@@ -0,0 +1,52 @@
export interface LakeCalculationConfig {
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
}
export interface LakeMetrics {
capacity: number; // 0-100 percentage
volume: number; // in mil. m3
storageDiff: number; // in meters
}
export function calculateLakeMetrics(
currentLevel: number,
reportedVolume: number,
config: LakeCalculationConfig
): LakeMetrics {
let capacity = 0;
let volume = reportedVolume;
let storageDiff = 0;
// 1. Calculate capacity and volume
if (volume > 0 && config.maxVolume && config.maxVolume > 0) {
// If real volume is available, calculate capacity from volume
capacity = Math.max(0, Math.min(100, Math.round((volume / config.maxVolume) * 1000) / 10));
} else if (config.minLevel && config.maxLevel && currentLevel > 0) {
// Fallback: estimate capacity and volume from level difference
const percentage = ((currentLevel - config.minLevel) / (config.maxLevel - config.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) * (config.maxVolume || 0)).toFixed(1));
}
} else {
// Missing required config data or bad level
if (volume === 0) {
volume = config.maxVolume || 0;
}
}
// 2. Calculate storage difference
if (config.storageLevel && currentLevel > 0) {
storageDiff = Number((currentLevel - config.storageLevel).toFixed(2));
}
return {
capacity,
volume,
storageDiff
};
}
+36 -9
View File
@@ -1,25 +1,52 @@
import { execSync } from 'child_process';
const args = process.argv.slice(2);
const minutes = parseInt(args[0], 10) || 10;
const intervalMs = minutes * 60 * 1000;
// How many minutes after the 10-minute mark should we run the scraper?
// The basin authority (PVL) generates data at HH:00, HH:10, HH:20... but it takes time to publish.
// 5 minutes (HH:05, HH:15...) is a safe buffer to avoid fetching outdated data.
const offsetMinutes = 5;
console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`);
console.log(`Budu automaticky stahovat nová data každých ${minutes} minut.\n`);
console.log(`Budu automaticky stahovat nová data vždy v časech končících na ${offsetMinutes} (např. 10:05, 10:15, 10:25...).\nTo zajistí, že má Povodí dostatek času data vygenerovat a nahrát.\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`);
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ✅ Úspěšně hotovo.\n`);
} catch (error: any) {
console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message);
}
scheduleNextRun();
}
// Spustit ihned po zapnutí
runUpdate();
function scheduleNextRun() {
const now = new Date();
const currentMinute = now.getMinutes();
// Find the next target minute (ending in 5)
// E.g. if it's 12, next will be 15. If it's 26, next will be 35.
let nextMinute = Math.floor(currentMinute / 10) * 10 + offsetMinutes;
if (nextMinute <= currentMinute) {
nextMinute += 10;
}
const targetTime = new Date(now);
if (nextMinute >= 60) {
targetTime.setHours(targetTime.getHours() + 1);
targetTime.setMinutes(nextMinute % 60);
} else {
targetTime.setMinutes(nextMinute);
}
targetTime.setSeconds(0);
targetTime.setMilliseconds(0);
const waitMs = targetTime.getTime() - now.getTime();
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ⏳ Další stahování naplánováno na: ${targetTime.toLocaleTimeString('cs-CZ')} (za ${(waitMs / 60000).toFixed(1)} minut)\n`);
setTimeout(runUpdate, waitMs);
}
// A pak periodicky v zadaném intervalu
setInterval(runUpdate, intervalMs);
// Run update immediately on first launch and then set the timer
runUpdate();
+9 -4
View File
@@ -7,12 +7,12 @@
}
.sidebar {
width: 250px;
width: 190px;
background-color: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 1.5rem 1rem;
padding: 1.5rem 0.75rem;
transition: width 0.3s ease;
overflow: hidden;
}
@@ -75,8 +75,8 @@
.nav-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
gap: 0.75rem;
padding: 0.75rem 0.5rem;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.95rem;
@@ -86,6 +86,11 @@
white-space: nowrap;
}
.nav-item svg {
flex-shrink: 0;
min-width: 20px;
}
.nav-item:hover {
background-color: rgba(255, 255, 255, 0.03);
color: var(--text-main);
+46 -12
View File
@@ -1,28 +1,36 @@
import { useState, useEffect } from 'react';
import { Routes, Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom';
import { Routes, Route, useParams, Navigate } from 'react-router-dom';
import LakeDetail from './components/LakeDetail';
import LakesOverview from './components/LakesOverview';
import LakeMap from './components/LakeMap';
import FavoritesOverview from './components/FavoritesOverview';
import Sidebar from './components/Sidebar';
import Topbar from './components/Topbar';
import SettingsModal from './components/SettingsModal';
import { type Language } from './translations';
import { type Language, t } from './translations';
import { lakesConfig } from '../scripts/lakesConfig';
import { slugify } from './utils/slugify';
import './App.css';
const LakeDetailWrapper = ({ language }: { language: Language }) => {
const LakeDetailWrapper = ({ language, windUnit }: { language: Language, windUnit: 'kmh' | 'ms' }) => {
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} />;
return <LakeDetail language={language} lakeId={lake.id} windUnit={windUnit} />;
};
function App() {
const [language, setLanguage] = useState<Language>('en');
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [language, setLanguage] = useState<Language>(() => {
return (localStorage.getItem('hladinator_lang') as Language) || 'en';
});
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
return (localStorage.getItem('hladinator_theme') as 'dark' | 'light') || 'dark';
});
const [windUnit, setWindUnit] = useState<'kmh' | 'ms'>(() => {
return (localStorage.getItem('hladinator_windUnit') as 'kmh' | 'ms') || 'kmh';
});
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -32,6 +40,7 @@ function App() {
} else {
document.body.classList.remove('light-mode');
}
localStorage.setItem('hladinator_theme', theme);
// Clean up empty hash from URL (e.g. if the user clicked an empty anchor)
if (window.location.href.endsWith('#')) {
@@ -39,6 +48,14 @@ function App() {
}
}, [theme]);
useEffect(() => {
localStorage.setItem('hladinator_lang', language);
}, [language]);
useEffect(() => {
localStorage.setItem('hladinator_windUnit', windUnit);
}, [windUnit]);
return (
<div className="dashboard-container">
{/* Mobile overlay */}
@@ -56,13 +73,28 @@ function App() {
onCloseMobileMenu={() => setIsMobileMenuOpen(false)}
/>
<div className="main-content">
<div className="main-content" style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<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 style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Routes>
<Route path="/" element={<LakesOverview language={language} />} />
<Route path="/favorites" element={<FavoritesOverview language={language} />} />
<Route path="/map" element={<LakeMap language={language} />} />
<Route path="/:slug" element={<LakeDetailWrapper language={language} windUnit={windUnit} />} />
</Routes>
</div>
<footer style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1.5rem',
fontSize: '0.8rem',
color: 'var(--text-muted)',
marginTop: 'auto'
}}>
<span>{t[language].chart.dataSources} pvl.cz, open-meteo.com</span>
<span>{t[language].chart.createdIn}</span>
</footer>
</div>
{isSettingsOpen && (
@@ -71,6 +103,8 @@ function App() {
setLanguage={setLanguage}
theme={theme}
setTheme={setTheme}
windUnit={windUnit}
setWindUnit={setWindUnit}
onClose={() => setIsSettingsOpen(false)}
/>
)}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
interface Props {
value: number;
size?: number;
strokeWidth?: number;
}
export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidth = 6 }) => {
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.toFixed(1)}%` : 'N/A'}
</div>
</div>
);
};
+185
View File
@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
import { FiStar } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useFavorites } from '../hooks/useFavorites';
import { Helmet } from 'react-helmet-async';
import { CircularProgress } from './CircularProgress';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
interface Lake {
id: string;
name: string;
river: string;
priority: boolean;
level: number;
capacity: number;
storageDiff?: number;
inflow: number;
outflow: number;
volume: number;
maxVolume: number;
sparkline: number[];
}
interface Props {
language: Language;
}
const FavoritesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const { isFavorite, toggleFavorite } = useFavorites();
const navigate = useNavigate();
useEffect(() => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
}, []);
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<Helmet>
<title>{t[language].seo.favoritesTitle}</title>
<meta name="description" content={t[language].seo.favoritesDesc} />
<meta property="og:title" content={t[language].seo.favoritesTitle} />
<meta property="og:description" content={t[language].seo.favoritesDesc} />
</Helmet>
<div>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0', display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<FiStar size={24} fill="#f59e0b" color="#f59e0b" />
{language === 'cs' ? 'Oblíbená' : 'Favourites'}
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
{language === 'cs'
? 'Jezera připnutá v přehledu. Připnout nebo odepnout lze ikonou hvězdičky.'
: 'Lakes you pinned in the overview. Use the star icon to pin or unpin.'}
</p>
</div>
{favoriteLakes.length === 0 ? (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: '1rem', padding: '4rem 2rem', color: 'var(--text-muted)', textAlign: 'center'
}}>
<FiStar size={48} strokeWidth={1.2} color="var(--text-muted)" />
<p style={{ margin: 0, fontSize: '1.1rem' }}>
{language === 'cs' ? 'Zatím žádná oblíbená jezera.' : 'No favourites yet.'}
</p>
<p style={{ margin: 0, fontSize: '0.85rem' }}>
{language === 'cs'
? 'Přejdi na Jezera a nádrže a klikni na ⭐ u jezera, které tě zajímá.'
: 'Go to Lakes & Reservoirs and click the ⭐ on any lake to pin it here.'}
</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
{favoriteLakes.map(lake => {
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
const minVal = Math.min(...lake.sparkline);
const maxVal = Math.max(...lake.sparkline);
const diff = maxVal - minVal;
const padding = diff === 0 ? 0.1 : diff * 0.1;
const yDomain = [minVal - padding, maxVal + padding];
const firstVal = lake.sparkline[0] || 0;
const lastVal = lake.sparkline[lake.sparkline.length - 1] || 0;
const trendDiff = lastVal - firstVal;
let trendColor = 'var(--color-cyan)';
if (trendDiff > 0.01) trendColor = 'var(--color-green)';
else if (trendDiff < -0.01) trendColor = 'var(--color-red)';
return (
<div
key={lake.id}
className="kpi-card priority-lake-card"
onClick={() => navigate(`/${slugify(lake.name)}`)}
style={{ cursor: 'pointer', padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
>
{/* Unpin button */}
<button
onClick={(e) => { e.stopPropagation(); toggleFavorite(lake.id); }}
title={language === 'cs' ? 'Odepnout' : 'Unpin'}
style={{
position: 'absolute', top: '1rem', right: '1rem',
background: 'none', border: 'none', cursor: 'pointer',
color: '#f59e0b', transition: 'transform 0.15s',
padding: '4px', display: 'flex', alignItems: 'center', zIndex: 2,
}}
onMouseOver={(e) => { e.currentTarget.style.transform = 'scale(1.2)'; }}
onMouseOut={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
>
<FiStar size={18} fill="#f59e0b" />
</button>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>
{lake.name} {lake.river ? `- ${lake.river}` : ''}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t[language].kpi.level}</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', display: 'flex', alignItems: 'baseline', gap: '0.25rem', lineHeight: '1.1' }}>
{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>m n.m.</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.25rem' }}>
{lake.storageDiff !== undefined && (
<div style={{ fontSize: '1rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', whiteSpace: 'nowrap' }}>
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
</div>
)}
{lake.maxVolume > 0 && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{lake.volume.toFixed(1)} / {lake.maxVolume.toFixed(1)} mil. m³
</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={`colorSparkFav-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={trendColor} stopOpacity={0.8} />
<stop offset="95%" stopColor={trendColor} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={yDomain} hide />
<Area type="monotone" dataKey="value" stroke={trendColor} fillOpacity={1} fill={`url(#colorSparkFav-${lake.id})`} baseValue={yDomain[0]} />
</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)' }}>{t[language].kpi.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)' }}>{t[language].kpi.outflow} <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default FavoritesOverview;
+94 -49
View File
@@ -1,6 +1,7 @@
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useState, useEffect } from 'react';
import { CircularProgress } from './CircularProgress';
interface KpiData {
level: number;
@@ -12,6 +13,9 @@ interface KpiData {
volume: number;
fullness: number;
storageDiff?: number;
minDiff?: number;
avgInflow24h?: number;
avgOutflow24h?: number;
}
interface Props {
@@ -35,62 +39,90 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
}, [showTooltip]);
return (
<div className="kpi-grid-container">
{/* CARD 1: HLADINA */}
<>
{/* CARD 1: WATER LEVEL */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.level} {lakeName}
</div>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, marginBottom: '0.5rem' }}>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap' }}>
{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 style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', alignContent: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', borderRadius: '6px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>1D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff24h ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff24h ?? 0) > 0 ? '+' : ''}{((data.levelDiff24h ?? 0) * 100).toFixed(1)} cm
</span>
</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 style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', borderRadius: '6px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>7D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff7d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff7d ?? 0) > 0 ? '+' : ''}{((data.levelDiff7d ?? 0) * 100).toFixed(1)} cm
</span>
</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>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', borderRadius: '6px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>30D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff30d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm
</span>
</div>
</div>
</div>
{/* CARD 3: NAPLNĚNOST */}
{/* CARD 2: FLOW */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.flow}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-green)', marginRight: '6px', flexShrink: 0 }}></span>
{dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span>
</div>
{data.avgInflow24h !== undefined && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '14px', marginTop: '-2px' }}>
Ø 24h: {data.avgInflow24h.toFixed(1)} m³/s
</div>
)}
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-red)', marginRight: '2px', flexShrink: 0 }}></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>
{data.avgOutflow24h !== undefined && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '14px', marginTop: '-2px' }}>
Ø 24h: {data.avgOutflow24h.toFixed(1)} m³/s
</div>
)}
</div>
{/* Flow Circle */}
<div style={{
width: '70px',
height: '70px',
borderRadius: '50%',
border: `4px solid ${flowDiff >= 0 ? 'rgba(74, 222, 128, 0.2)' : 'rgba(248, 113, 113, 0.2)'}`,
borderTopColor: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)',
borderRightColor: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: 'rotate(-45deg)',
flexShrink: 0
}}>
<span style={{ transform: 'rotate(45deg)', color: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', textAlign: 'center', lineHeight: 1.2 }}>
<div style={{ fontSize: '0.8rem' }}>{flowDiff > 0 ? '+' : ''}{flowDiff.toFixed(1)}</div>
<div style={{ fontSize: '0.6rem', opacity: 0.8 }}>m³/s</div>
</span>
</div>
</div>
</div>
{/* CARD 3: CAPACITY */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
{dict.fullness}
<span
onClick={() => setShowTooltip(!showTooltip)}
@@ -123,14 +155,27 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, minWidth: 0, paddingRight: '0.5rem' }}>
<div style={{ fontSize: '1.7rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', whiteSpace: 'nowrap', 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)', whiteSpace: 'nowrap' }}>
{dict.volume}: {data.volume.toFixed(1)} <span style={{ fontSize: '0.7rem' }}>mil. m³</span>
</div>
{data.minDiff !== undefined && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
{language === 'cs' ? 'K minimu:' : 'To min:'} <span style={{ color: data.minDiff < 0.5 ? 'var(--color-red)' : 'var(--color-green)' }}>{data.minDiff.toFixed(2)} m</span>
</div>
)}
</div>
<div style={{ flexShrink: 0 }}>
<CircularProgress value={data.fullness} size={80} strokeWidth={8} />
</div>
</div>
</div>
</div>
</>
);
};
+166 -52
View File
@@ -1,7 +1,14 @@
import { useState, useEffect } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts';
import { ComposedChart, Area, Line, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
import { Helmet } from 'react-helmet-async';
import { type Language, t } from '../translations';
import KpiCards from './KpiCards';
import { WeatherWidget } from './WeatherWidget';
import { WindChart } from './WindChart';
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
import { lakesConfig } from '../../scripts/lakesConfig';
import { FiAlertCircle, FiStar } from 'react-icons/fi';
import { useFavorites } from '../hooks/useFavorites';
interface LipnoData {
timestamp: string;
@@ -18,11 +25,12 @@ interface LipnoData {
interface Props {
language: Language;
lakeId: string | null;
windUnit?: 'kmh' | 'ms';
}
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
if (active && payload && payload.length) {
const dict = t[language].chart;
const dict = t[language as 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)' }}>
@@ -31,7 +39,7 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
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>
{isTemp ? (language === 'cs' ? 'Teplota' : 'Temperature') : (language === 'cs' ? 'Srážky' : 'Precipitation')}: <span style={{ fontWeight: 'bold' }}>{entry.value !== undefined && entry.value !== null ? entry.value.toFixed(1) : 'N/A'} {isTemp ? '°C' : 'mm'}</span>
</p>
);
})}
@@ -51,8 +59,8 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
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 === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-red)'; }
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = 'var(--color-green)'; }
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)'; }
@@ -71,7 +79,7 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
return null;
};
const LakeDetail = ({ language, lakeId }: Props) => {
const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
const [data, setData] = useState<LipnoData[]>([]);
const [loading, setLoading] = useState(true);
const [lakeInfo, setLakeInfo] = useState<any>(null);
@@ -81,44 +89,50 @@ const LakeDetail = ({ language, lakeId }: Props) => {
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 loadData = () => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.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';
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;
fetch(`/data/${internalId}.json?t=${Date.now()}`)
.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
};
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 === null ? undefined : item.precipitation
};
});
setData(formattedData);
setLoading(false);
})
.catch(err => {
console.error('Failed to load data', err);
setLoading(false);
});
setData(formattedData);
setLoading(false);
})
.catch(err => {
console.error('Failed to load data', err);
setLoading(false);
});
};
loadData();
const intervalId = setInterval(loadData, 60 * 1000); // Poll every 1 minute
return () => clearInterval(intervalId);
}, [language, lakeId]);
if (loading) {
@@ -165,6 +179,9 @@ const LakeDetail = ({ language, lakeId }: Props) => {
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
const { isFavorite, toggleFavorite } = useFavorites();
const isFav = lakeId ? isFavorite(lakeId) : false;
let level24hAgo = latestData.level;
let level7dAgo = latestData.level;
let level30dAgo = latestData.level;
@@ -173,6 +190,10 @@ const LakeDetail = ({ language, lakeId }: Props) => {
let minDiff7d = Infinity;
let minDiff30d = Infinity;
let inflowSum24h = 0;
let outflowSum24h = 0;
let flowCount24h = 0;
for (const d of data) {
const t = new Date(d.timestamp).getTime();
@@ -193,11 +214,23 @@ const LakeDetail = ({ language, lakeId }: Props) => {
minDiff30d = diff30d;
level30dAgo = d.level;
}
if (t >= targetMs24h && d.inflow !== undefined && d.outflow !== undefined) {
inflowSum24h += d.inflow;
outflowSum24h += d.outflow;
flowCount24h++;
}
}
const levelDiff24h = latestData.level - level24hAgo;
const levelDiff7d = latestData.level - level7dAgo;
const levelDiff30d = latestData.level - level30dAgo;
const avgInflow24h = flowCount24h > 0 ? inflowSum24h / flowCount24h : undefined;
const avgOutflow24h = flowCount24h > 0 ? outflowSum24h / flowCount24h : undefined;
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const kpiData = {
level: latestData.level,
@@ -208,17 +241,89 @@ const LakeDetail = ({ language, lakeId }: Props) => {
outflow: lastValidFlowData.outflow,
volume: lakeInfo?.volume || 0,
fullness: lakeInfo?.capacity || 0,
storageDiff: lakeInfo?.storageDiff
storageDiff: lakeInfo?.storageDiff,
minDiff: staticConfig?.minLevel ? latestData.level - staticConfig.minLevel : undefined,
avgInflow24h,
avgOutflow24h
};
const leftYAxisDomain = [
(dataMin: number) => {
let min = dataMin;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
return min - 0.5;
},
(dataMax: number) => {
let max = dataMax;
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
return max + 0.5;
}
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
{lakeInfo && (
<>
<Helmet>
<title>{t[language].seo.lakeTitle.replace('{name}', lakeInfo.name)}</title>
<meta name="description" content={t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)} />
<meta property="og:title" content={t[language].seo.lakeTitle.replace('{name}', lakeInfo.name)} />
<meta property="og:description" content={t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)} />
</Helmet>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '0 0 0.5rem 0' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', margin: 0, color: 'var(--text-main)' }}>
{lakeInfo.name}
</h1>
<button
onClick={(e) => { e.preventDefault(); if (lakeId) toggleFavorite(lakeId); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', padding: '0.25rem' }}
title={isFav ? (language === 'cs' ? "Odebrat z oblíbených" : "Remove from favorites") : (language === 'cs' ? "Přidat do oblíbených" : "Add to favorites")}
>
<FiStar size={24} fill={isFav ? '#f59e0b' : 'none'} color={isFav ? '#f59e0b' : 'var(--text-muted)'} />
</button>
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)}
</p>
</div>
</>
)}
<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'} />
<div className="kpi-grid-container">
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} />
)}
</div>
{limits && limits.map((limit, idx) => {
const diff = latestData.level - limit.level;
if (diff < 0.3) {
const isBelow = diff < 0;
return (
<div key={idx} style={{ padding: '1rem', borderRadius: '8px', backgroundColor: isBelow ? 'rgba(248, 113, 113, 0.1)' : 'rgba(245, 158, 11, 0.1)', border: `1px solid ${isBelow ? 'var(--color-red)' : '#f59e0b'}`, color: isBelow ? 'var(--color-red)' : '#f59e0b', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiAlertCircle style={{ flexShrink: 0, fontSize: '1.5rem' }} />
<div>
<strong>{language === 'cs' ? limit.labelCs : limit.labelEn} ({limit.level.toFixed(2)} m n.m.):</strong>
<br/>
{isBelow
? (language === 'cs' ? `Hladina je ${Math.abs(diff).toFixed(2)} m POD limitem! Přerušení provozu.` : `Level is ${Math.abs(diff).toFixed(2)} m BELOW limit! Operations suspended.`)
: (language === 'cs' ? `Hladina se blíží k limitu (zbývá ${diff.toFixed(2)} m).` : `Level is approaching limit (${diff.toFixed(2)} m remaining).`)
}
</div>
</div>
);
}
return null;
})}
{/* CHART SECTION */}
<div className="chart-card">
@@ -243,16 +348,25 @@ const LakeDetail = ({ language, lakeId }: Props) => {
</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="left" domain={leftYAxisDomain as any} 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 */}
{limits && limits.map((limit, idx) => (
<ReferenceLine key={idx} yAxisId="left" y={limit.level} stroke={limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b'} strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `${limit.labelCs} (${limit.level.toFixed(2)} m n. m.)` : `${limit.labelEn} (${limit.level.toFixed(2)} m a.s.l.)`, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 12 }} />
))}
{staticConfig?.maxLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-orange)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)` : `Max retention level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`, fill: 'var(--color-orange)', fontSize: 12 }} />
)}
{staticConfig?.storageLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.storageLevel} stroke="#a855f7" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Hladina zásobního prostoru (${staticConfig.storageLevel.toFixed(2)} m n. m.)` : `Storage space level (${staticConfig.storageLevel.toFixed(2)} m a.s.l.)`, fill: '#a855f7', fontSize: 12 }} />
)}
<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} />
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-red)" strokeWidth={2} dot={false} isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -260,8 +374,8 @@ const LakeDetail = ({ language, lakeId }: Props) => {
{/* 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>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {dict.outflow}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
</div>
{/* WEATHER CHART SECTION */}
@@ -290,6 +404,11 @@ const LakeDetail = ({ language, lakeId }: Props) => {
<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>
{/* Wind Chart placed inside the main card below the weather graph */}
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WindChart lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} timeRange={timeRange} windUnit={windUnit} />
)}
{/* 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>
@@ -303,11 +422,6 @@ const LakeDetail = ({ language, lakeId }: Props) => {
</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>
);
};
+16 -8
View File
@@ -2,10 +2,11 @@ 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 { FiX, FiSearch } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { slugify } from '../utils/slugify';
import { useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
interface LakeData {
id: string;
@@ -61,6 +62,13 @@ const LakeMap = ({ language }: Props) => {
return (
<div className="map-view-container">
<Helmet>
<title>{t[language].seo.mapTitle}</title>
<meta name="description" content={t[language].seo.mapDesc} />
<meta property="og:title" content={t[language].seo.mapTitle} />
<meta property="og:description" content={t[language].seo.mapDesc} />
</Helmet>
{/* Leaflet Map */}
<MapContainer
center={[49.8, 15.5]}
@@ -95,18 +103,18 @@ const LakeMap = ({ language }: Props) => {
<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>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>{language === 'cs' ? 'Seznam jezer a nádrží' : 'Lakes and Reservoirs 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
{language === 'cs' ? `Nalezeno: ${filteredLakes.length} záznamů` : `Found: ${filteredLakes.length} records`}
</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..."
placeholder={language === 'cs' ? 'Najít jezero...' : 'Find a lake...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ width: '100%', background: 'transparent', border: 'none', color: 'white', outline: 'none' }}
@@ -117,14 +125,14 @@ const LakeMap = ({ language }: Props) => {
<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 style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>{index + 1}. {lake.name}</div>
<div className="map-lake-stats">
<div>
<span style={{ color: 'var(--text-muted)', display: 'block' }}>Area</span>
<span style={{ color: 'var(--text-muted)', display: 'block' }}>{language === 'cs' ? 'Rozloha' : '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-muted)', display: 'block' }}>{language === 'cs' ? 'Hloubka' : 'Depth'}</span>
<span style={{ color: 'var(--text-main)' }}>{(Math.random() * 30 + 5).toFixed(1)}m</span>
</div>
</div>
@@ -139,7 +147,7 @@ const LakeMap = ({ language }: Props) => {
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
{language === 'cs' ? 'Zobrazit seznam' : 'Show List'}
</button>
)}
</div>
+135 -113
View File
@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
import { Helmet } from 'react-helmet-async';
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { useFavorites } from '../hooks/useFavorites';
import { CircularProgress } from './CircularProgress';
interface Lake {
id: string;
@@ -12,9 +15,11 @@ interface Lake {
priority: boolean;
level: number;
capacity: number;
storageDiff?: number;
inflow: number;
outflow: number;
volume: number;
maxVolume: number;
sparkline: number[];
}
@@ -22,89 +27,91 @@ 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 LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
const minVal = Math.min(...lake.sparkline);
const maxVal = Math.max(...lake.sparkline);
const diff = maxVal - minVal;
const padding = diff === 0 ? 0.1 : diff * 0.1; // dynamic 10% padding
const yDomain = [minVal - padding, maxVal + padding];
const firstVal = lake.sparkline[0] || 0;
const lastVal = lake.sparkline[lake.sparkline.length - 1] || 0;
const trendDiff = lastVal - firstVal;
// Dynamic color based on trend direction: stable=cyan, rising=green, falling=red
let trendColor = 'var(--color-cyan)';
if (trendDiff > 0.01) trendColor = 'var(--color-green)';
else if (trendDiff < -0.01) trendColor = 'var(--color-red)';
return (
<div
className="kpi-card priority-lake-card"
<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>
{/* Star / Favorite button */}
<button
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
title={isFav ? (language === 'cs' ? 'Odepnout' : 'Unpin') : (language === 'cs' ? 'Připnout jako oblíbené' : 'Pin to favorites')}
style={{
position: 'absolute', top: '1rem', right: '1rem',
background: 'none', border: 'none', cursor: 'pointer',
color: isFav ? '#f59e0b' : 'var(--text-muted)',
opacity: isFav ? 1 : 0.4,
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
padding: '4px',
display: 'flex', alignItems: 'center',
zIndex: 2,
}}
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
>
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
</button>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Water level</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', display: 'flex', alignItems: 'baseline', gap: '0.25rem', lineHeight: '1.1' }}>
{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>m n.m.</span>
</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 style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.25rem' }}>
{lake.storageDiff !== undefined && (
<div style={{ fontSize: '1rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', whiteSpace: 'nowrap' }}>
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
</div>
)}
{lake.maxVolume > 0 && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{lake.volume.toFixed(1)} / {lake.maxVolume.toFixed(1)} mil. m³
</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 id={`colorSpark-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={trendColor} stopOpacity={0.8} />
<stop offset="95%" stopColor={trendColor} stopOpacity={0} />
</linearGradient>
</defs>
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" />
<YAxis domain={yDomain} hide />
<Area type="monotone" dataKey="value" stroke={trendColor} fillOpacity={1} fill={`url(#colorSpark-${lake.id})`} baseValue={yDomain[0]} />
</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)" />
@@ -122,71 +129,86 @@ const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
const LakesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => {
fetch('/data/lakes_index.json')
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
const loadData = () => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
};
loadData();
const intervalId = setInterval(loadData, 60 * 1000); // Poll every 1 minute
return () => clearInterval(intervalId);
}, []);
const priorityLakes = lakes.filter(l => l.priority);
const otherLakes = lakes.filter(l => !l.priority);
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
const priorityLakes = lakes.filter(l => l.priority && !isFavorite(l.id));
const otherLakes = lakes.filter(l => !l.priority && !isFavorite(l.id));
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'
});
otherLakes.sort((a, b) => a.name.localeCompare(b.name));
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>
<Helmet>
<title>{t[language].seo.homeTitle}</title>
<meta name="description" content={t[language].seo.homeDesc} />
<meta property="og:title" content={t[language].seo.homeTitle} />
<meta property="og:description" content={t[language].seo.homeDesc} />
</Helmet>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0', color: 'var(--text-main)' }}>{t[language].sidebar.lakes} ({lakes.length})</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{t[language].seo.homeDesc}
</p>
</div>
{priorityLakes.length > 0 && (
{/* Favorites section */}
{favoriteLakes.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'
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> {language === 'cs' ? 'Oblíbená' : 'Favorites'} ({favoriteLakes.length})
</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} />)}
{favoriteLakes.map(lake => (
<LakeCard key={lake.id} lake={lake} language={language} isFav={true} onToggleFav={toggleFavorite} />
))}
</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>
{priorityLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and 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} isFav={false} onToggleFav={toggleFavorite} />)}
</div>
</section>
)}
{otherLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', marginTop: '1rem' }}>{language === 'cs' ? 'Ostatní' : 'Other'}</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
</div>
</section>
)}
</div>
);
};
+66 -4
View File
@@ -1,4 +1,4 @@
import { FiX, FiMoon, FiSun, FiGlobe, FiCoffee } from 'react-icons/fi';
import { FiX, FiMoon, FiSun, FiCoffee, FiWind } from 'react-icons/fi';
import { type Language, t } from '../translations';
interface Props {
@@ -6,10 +6,12 @@ interface Props {
setLanguage: (lang: Language) => void;
theme: 'dark' | 'light';
setTheme: (theme: 'dark' | 'light') => void;
windUnit: 'kmh' | 'ms';
setWindUnit: (unit: 'kmh' | 'ms') => void;
onClose: () => void;
}
const SettingsModal = ({ language, setLanguage, theme, setTheme, onClose }: Props) => {
const SettingsModal = ({ language, setLanguage, theme, setTheme, windUnit, setWindUnit, onClose }: Props) => {
const dict = t[language].settings;
return (
@@ -96,7 +98,7 @@ const SettingsModal = ({ language, setLanguage, theme, setTheme, onClose }: Prop
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiGlobe /> {dict.english}
<span style={{ fontSize: '1.2rem', lineHeight: 1 }}>🇬🇧</span> {dict.english}
</button>
<button
onClick={() => setLanguage('cs')}
@@ -109,11 +111,71 @@ const SettingsModal = ({ language, setLanguage, theme, setTheme, onClose }: Prop
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiGlobe /> {dict.czech}
<span style={{ fontSize: '1.2rem', lineHeight: 1 }}>🇨🇿</span> {dict.czech}
</button>
</div>
</div>
{/* Wind Units Setting */}
<div style={{ marginBottom: '2rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.windUnits}
</label>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => setWindUnit('kmh')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: windUnit === 'kmh' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: windUnit === 'kmh' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: windUnit === 'kmh' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiWind /> {dict.windUnitKmh}
</button>
<button
onClick={() => setWindUnit('ms')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: windUnit === 'ms' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: windUnit === 'ms' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: windUnit === 'ms' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiWind /> {dict.windUnitMs}
</button>
</div>
</div>
{/* Contact */}
<div style={{ marginBottom: '2rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.contact}
</label>
<a
href="mailto:info@hladinator.cz"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
color: 'var(--color-cyan)',
fontSize: '0.95rem',
textDecoration: 'none',
fontWeight: 500,
opacity: 0.9,
transition: 'opacity 0.2s'
}}
onMouseOver={(e) => e.currentTarget.style.opacity = '1'}
onMouseOut={(e) => e.currentTarget.style.opacity = '0.9'}
>
info@hladinator.cz
</a>
</div>
{/* Buy me a coffee */}
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem', textAlign: 'center' }}>
<a
+42 -11
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { FiDroplet, FiStar, FiMap, FiSettings, FiMenu, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useFavorites } from '../hooks/useFavorites';
interface Props {
language: Language;
@@ -15,10 +16,11 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
const navigate = useNavigate();
const location = useLocation();
const dict = t[language].sidebar;
const { favorites } = useFavorites();
const isOverview = location.pathname === '/';
const isFavoritesPage = location.pathname === '/favorites';
const isMap = location.pathname === '/map';
const isDetail = !isOverview && !isMap;
const handleNavigate = (path: string) => {
navigate(path);
@@ -27,18 +29,19 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
return (
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="sidebar-logo" style={{ position: 'relative' }}>
<FiDroplet />
<div className="sidebar-logo">
<FiDroplet size={28} color="var(--color-cyan)" />
<div className="sidebar-text">
<span>HLADINATOR</span>
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.1rem' }}>HLADINATOR</span>
<small>v1.0</small>
</div>
{/* Toggle Button */}
</div>
{/* Toggle Button */}
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '1.5rem', marginTop: isCollapsed ? '1rem' : '-0.5rem' }}>
<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)'
@@ -49,14 +52,42 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
</div>
<div className="nav-links">
<div className={`nav-item ${isDetail ? 'active' : ''}`} onClick={() => handleNavigate('/lipno-1')}>
<FiStar />
{/* Favourites */}
<div className={`nav-item ${isFavoritesPage ? 'active' : ''}`} onClick={() => handleNavigate('/favorites')} style={{ position: 'relative' }}>
<div style={{ position: 'relative', display: 'flex' }}>
<FiStar fill={favorites.length > 0 ? '#f59e0b' : 'none'} color={favorites.length > 0 ? '#f59e0b' : 'currentColor'} />
{favorites.length > 0 && (
<span style={{
position: 'absolute',
top: '-8px',
right: '-12px',
backgroundColor: '#f59e0b',
color: '#000',
borderRadius: '999px',
fontSize: '0.65rem',
fontWeight: 'bold',
minWidth: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
border: '2px solid var(--bg-card)'
}}>
{favorites.length}
</span>
)}
</div>
<span className="sidebar-text">{dict.favorites}</span>
</div>
{/* Lakes & Reservoirs */}
<div className={`nav-item ${isOverview ? 'active' : ''}`} onClick={() => handleNavigate('/')}>
<FiMenu />
<FiDatabase />
<span className="sidebar-text">{dict.lakes}</span>
</div>
{/* Map */}
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
<FiMap />
<span className="sidebar-text">{dict.map}</span>
+9 -4
View File
@@ -1,5 +1,6 @@
import { FiSearch, FiMenu, FiDroplet } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useLocation } from 'react-router-dom';
interface Props {
language: Language;
@@ -8,6 +9,8 @@ interface Props {
const Topbar = ({ language, onToggleMobileMenu }: Props) => {
const dict = t[language].topbar;
const location = useLocation();
const showSearch = location.pathname === '/' || location.pathname === '/favorites';
return (
<div className="topbar">
@@ -19,10 +22,12 @@ const Topbar = ({ language, onToggleMobileMenu }: Props) => {
<span>Hladinator</span>
</div>
<div className="search-bar">
<FiSearch />
<input type="text" placeholder={dict.search} />
</div>
{showSearch && (
<div className="search-bar">
<FiSearch />
<input type="text" placeholder={dict.search} />
</div>
)}
</div>
</div>
);
+144
View File
@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import { FiWind, FiSunrise, FiSunset, FiThermometer, FiAlertCircle } from 'react-icons/fi';
interface WeatherProps {
lat: number;
lng: number;
language: 'cs' | 'en';
sensorTemp?: number;
windUnit?: 'kmh' | 'ms';
}
interface WeatherData {
temp: number;
windSpeed: number; // m/s
windGusts: number; // m/s
windDir: number; // degrees
sunrise: string;
sunset: string;
}
const getCompassDirection = (degrees: number, language: 'cs' | 'en') => {
const directionsEn = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const directionsCs = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const directions = language === 'cs' ? directionsCs : directionsEn;
const index = Math.round(((degrees %= 360) < 0 ? degrees + 360 : degrees) / 45) % 8;
return directions[index];
};
const formatTime = (isoString: string) => {
if (!isoString) return '--:--';
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh' }: WeatherProps) => {
const [data, setData] = useState<WeatherData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
if (!lat || !lng) {
setLoading(false);
setError(true);
return;
}
const fetchWeather = async () => {
try {
setLoading(true);
const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m&daily=sunrise,sunset&timezone=auto&wind_speed_unit=${windUnit}`);
if (!res.ok) throw new Error('Failed to fetch weather');
const json = await res.json();
setData({
temp: json.current.temperature_2m,
windSpeed: json.current.wind_speed_10m,
windGusts: json.current.wind_gusts_10m,
windDir: json.current.wind_direction_10m,
sunrise: json.daily.sunrise[0],
sunset: json.daily.sunset[0]
});
setError(false);
} catch (err) {
console.error('Weather fetch error:', err);
setError(true);
} finally {
setLoading(false);
}
};
fetchWeather();
// Refresh weather every 15 minutes
const interval = setInterval(fetchWeather, 15 * 60 * 1000);
return () => clearInterval(interval);
}, [lat, lng]);
const dict = {
cs: { title: 'Počasí a Vítr (Aktuálně)', error: 'Data nedostupná', wind: 'Vítr', gusts: 'Nárazy', temp: 'Teplota' },
en: { title: 'Weather & Wind (Current)', error: 'Data unavailable', wind: 'Wind', gusts: 'Gusts', temp: 'Temp' }
}[language];
if (loading) {
return <div className="kpi-card" style={{ minHeight: '120px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>Loading weather...</div>;
}
if (error || !data) {
return (
<div className="kpi-card" style={{ opacity: 0.7 }}>
<h3 style={{ fontSize: '1rem', color: 'var(--text-muted)', margin: '0 0 0.5rem 0' }}>{dict.title}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--color-red)' }}>
<FiAlertCircle /> {dict.error}
</div>
</div>
);
}
return (
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>{dict.title}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem', alignItems: 'center' }}>
{/* Left Column: Wind */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
<div style={{
width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'rgba(0, 195, 255, 0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--color-cyan)', fontSize: '1.2rem',
transform: `rotate(${data.windDir}deg)`
}} title={`Wind direction: ${data.windDir}°`}>
<FiWind style={{ transform: 'rotate(-90deg)' }} /> {/* Assume icon points UP by default, wind from south (180) should point UP. Arrow should point where wind is GOING. */}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', lineHeight: 1.1, color: 'var(--text-main)', whiteSpace: 'nowrap' }}>
{data.windSpeed.toFixed(1)} <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'} {getCompassDirection(data.windDir, language)}</span>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
{dict.gusts}: <span style={{ color: data.windGusts > (windUnit === 'kmh' ? 50 : 13.8) ? 'var(--color-red)' : 'var(--text-main)' }}>{data.windGusts.toFixed(1)} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div>
</div>
</div>
{/* Right Column: Other Info */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', borderLeft: '1px solid var(--border-color)', paddingLeft: '1rem', whiteSpace: 'nowrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem' }} title={sensorTemp !== undefined ? (language === 'cs' ? 'Měřeno přímo senzorem na hrázi' : 'Measured by sensor at the dam') : 'OpenMeteo API'}>
<FiThermometer color="var(--color-orange)" />
<span style={{ fontWeight: 'bold' }}>{sensorTemp !== undefined ? sensorTemp.toFixed(1) : data.temp.toFixed(1)} °C</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
<FiSunrise color="#f59e0b" />
<span>{formatTime(data.sunrise)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
<FiSunset color="#f59e0b" />
<span>{formatTime(data.sunset)}</span>
</div>
</div>
</div>
</div>
);
};
+273
View File
@@ -0,0 +1,273 @@
import { useState, useEffect } from 'react';
import { ComposedChart, Line, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FiWind } from 'react-icons/fi';
import { type Language } from '../translations';
interface WindChartProps {
lat: number;
lng: number;
language: Language;
timeRange?: '24h' | '7d' | '30d' | '1y' | 'all';
windUnit?: 'kmh' | 'ms';
}
interface WindDataPoint {
time: string;
speed: number;
gusts: number;
dir: number;
dirStr: string;
}
const getCompassDirection = (degrees: number, language: 'cs' | 'en') => {
const directionsEn = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const directionsCs = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const directions = language === 'cs' ? directionsCs : directionsEn;
const index = Math.round(((degrees %= 360) < 0 ? degrees + 360 : degrees) / 45) % 8;
return directions[index];
};
const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh' }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const date = new Date(label);
const dateStr = date.toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = date.toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' });
return (
<div style={{ backgroundColor: 'rgba(30, 41, 59, 0.95)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '12px', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.5)', color: 'var(--text-main)', fontSize: '0.9rem', zIndex: 100 }}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '4px' }}>
{dateStr} {timeStr}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: 'var(--color-cyan)', fontSize: '1.2rem' }}></span>
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}: <strong>{data.speed} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</strong></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: 'var(--color-purple)', fontSize: '1.2rem' }}></span>
<span>{language === 'cs' ? 'Nárazy větru' : 'Wind Gusts'}: <strong>{data.gusts} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</strong></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '4px', color: 'var(--text-muted)' }}>
<FiWind />
<span>{language === 'cs' ? 'Směr' : 'Direction'}: <strong>{data.dirStr} ({data.dir}°)</strong></span>
</div>
</div>
</div>
);
}
return null;
};
const CustomWindDot = (props: any) => {
const { cx, cy, payload } = props;
if (!cx || !cy || payload.dir === undefined) return null;
return (
<g transform={`translate(${cx},${cy}) rotate(${payload.dir}) scale(1.5)`}>
<path
d="M0,-6 L-4,4 L0,2 L4,4 Z"
fill="var(--color-cyan)"
stroke="#1e293b"
strokeWidth={1}
/>
</g>
);
};
export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'kmh' }: WindChartProps) => {
const [data, setData] = useState<WindDataPoint[]>([]);
const [loading, setLoading] = useState(true);
const [currentSpeed, setCurrentSpeed] = useState(0);
const [maxGust, setMaxGust] = useState(0);
useEffect(() => {
const fetchWind = async () => {
try {
setLoading(true);
let url = '';
let isDaily = false;
if (timeRange === '1y' || timeRange === 'all') {
isDaily = true;
const end = new Date();
end.setDate(end.getDate() - 1);
const endStr = end.toISOString().split('T')[0];
const start = new Date();
if (timeRange === '1y') {
start.setDate(start.getDate() - 365);
} else {
start.setFullYear(2020);
}
const startStr = start.toISOString().split('T')[0];
url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=wind_speed_10m_max,wind_gusts_10m_max,wind_direction_10m_dominant&wind_speed_unit=${windUnit}&timezone=auto`;
} else {
let pastDays = 7;
if (timeRange === '24h') pastDays = 1;
if (timeRange === '30d') pastDays = 30;
url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&hourly=wind_speed_10m,wind_gusts_10m,wind_direction_10m&past_days=${pastDays}&forecast_days=1&wind_speed_unit=${windUnit}&timezone=auto`;
}
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch wind data');
const json = await res.json();
const times = isDaily ? json.daily.time : json.hourly.time;
const speeds = isDaily ? json.daily.wind_speed_10m_max : json.hourly.wind_speed_10m;
const gusts = isDaily ? json.daily.wind_gusts_10m_max : json.hourly.wind_gusts_10m;
const dirs = isDaily ? json.daily.wind_direction_10m_dominant : json.hourly.wind_direction_10m;
const chartData: WindDataPoint[] = [];
let maxG = 0;
const now = new Date();
let closestIdx = 0;
let minDiff = Infinity;
for (let i = 0; i < times.length; i++) {
const t = new Date(times[i]);
const diff = Math.abs(t.getTime() - now.getTime());
if (diff < minDiff) {
minDiff = diff;
closestIdx = i;
}
if (t.getTime() <= now.getTime() || isDaily) {
if (gusts[i] > maxG) maxG = gusts[i];
chartData.push({
time: times[i],
speed: speeds[i] || 0,
gusts: gusts[i] || 0,
dir: dirs[i] || 0,
dirStr: getCompassDirection(dirs[i] || 0, language)
});
}
}
let downsampleFactor = 1;
if (timeRange === '7d') downsampleFactor = 3;
if (timeRange === '30d') downsampleFactor = 12;
if (timeRange === '1y') downsampleFactor = 3;
if (timeRange === 'all') downsampleFactor = 14;
const downsampled = chartData.filter((_, i) => i % downsampleFactor === 0 || i === chartData.length - 1);
setData(downsampled);
setMaxGust(maxG);
setCurrentSpeed(speeds[closestIdx] || speeds[speeds.length - 1] || 0);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (lat && lng) {
fetchWind();
}
}, [lat, lng, language, timeRange]);
if (loading) {
return (
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '200px', color: 'var(--text-muted)' }}>
{language === 'cs' ? 'Načítám data o větru...' : 'Loading wind data...'}
</div>
);
}
if (data.length === 0) return null;
return (
<div style={{ marginTop: '3rem', paddingTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiWind style={{ color: 'var(--color-cyan)' }} />
{language === 'cs' ? `Aktivita větru (${timeRange === '1y' || timeRange === 'all' ? 'denní maxima' : timeRange})` : `Wind Activity (${timeRange === '1y' || timeRange === 'all' ? 'daily max' : timeRange})`}
</h3>
<div style={{ display: 'flex', gap: '1.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{language === 'cs' ? 'Aktuální rychlost' : 'Current Speed'}</span>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.25rem' }}>
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{currentSpeed.toFixed(1)}</span>
<span style={{ fontSize: '0.9rem', color: 'var(--color-cyan)' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{language === 'cs' ? 'Max. nárazy' : 'Peak Gusts'}</span>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.25rem' }}>
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{maxGust.toFixed(1)}</span>
<span style={{ fontSize: '0.9rem', color: 'var(--color-purple)' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div>
</div>
</div>
</div>
<div style={{ flex: 1, minHeight: '280px', width: '100%', marginTop: '0.5rem' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorWind" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.4}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis
dataKey="time"
stroke="var(--text-muted)"
tick={{fill: 'var(--text-muted)', fontSize: 11}}
minTickGap={60}
tickFormatter={(v) => {
const d = new Date(v);
return `${d.getDate()}.${d.getMonth()+1}.`;
}}
/>
<YAxis
stroke="var(--text-muted)"
tick={{fill: 'var(--text-muted)', fontSize: 11}}
/>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomWindTooltip language={language} windUnit={windUnit} />} />
<Area
type="monotone"
dataKey="speed"
stroke="var(--color-cyan)"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorWind)"
isAnimationActive={true}
dot={<CustomWindDot />}
activeDot={{ r: 6, fill: 'var(--color-cyan)', stroke: '#1e293b', strokeWidth: 2 }}
/>
<Line
type="monotone"
dataKey="gusts"
stroke="var(--color-purple)"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
isAnimationActive={true}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem', fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ display: 'inline-block', width: '12px', height: '3px', backgroundColor: 'var(--color-cyan)' }}></span>
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ display: 'inline-block', width: '12px', height: '3px', borderTop: '2px dashed var(--color-purple)' }}></span>
<span>{language === 'cs' ? 'Nárazy větru' : 'Wind Gusts'}</span>
</div>
</div>
</div>
);
};
+5 -4
View File
@@ -13,9 +13,8 @@ describe('KpiCards Component', () => {
};
it('renders correctly with negative storageDiff (red)', () => {
const { container } = render(<KpiCards data={mockData} language="cs" />);
// ZÁSOBNÍ PROSTOR card should show -1.81 m
render(<KpiCards data={mockData} language="cs" />);
// STORAGE SPACE 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
@@ -36,6 +35,8 @@ describe('KpiCards Component', () => {
const noDiffData = { ...mockData, storageDiff: 0, fullness: 85.5 };
render(<KpiCards data={noDiffData} language="cs" />);
expect(screen.getByText('85.5%')).toBeInTheDocument();
const elements = screen.getAllByText('85.5%');
expect(elements.length).toBeGreaterThan(0);
expect(elements[0]).toBeInTheDocument();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
const STORAGE_KEY = 'hladinator_favorites';
const loadFavorites = (): string[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
interface FavoritesContextType {
favorites: string[];
toggleFavorite: (id: string) => void;
isFavorite: (id: string) => boolean;
}
const FavoritesContext = createContext<FavoritesContextType>({
favorites: [],
toggleFavorite: () => {},
isFavorite: () => false,
});
export const FavoritesProvider = ({ children }: { children: ReactNode }) => {
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
const toggleFavorite = useCallback((id: string) => {
setFavorites(prev => {
const next = prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id];
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return next;
});
}, []);
const isFavorite = useCallback((id: string) => favorites.includes(id), [favorites]);
return (
<FavoritesContext.Provider value={{ favorites, toggleFavorite, isFavorite }}>
{children}
</FavoritesContext.Provider>
);
};
export const useFavorites = () => useContext(FavoritesContext);
+12 -5
View File
@@ -6,14 +6,15 @@
--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 */
--color-cyan: #06b6d4; /* Water level / Primary */
--color-green: #22c55e; /* Inflow / Positive trend */
--color-red: #ef4444; /* Outflow / Negative trend */
--color-orange: #f97316; /* Outflow line chart color */
--color-purple: #a855f7; /* Wind gusts line color */
.kpi-grid-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
width: 100%;
}
@@ -161,6 +162,12 @@
border-top: 8px solid white;
}
@media (max-width: 1024px) {
.kpi-grid-container {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.map-overlay-panel {
top: auto;
+9 -3
View File
@@ -1,13 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { FavoritesProvider } from './hooks/useFavorites'
import { HelmetProvider } from 'react-helmet-async'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<HelmetProvider>
<BrowserRouter>
<FavoritesProvider>
<App />
</FavoritesProvider>
</BrowserRouter>
</HelmetProvider>
</StrictMode>,
)
+32 -2
View File
@@ -4,7 +4,7 @@ export const t = {
en: {
sidebar: {
favorites: 'Favorites',
lakes: 'Lakes',
lakes: 'Lakes & Reservoirs',
map: 'Map',
settings: 'Settings'
},
@@ -12,6 +12,16 @@ export const t = {
search: 'Search river or reservoir (e.g. Lipno)...',
updated: 'Last updated:'
},
seo: {
homeTitle: 'Hladinátor - Water levels and flow rates of reservoirs',
homeDesc: 'Track current water levels, flow rates, inflow, and weather development on major Czech dams and reservoirs in real time. Data sourced from official river basin authorities.',
lakeTitle: '{name} - Water level and flow | Hladinátor',
lakeDesc: 'Current water level and statistics for the {name} reservoir. Track water level, flow rate, wind strength, and storage capacity in real time.',
favoritesTitle: 'Favorites | Hladinátor',
favoritesDesc: 'Your pinned lakes and reservoirs. Track water level, flow rate, and weather development on major Czech dams and reservoirs.',
mapTitle: 'Map | Hladinátor',
mapDesc: 'Interactive map of all monitored lakes and reservoirs in the Czech Republic.'
},
kpi: {
level: 'WATER LEVEL',
flow: 'FLOW RATE',
@@ -46,13 +56,18 @@ export const t = {
language: 'Language',
english: 'English',
czech: 'Čeština',
windUnits: 'Wind units',
windUnitKmh: 'km/h',
windUnitMs: 'm/s',
contact: 'Contact',
contactPlaceholder: 'Your email address',
buyCoffee: 'Buy Me a Coffee'
}
},
cs: {
sidebar: {
favorites: 'Oblíbené',
lakes: 'Jezera',
lakes: 'Jezera a nádrže',
map: 'Mapa',
settings: 'Nastavení'
},
@@ -60,6 +75,16 @@ export const t = {
search: 'Hledat tok nebo nádrž (např. Lipno)...',
updated: 'Aktualizováno:'
},
seo: {
homeTitle: 'Hladinátor - Aktuální stav přehrad a nádrží',
homeDesc: 'Sledujte aktuální vodní stav, průtok, přítok a vývoj počasí na nejvýznamnějších českých přehradách v reálném čase. Oficiální data z povodí.',
lakeTitle: '{name} - Stav hladiny a průtok | Hladinátor',
lakeDesc: 'Aktuální vodní stav a statistiky pro vodní dílo {name}. Sledujte vývoj hladiny, sílu větru a kapacitu zásobního prostoru v reálném čase.',
favoritesTitle: 'Oblíbené | Hladinátor',
favoritesDesc: 'Vaše připnuté přehrady a nádrže. Sledujte aktuální vodní stav, průtok a vývoj počasí na vybraných českých přehradách.',
mapTitle: 'Mapa | Hladinátor',
mapDesc: 'Interaktivní mapa všech sledovaných přehrad a nádrží v České republice.'
},
kpi: {
level: 'HLADINA',
flow: 'PRŮTOK',
@@ -94,6 +119,11 @@ export const t = {
language: 'Jazyk',
english: 'English',
czech: 'Čeština',
windUnits: 'Jednotky větru',
windUnitKmh: 'km/h',
windUnitMs: 'm/s',
contact: 'Kontakt',
contactPlaceholder: 'Vaše e-mailová adresa',
buyCoffee: 'Kup mi kávu'
}
}
+90
View File
@@ -0,0 +1,90 @@
export interface NavigationLimit {
level: number;
labelCs: string;
labelEn: string;
type: 'danger' | 'warning';
}
export const NAVIGATION_LIMITS: Record<string, NavigationLimit[]> = {
// Orlík
'VLOR|2': [
{
level: 342.50,
labelCs: 'Minimální hladina pro lodní výtah Orlík',
labelEn: 'Minimum level for Orlík boat lift',
type: 'danger'
}
],
// Slapy
'VLSL|2': [
{
level: 266.50,
labelCs: 'Minimální hladina pro převoz lodí Slapy',
labelEn: 'Minimum level for Slapy boat transport',
type: 'danger'
}
],
// Lipno 1
'VLL1|1': [
{
level: 719.60,
labelCs: 'Ukončení značení plavební dráhy',
labelEn: 'End of navigation channel marking',
type: 'danger'
}
],
// Hněvkovice
'VLHN|1': [
{
level: 368.90,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Hracholusky
'MZHR|3': [
{
level: 351.10,
labelCs: 'Zkrácení zaručené plavební dráhy',
labelEn: 'Shortened guaranteed navigation channel',
type: 'warning'
}
],
// Kamýk
'VLKA|2': [
{
level: 283.60,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Vrané
'VLVE|2': [
{
level: 199.30,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Štěchovice
'VLST|2': [
{
level: 217.20,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Kořensko
'VLKO|1': [
{
level: 352.00,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
]
};

Some files were not shown because too many files have changed in this diff Show More