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
+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}}