Add water temperature production workspace
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user