Add water temperature production workspace

This commit is contained in:
b0txec
2026-08-19 22:01:42 +03:00
parent 33409e8947
commit 1868041aac
18 changed files with 307 additions and 38 deletions
+3 -1
View File
@@ -12,6 +12,7 @@ import { Harmonie } from './pages/harmonie/Harmonie'
import { LvgmcForecast } from './pages/lvgmc-forecast/LvgmcForecast'
import { Home } from './pages/home/Home'
import { Faktiska } from './pages/map-graphics/MapGraphics'
import { WaterTemperature } from './pages/water-temperature/WaterTemperature'
console.log("env:", import.meta.env.MODE)
console.log("api host:", apiHost)
@@ -25,6 +26,7 @@ const App: Component = () => {
<Route path="/station" component={Station} />
<Route path="/cities" component={Cities} />
<Route path="/faktiska" component={Faktiska} />
<Route path="/udens-temperatura" component={WaterTemperature} />
<Route path="/latvia" component={Country} />
<Route path="/database" component={Database} />
</Router>
@@ -34,7 +36,7 @@ const App: Component = () => {
const AppLayout: ParentComponent = (props) => {
const [isScrolled, setIsScrolled] = createSignal(false)
const pages = [
['/station', 'Stations'], ['/cities', 'City analysis'], ['/faktiska', 'Faktiskā'], ['/latvia', 'Latvia overview'],
['/station', 'Stations'], ['/cities', 'City analysis'], ['/faktiska', 'Faktiskā'], ['/udens-temperatura', 'Ūdens temperatūra'], ['/latvia', 'Latvia overview'],
['/database', 'Data archive'], ['/harmonie', 'HARMONIE'], ['/lvgmc-forecast', 'LVĢMC forecast'],
]
onMount(() => {
+9 -9
View File
@@ -28,7 +28,7 @@ export function drawOnMap(
const numericValue = roundValues ? Math.round(value) : value.toFixed(1);
const stringValue = numericValue.toString().replace(".", ",");
ctx.font = `bold ${props.fontSize}px Rubik`;
ctx.font = `bold ${props.fontSize}px Monda`;
const badgeWidth = getBadgeWidth(ctx, stringValue, props);
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(localX - badgeWidth / 2, localY - props.boxSize / 2, badgeWidth, props.boxSize);
@@ -39,7 +39,7 @@ export function drawOnMap(
const localX = props.offsetX + x * props.scale;
const localY = props.offsetY + y * props.scale;
ctx.font = `bold ${props.fontSize}px Rubik`;
ctx.font = `bold ${props.fontSize}px Monda`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#000000";
@@ -75,7 +75,7 @@ export function drawOnMap(
if (!props.showWind) return;
// wind values
ctx.font = `bold ${props.windFontSize}px Rubik`;
ctx.font = `bold ${props.windFontSize}px Monda`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillStyle = "#FFFFFF";
@@ -88,18 +88,18 @@ export function drawOnMap(
const speedWidth = ctx.measureText(windSpeed).width;
ctx.fillText(windSpeed, windProp.offsetX + boxMiddleX - speedWidth / 2 - 30, (windProp.offsetY + 575)*windProp.scaleY);
ctx.font = "bold 25px Rubik";
ctx.font = "bold 25px Monda";
ctx.fillText("M/S", windProp.offsetX + boxMiddleX + speedWidth / 2 - 22, (windProp.offsetY + 590)*windProp.scaleY);
ctx.font = `bold ${props.windFontSize}px Rubik`;
ctx.font = `bold ${props.windFontSize}px Monda`;
const gustsWidth = ctx.measureText(windGusts).width;
ctx.fillText(windGusts, windProp.offsetX + boxMiddleX - gustsWidth / 2 - 30, (windProp.offsetY + 790)*windProp.scaleY);
ctx.font = "bold 25px Rubik";
ctx.font = "bold 25px Monda";
ctx.fillText("M/S", windProp.offsetX + boxMiddleX + gustsWidth / 2 - 22, (windProp.offsetY + 805)*windProp.scaleY);
}
function getBadgeWidth(ctx: CanvasRenderingContext2D, value: string, props: ResolutionPropsValue, restoreValueFont = false): number {
if (restoreValueFont) ctx.font = `bold ${props.fontSize}px Rubik`;
if (restoreValueFont) ctx.font = `bold ${props.fontSize}px Monda`;
const width = Math.max(props.boxSize, Math.ceil(ctx.measureText(value).width + props.fontSize * 0.8));
if (restoreValueFont) ctx.font = `normal ${props.fontSize * 4.2}px Daira by LTV grafika`;
return width;
@@ -126,7 +126,7 @@ function drawTitleOverlay(ctx: CanvasRenderingContext2D, title: string, source:
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = `bold ${30 * scale}px Rubik`;
ctx.font = `bold ${30 * scale}px Monda`;
const titleText = title.trim().toUpperCase();
const titleWidth = Math.max(360 * scale, ctx.measureText(titleText).width + horizontalPadding * 2);
const titleX = canvasWidth - right - titleWidth;
@@ -137,7 +137,7 @@ function drawTitleOverlay(ctx: CanvasRenderingContext2D, title: string, source:
ctx.fillText(titleText, titleX + horizontalPadding, top + titleHeight / 2);
if (source.trim()) {
ctx.font = `bold ${19 * scale}px Rubik`;
ctx.font = `bold ${19 * scale}px Monda`;
const sourceText = source.trim().toUpperCase();
const sourceWidth = Math.max(130 * scale, ctx.measureText(sourceText).width + horizontalPadding * 2);
const sourceX = canvasWidth - right - sourceWidth;
+5 -5
View File
@@ -68,7 +68,7 @@ function drawTable(
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 30px Rubik'
ctx.font = 'bold 30px Monda'
ctx.fillStyle = '#000000'
ctx.letterSpacing = '10px'
ctx.fillText(title, rigaBg.width/2, rigaBg.height*0.065)
@@ -78,18 +78,18 @@ function drawTable(
data.forEach((weather, i) => {
const midX = colWidth * (i + 0.5)
ctx.textAlign = 'center'
ctx.font = 'bold 20px Rubik'
ctx.font = 'bold 20px Monda'
ctx.fillStyle = '#FFFFFF'
ctx.fillText(weather.hour, midX, rigaBg.height * 0.2)
ctx.font = `normal 240px Daira by LTV grafika`
ctx.fillText(weather.icon, midX, rigaBg.height * 0.5)
ctx.font = 'normal 50px Rubik'
ctx.font = 'normal 50px Monda'
ctx.fillText(weather.temperature, midX, rigaBg.height * 0.75)
const bottomLineY = rigaBg.height * 0.9
ctx.font = 'bold 30px Rubik'
ctx.font = 'bold 30px Monda'
ctx.textAlign = 'center'
ctx.fillText(weather.windDirection, midX, bottomLineY)
const dirWidth = ctx.measureText(weather.windDirection).width
@@ -109,4 +109,4 @@ const weekDaysLV = new Map([
[4, 'ceturtdiena'],
[5, 'piektdiena'],
[6, 'sestdiena'],
])
])
+4 -4
View File
@@ -74,7 +74,7 @@ function drawTable(
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 30px Rubik'
ctx.font = 'bold 30px Monda'
ctx.fillStyle = '#000000'
ctx.letterSpacing = '10px'
ctx.fillText(title, rigaBg.width/2, rigaBg.height*0.065)
@@ -89,11 +89,11 @@ function drawTable(
ctx.font = `normal 240px Daira by LTV grafika`
ctx.fillText(weather.icon, midX, rigaBg.height * 0.4)
ctx.font = 'normal 50px Rubik'
ctx.font = 'normal 50px Monda'
ctx.fillText(weather.temperature, midX, rigaBg.height * 0.7)
const bottomLineY = rigaBg.height * 0.85
ctx.font = 'bold 30px Rubik'
ctx.font = 'bold 30px Monda'
ctx.textAlign = 'center'
ctx.fillText(weather.windDirection, midX, bottomLineY)
const dirWidth = ctx.measureText(weather.windDirection).width
@@ -113,4 +113,4 @@ const weekDaysLV = new Map([
[4, 'ceturtdiena'],
[5, 'piektdiena'],
[6, 'sestdiena'],
])
])
+15 -7
View File
@@ -2,14 +2,14 @@
*{box-sizing:border-box}
body {
margin: 0;
font-family: Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
font-family: 'Monda',ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
color:var(--text);background-color:#edf3fb;background-image:radial-gradient(circle at 8% 4%,rgba(95,157,230,.14),transparent 30%),radial-gradient(circle at 92% 18%,rgba(99,196,218,.12),transparent 28%),linear-gradient(145deg,#f4f8fd 0%,#edf4fc 48%,#eaf1fb 100%);background-attachment:fixed;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4 {
font-family: 'Rubik', sans-serif;
font-family: 'Monda', sans-serif;
}
code {
@@ -27,9 +27,17 @@ thead {
}
@font-face {
font-family: 'Rubik';
src: url('../assets/Rubik-Medium.ttf') format('truetype');
font-weight: normal;
font-family: 'Monda';
src: url('../../../Monda/Monda-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Monda';
src: url('../../../Monda/Monda-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@@ -37,14 +45,14 @@ thead {
.primary,.secondary,button {
min-height:38px;background-color:var(--surface-strong);border:1px solid transparent;border-radius:9px;padding:8px 14px;
cursor: pointer;
font-family: 'Rubik', sans-serif;
font-family: 'Monda', sans-serif;
}
.primary{background:var(--accent);color:#fff}.primary:hover{background:var(--accent-strong)}
button,input[type=button]{transition:transform 260ms cubic-bezier(.23,1,.32,1),box-shadow 260ms,background-color 180ms,border-color 180ms;touch-action:manipulation;will-change:transform}button:not(:disabled):hover,input[type=button]:not(:disabled):hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(26,74,128,.16)}button:not(:disabled):active,input[type=button]:not(:disabled):active{transform:translateY(0);box-shadow:none}
input[type=checkbox]{--checkbox-size:18px;appearance:none;width:var(--checkbox-size);height:var(--checkbox-size);margin:0 6px 0 0;vertical-align:-3px;border:1px solid #aebed1;border-radius:5px;background:#fff;cursor:pointer;position:relative;transition:all .25s}input[type=checkbox]:hover{border-color:var(--accent)}input[type=checkbox]:checked{border-color:transparent;background:linear-gradient(145deg,#3388d7,#1763aa);box-shadow:0 0 0 3px rgba(36,119,197,.13)}input[type=checkbox]:checked:before{content:"";position:absolute;top:45%;left:50%;width:4px;height:8px;border-right:2px solid #fff;border-bottom:2px solid #fff;transform:translate(-50%,-50%) rotate(45deg) scale(1);animation:check-pop .22s cubic-bezier(.12,.4,.29,1.46)}input[type=checkbox]:focus-visible{outline:3px solid var(--accent-soft);outline-offset:2px}@keyframes check-pop{from{opacity:0;transform:translate(-50%,-50%) rotate(45deg) scale(0)}to{opacity:1;transform:translate(-50%,-50%) rotate(45deg) scale(1)}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
input[type=text],input[type=date],input[type=datetime-local],select{min-height:38px;padding:7px 10px;border:1px solid #cfd7e2;border-radius:8px;background:#fff}button:disabled,input:disabled{cursor:not-allowed;opacity:.48}.appContent{min-height:calc(100vh - 73px)}
input[type=text],input[type=number],input[type=date],input[type=datetime-local],select{min-height:38px;padding:7px 10px;border:1px solid #cfd7e2;border-radius:8px;background:#fff;font-family:inherit}button:disabled,input:disabled{cursor:not-allowed;opacity:.48}.appContent{min-height:calc(100vh - 73px)}
table td {
padding: 3px 7px;
+1 -1
View File
@@ -1,5 +1,5 @@
.header{position:sticky;top:0;z-index:100;display:flex;align-items:center;justify-content:space-between;gap:34px;min-height:88px;padding:0 clamp(24px,3vw,58px);border-bottom:1px solid rgba(99,145,202,.22);background:linear-gradient(110deg,rgba(249,252,255,.9),rgba(224,238,255,.84),rgba(239,248,255,.9));box-shadow:0 8px 28px rgba(41,92,151,.04);backdrop-filter:blur(20px) saturate(125%);transition:min-height 280ms cubic-bezier(.23,1,.32,1),background 280ms,box-shadow 280ms,border-color 280ms}
.header.scrolled{min-height:58px;border-color:rgba(79,127,185,.3);background:linear-gradient(110deg,rgba(246,251,255,.82),rgba(218,234,253,.78),rgba(235,246,255,.84));box-shadow:0 8px 26px rgba(31,77,132,.12);backdrop-filter:blur(24px) saturate(140%)}
.brand{display:flex;align-items:center;gap:14px;color:var(--text);text-decoration:none;transition:gap 280ms,opacity 280ms}.brand:hover{opacity:.88}.brandMark{display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:linear-gradient(145deg,#4499e6,#15579b);color:#fff;font-family:Rubik,sans-serif;font-size:23px;box-shadow:0 9px 22px rgba(26,91,157,.28),0 0 0 5px rgba(52,137,216,.08);transition:width 280ms,height 280ms,border-radius 280ms,font-size 280ms,box-shadow 280ms}.brand strong,.brand small{display:block;white-space:nowrap}.brand strong{font-size:18px;letter-spacing:-.01em;transition:font-size 280ms}.brand small{max-height:18px;margin-top:3px;color:var(--text-muted);font-size:12px;opacity:1;overflow:hidden;transition:max-height 240ms,margin 240ms,opacity 180ms}.scrolled .brand{gap:10px}.scrolled .brandMark{width:36px;height:36px;border-radius:10px;font-size:18px;box-shadow:0 5px 13px rgba(26,91,157,.22)}.scrolled .brand strong{font-size:15px}.scrolled .brand small{max-height:0;margin-top:0;opacity:0}
.brand{display:flex;align-items:center;gap:14px;color:var(--text);text-decoration:none;transition:gap 280ms,opacity 280ms}.brand:hover{opacity:.88}.brandMark{display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:linear-gradient(145deg,#4499e6,#15579b);color:#fff;font-family:Monda,sans-serif;font-size:23px;box-shadow:0 9px 22px rgba(26,91,157,.28),0 0 0 5px rgba(52,137,216,.08);transition:width 280ms,height 280ms,border-radius 280ms,font-size 280ms,box-shadow 280ms}.brand strong,.brand small{display:block;white-space:nowrap}.brand strong{font-size:18px;letter-spacing:-.01em;transition:font-size 280ms}.brand small{max-height:18px;margin-top:3px;color:var(--text-muted);font-size:12px;opacity:1;overflow:hidden;transition:max-height 240ms,margin 240ms,opacity 180ms}.scrolled .brand{gap:10px}.scrolled .brandMark{width:36px;height:36px;border-radius:10px;font-size:18px;box-shadow:0 5px 13px rgba(26,91,157,.22)}.scrolled .brand strong{font-size:15px}.scrolled .brand small{max-height:0;margin-top:0;opacity:0}
.menu{display:flex;align-items:center;gap:5px;margin:0;padding:0;list-style:none}.menu li{margin:0;padding:0}.menu a{position:relative;display:block;padding:11px 13px;border-radius:10px;color:var(--text-muted);font-size:14px;font-weight:700;text-decoration:none;transition:padding 280ms,font-size 280ms,color 180ms,background 180ms,transform 180ms}.menu a:after{content:"";position:absolute;right:13px;bottom:5px;left:13px;height:2px;border-radius:2px;background:var(--accent);opacity:0;transform:scaleX(.35);transition:opacity 180ms,transform 220ms}.menu a:hover{background:rgba(255,255,255,.5);color:var(--text);transform:translateY(-1px)}.menu a.active{background:rgba(224,239,255,.78);color:var(--accent-strong)}.menu a.active:after{opacity:1;transform:scaleX(1)}.scrolled .menu a{padding:8px 11px;font-size:12.5px}.scrolled .menu a:after{right:11px;bottom:3px;left:11px}
@media(max-width:1120px){.header{align-items:flex-start;flex-direction:column;gap:10px;padding:14px 20px}.header.scrolled{min-height:auto;padding-block:9px}.menu{max-width:100%;overflow-x:auto}.menu a{white-space:nowrap}.brand small,.scrolled .brand small{max-height:0;margin:0;opacity:0}}
+2 -1
View File
@@ -1,11 +1,12 @@
import { A } from '@solidjs/router'
import { Archive, BarChart3, CloudSun, Database, Map, RadioTower, Wind } from 'lucide-solid'
import { Archive, BarChart3, CloudSun, Database, Map, RadioTower, Waves, Wind } from 'lucide-solid'
import './home.css'
const tools = [
{path:'/station', title:'Stations', description:'Recent measurements and station charts.', icon:RadioTower},
{path:'/cities', title:'City analysis', description:'Compare values and prepare temperature maps.', icon:BarChart3},
{path:'/faktiska', title:'Faktiskā', description:'Temperatures and weather symbols for daily production.', icon:CloudSun},
{path:'/udens-temperatura', title:'Ūdens temperatūra', description:'Manuāli ūdens temperatūras diapazoni ētera kartei.', icon:Waves},
{path:'/latvia', title:'Latvia overview', description:'Country-wide weather summaries.', icon:Map},
{path:'/database', title:'Data archive', description:'Browse dates and stored observations.', icon:Archive},
{path:'/harmonie', title:'HARMONIE', description:'Render locally stored forecast fields.', icon:Wind},
+1 -1
View File
@@ -41,7 +41,7 @@
.faktiskaStatus>div,.faktiskaStatus strong,.faktiskaStatus small{display:block}
.faktiskaStatus strong{margin-top:5px;font-size:18px}
.faktiskaStatus small{margin-top:3px;color:var(--text-muted)}
.stepLabel{font-family:Rubik,sans-serif;font-size:20px;font-weight:700}
.stepLabel{font-family:Monda,sans-serif;font-size:20px;font-weight:700}
.temperatureEditor{display:grid;grid-template-columns:repeat(7,minmax(150px,1fr));gap:10px}
.temperatureField{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"heading reset" "input input";gap:8px;padding:11px 12px;border:1px solid rgba(117,151,190,.28);border-radius:12px;background:rgba(255,255,255,.52);transition:border-color 180ms,background 180ms}
.temperatureField.manual{border-color:rgba(36,119,197,.58);background:rgba(229,241,255,.76)}
@@ -0,0 +1,156 @@
import { createEffect, createSignal, For, onMount } from 'solid-js';
import map1920 from '../../assets/map_1920x1080.webp';
import map3840 from '../../assets/map_3840x1440.webp';
import { download } from '../../helpers/download';
import './waterTemperature.css';
type OutputSize = '1920x1080' | '3840x1440';
type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale';
type RangeValue = { min: string; max: string };
const areas: Array<{ name: WaterArea; x: number; y: number }> = [
{ name: 'Jūra', x: 690, y: 720 },
{ name: 'Līcis', x: 1640, y: 455 },
{ name: 'Kurzeme', x: 1275, y: 785 },
{ name: 'Zemgale', x: 1880, y: 920 },
{ name: 'Vidzeme', x: 2235, y: 525 },
{ name: 'Latgale', x: 2540, y: 985 },
];
const emptyRanges = (): Record<WaterArea, RangeValue> => Object.fromEntries(
areas.map(({ name }) => [name, { min: '', max: '' }]),
) as Record<WaterArea, RangeValue>;
export function WaterTemperature() {
const [output, setOutput] = createSignal<OutputSize>('3840x1440');
const [ranges, setRanges] = createSignal(emptyRanges());
const [background, setBackground] = createSignal<HTMLImageElement>();
let canvas!: HTMLCanvasElement;
const dimensions = () => output() === '3840x1440'
? { width: 3840, height: 1440, map: map3840 }
: { width: 1920, height: 1080, map: map1920 };
const complete = () => areas.every(({ name }) => ranges()[name].min.trim() && ranges()[name].max.trim());
const updateRange = (name: WaterArea, key: keyof RangeValue, value: string) => {
setRanges(current => ({ ...current, [name]: { ...current[name], [key]: value } }));
};
const loadBackground = () => {
const image = new Image();
image.onload = () => setBackground(image);
image.src = dimensions().map;
};
const draw = async () => {
const image = background();
if (!canvas || !image) return;
await Promise.all([
document.fonts.load('700 90px Monda'),
document.fonts.load('400 40px Monda'),
]);
const { width, height } = dimensions();
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(image, 0, 0, width, height);
drawNameplate(ctx, width, height);
drawRanges(ctx, width, height, ranges());
};
onMount(loadBackground);
createEffect(() => { output(); loadBackground(); });
createEffect(() => { ranges(); background(); void draw(); });
const downloadPng = () => {
if (!complete()) return;
const size = output();
canvas.toBlob(blob => blob && download(blob, `udens-temperatura-${size}.png`), 'image/png');
};
return <section class="pageWorkspace waterPage">
<div class="pageHeading waterHeading">
<div><span class="eyebrow">ĒTERA GRAFIKA</span><h1>Ūdens temperatūra</h1><p>Manuāli ievadi temperatūras diapazonus un sagatavo karti eksportam.</p></div>
<span class="selectionCount">{complete() ? '6 diapazoni aizpildīti' : `${areas.filter(({ name }) => ranges()[name].min && ranges()[name].max).length} no 6 aizpildīti`}</span>
</div>
<section class="waterSetup" aria-labelledby="water-settings">
<div class="waterOutput">
<h2 id="water-settings">1. Izvēlies izmēru</h2>
<div class="resolutionChoices">
<button classList={{ active: output() === '1920x1080' }} onClick={() => setOutput('1920x1080')}>1920 × 1080</button>
<button classList={{ active: output() === '3840x1440' }} onClick={() => setOutput('3840x1440')}>3840 × 1440</button>
</div>
</div>
<div class="waterFields">
<h2>2. Ievadi temperatūras</h2>
<div class="rangeGrid"><For each={areas}>{({ name }) => <fieldset class="rangeField">
<legend>{name}</legend>
<label><span>No</span><input type="number" step="1" inputmode="numeric" value={ranges()[name].min} onInput={event => updateRange(name, 'min', event.currentTarget.value)} /></label>
<span class="rangeSeparator">...</span>
<label><span>Līdz</span><input type="number" step="1" inputmode="numeric" value={ranges()[name].max} onInput={event => updateRange(name, 'max', event.currentTarget.value)} /></label>
</fieldset>}</For></div>
</div>
</section>
<section class="waterPreview">
<div class="mapToolbar"><div><strong>3. Kartes priekšskatījums</strong><span>{dimensions().width} × {dimensions().height} px eksports</span></div><button class="primary" disabled={!complete()} onClick={downloadPng}>Lejupielādēt PNG</button></div>
{!complete() && <p class="waterNotice">Aizpildi visus sešus diapazonus, lai lejupielādētu gatavo karti.</p>}
<div class="canvasFrame"><canvas ref={canvas} width={dimensions().width} height={dimensions().height} /></div>
</section>
</section>;
}
function drawRanges(ctx: CanvasRenderingContext2D, width: number, height: number, values: Record<WaterArea, RangeValue>) {
const scaleX = width / 3840;
const scaleY = height / 1440;
const fontSize = 88 * Math.min(scaleX, scaleY);
const badgeHeight = 132 * scaleY;
const padding = 56 * scaleX;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `700 ${fontSize}px Monda`;
areas.forEach(({ name, x, y }) => {
const range = values[name];
if (!range.min.trim() || !range.max.trim()) return;
const text = `${range.min}...${range.max}`;
const localX = x * scaleX;
const localY = y * scaleY;
const badgeWidth = Math.max(300 * scaleX, ctx.measureText(text).width + padding * 2);
ctx.fillStyle = '#f4f4f4';
ctx.fillRect(localX - badgeWidth / 2, localY - badgeHeight / 2, badgeWidth, badgeHeight);
ctx.fillStyle = '#000';
ctx.fillText(text, localX, localY + 2 * scaleY);
});
}
function drawNameplate(ctx: CanvasRenderingContext2D, width: number, height: number) {
const scaleX = width / 3840;
const scaleY = height / 1440;
const x = 2698 * scaleX;
const y = 145 * scaleY;
const titleWidth = 1062 * scaleX;
const titleHeight = 124 * scaleY;
const sourceWidth = 290 * scaleX;
const sourceHeight = 60 * scaleY;
ctx.fillStyle = '#8f090b';
ctx.fillRect(x, y, titleWidth, titleHeight);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = `700 ${56 * Math.min(scaleX, scaleY)}px Monda`;
ctx.fillStyle = '#fff';
ctx.fillText('ŪDENS TEMPERATŪRA', x + 66 * scaleX, y + titleHeight / 2);
const sourceX = x + titleWidth - sourceWidth;
ctx.fillStyle = '#f4f4f4';
ctx.fillRect(sourceX, y + titleHeight, sourceWidth, sourceHeight);
ctx.fillStyle = '#000';
ctx.font = `700 ${39 * Math.min(scaleX, scaleY)}px Monda`;
ctx.textAlign = 'center';
ctx.fillText('LVĢMC', sourceX + sourceWidth / 2, y + titleHeight + sourceHeight / 2);
}
@@ -0,0 +1 @@
.waterPage{max-width:1900px}.waterHeading{margin-bottom:18px}.waterSetup{display:grid;grid-template-columns:260px minmax(0,1fr);gap:28px;padding:18px 0 24px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.waterSetup h2{margin:0 0 14px;font-size:20px}.resolutionChoices{display:grid;gap:9px}.resolutionChoices button{text-align:left}.resolutionChoices button.active{border-color:var(--accent);background:var(--accent-pale);color:var(--accent-strong);box-shadow:inset 0 0 0 1px var(--accent)}.rangeGrid{display:grid;grid-template-columns:repeat(3,minmax(250px,1fr));gap:12px}.rangeField{display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:end;margin:0;padding:10px 12px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.46)}.rangeField legend{padding:0 5px;font-weight:700}.rangeField label{display:grid;gap:4px}.rangeField label span{font-size:11px;color:var(--text-muted)}.rangeField input{width:100%;font-size:18px;font-weight:700}.rangeSeparator{padding-bottom:9px;font-weight:700}.waterPreview{padding-top:24px}.waterNotice{margin:0 0 10px;color:var(--text-muted);font-size:13px}.waterPreview canvas{display:block;width:100%;height:auto}@media(max-width:1050px){.waterSetup{grid-template-columns:1fr}.resolutionChoices{grid-template-columns:repeat(2,minmax(0,1fr))}.rangeGrid{grid-template-columns:repeat(2,minmax(220px,1fr))}}@media(max-width:650px){.rangeGrid,.resolutionChoices{grid-template-columns:1fr}}