Zeitbereich-Buttons und dynamische X-Achse (6h/12h/1d/7d/30d)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bodenfeuchte",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ export function insertMeasurement(soilMoisture: number): void {
|
||||
stmt.run(Date.now(), soilMoisture);
|
||||
}
|
||||
|
||||
export function getLast6Hours(): { timestamp: number; soil_moisture: number }[] {
|
||||
const since = Date.now() - 6 * 60 * 60 * 1000;
|
||||
export function getMeasurementsSince(hours: number): { timestamp: number; soil_moisture: number }[] {
|
||||
const since = Date.now() - hours * 60 * 60 * 1000;
|
||||
return getDb()
|
||||
.prepare(
|
||||
'SELECT timestamp, soil_moisture FROM measurements WHERE timestamp >= ? ORDER BY timestamp ASC'
|
||||
|
||||
+99
-8
@@ -8,6 +8,53 @@
|
||||
|
||||
type Measurement = { timestamp: number; soil_moisture: number };
|
||||
|
||||
type Range = {
|
||||
label: string;
|
||||
hours: number;
|
||||
labelText: string;
|
||||
step: number;
|
||||
isMajor: (d: Date) => boolean;
|
||||
formatTick: (d: Date, major: boolean) => string | string[];
|
||||
};
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
const RANGES: Range[] = [
|
||||
{
|
||||
label: '6h', hours: 6, labelText: 'Letzte 6 Stunden',
|
||||
step: 10 * 60 * 1000,
|
||||
isMajor: (d) => d.getMinutes() === 0,
|
||||
formatTick: (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
||||
},
|
||||
{
|
||||
label: '12h', hours: 12, labelText: 'Letzte 12 Stunden',
|
||||
step: 30 * 60 * 1000,
|
||||
isMajor: (d) => d.getMinutes() === 0,
|
||||
formatTick: (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
||||
},
|
||||
{
|
||||
label: '1d', hours: 24, labelText: 'Letzter Tag',
|
||||
step: 60 * 60 * 1000,
|
||||
isMajor: (d) => d.getHours() % 6 === 0,
|
||||
formatTick: (d) => `${pad(d.getHours())}:00`,
|
||||
},
|
||||
{
|
||||
label: '7d', hours: 7 * 24, labelText: 'Letzte 7 Tage',
|
||||
step: 6 * 60 * 60 * 1000,
|
||||
isMajor: (d) => d.getHours() === 0,
|
||||
formatTick: (d, major) => major
|
||||
? ['00:00', `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`]
|
||||
: `${pad(d.getHours())}:00`,
|
||||
},
|
||||
{
|
||||
label: '30d', hours: 30 * 24, labelText: 'Letzte 30 Tage',
|
||||
step: 24 * 60 * 60 * 1000,
|
||||
isMajor: (d) => d.getDay() === 1,
|
||||
formatTick: (d) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`,
|
||||
},
|
||||
];
|
||||
|
||||
let selectedRange = $state(0);
|
||||
let data = $state<Measurement[]>([]);
|
||||
let lastUpdate = $state('');
|
||||
let canvas: HTMLCanvasElement;
|
||||
@@ -15,17 +62,26 @@
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
async function fetchData() {
|
||||
const res = await fetch(`${base}/api/data`);
|
||||
const res = await fetch(`${base}/api/data?hours=${RANGES[selectedRange].hours}`);
|
||||
data = await res.json();
|
||||
lastUpdate = new Date().toLocaleTimeString('de-DE');
|
||||
updateChart();
|
||||
}
|
||||
|
||||
async function selectRange(idx: number) {
|
||||
selectedRange = idx;
|
||||
await fetchData();
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (!chart) return;
|
||||
const now = Date.now();
|
||||
const range = RANGES[selectedRange];
|
||||
const showPoints = range.hours <= 24;
|
||||
chart.data.datasets[0].data = data.map(d => ({ x: d.timestamp, y: d.soil_moisture }));
|
||||
chart.options.scales!['x']!.min = now - 6 * 60 * 60 * 1000;
|
||||
(chart.data.datasets[0] as any).pointRadius = showPoints ? 3 : 0;
|
||||
(chart.data.datasets[0] as any).pointHoverRadius = showPoints ? 4 : 0;
|
||||
chart.options.scales!['x']!.min = now - range.hours * 60 * 60 * 1000;
|
||||
chart.options.scales!['x']!.max = now;
|
||||
chart.update();
|
||||
}
|
||||
@@ -60,21 +116,42 @@
|
||||
},
|
||||
adapters: { date: { locale: de } },
|
||||
afterBuildTicks(scale: any) {
|
||||
// Rebuild ticks at exactly 10-minute intervals
|
||||
const range = RANGES[selectedRange];
|
||||
const min = scale.min;
|
||||
const max = scale.max;
|
||||
const step = 10 * 60 * 1000;
|
||||
const step = range.step;
|
||||
const ticks: { value: number; major: boolean }[] = [];
|
||||
|
||||
if (step < 60 * 60 * 1000) {
|
||||
// Sub-hour: UTC alignment is fine (minutes are TZ-invariant)
|
||||
const start = Math.ceil(min / step) * step;
|
||||
const ticks = [];
|
||||
for (let t = start; t <= max; t += step) {
|
||||
const d = new Date(t);
|
||||
ticks.push({ value: t, major: d.getMinutes() === 0 });
|
||||
ticks.push({ value: t, major: range.isMajor(new Date(t)) });
|
||||
}
|
||||
} else {
|
||||
// Hour+: align to local midnight so isMajor works in any timezone
|
||||
const isDaily = step === 24 * 60 * 60 * 1000;
|
||||
const d = new Date(min);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const advance = () => isDaily ? d.setDate(d.getDate() + 1) : d.setTime(d.getTime() + step);
|
||||
if (d.getTime() < min) advance();
|
||||
while (d.getTime() <= max) {
|
||||
ticks.push({ value: d.getTime(), major: range.isMajor(d) });
|
||||
advance();
|
||||
}
|
||||
}
|
||||
|
||||
scale.ticks = ticks;
|
||||
},
|
||||
ticks: {
|
||||
major: { enabled: true },
|
||||
autoSkip: false,
|
||||
callback: (value: any, index: number, ticks: any[]) => {
|
||||
const range = RANGES[selectedRange];
|
||||
const d = new Date(value);
|
||||
const major = ticks[index]?.major ?? false;
|
||||
return range.formatTick(d, major);
|
||||
},
|
||||
color: (ctx: any) => ctx.tick?.major ? '#222' : '#aaa',
|
||||
font: (ctx: any) => ctx.tick?.major ? { weight: 'bold' as const, size: 13 } : { size: 10 },
|
||||
maxRotation: 0,
|
||||
@@ -107,6 +184,7 @@
|
||||
});
|
||||
|
||||
const latest = $derived(data.at(-1));
|
||||
const currentRangeLabel = $derived(RANGES[selectedRange].labelText);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -140,9 +218,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Zeitbereich-Auswahl -->
|
||||
<div class="flex gap-2 justify-center flex-wrap px-2">
|
||||
{#each RANGES as range, i}
|
||||
<button
|
||||
onclick={() => selectRange(i)}
|
||||
class="px-5 py-1.5 border-2 border-black rounded font-bold text-sm"
|
||||
style={selectedRange === i ? 'background:#2980b9; color:white;' : 'background:#FFFFDD; color:black;'}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Diagramm -->
|
||||
<div style="background:white; height:340px;" class="border-y-2 sm:border-2 border-black sm:rounded-lg p-4 pb-8">
|
||||
<p class="text-xs text-gray-400 mb-2">Letzte 6 Stunden · Stand: {lastUpdate || '…'}</p>
|
||||
<p class="text-xs text-gray-400 mb-2">{currentRangeLabel} · Stand: {lastUpdate || '…'}</p>
|
||||
{#if data.length === 0}
|
||||
<div class="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
Noch keine Daten vorhanden
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getLast6Hours } from '$lib/db';
|
||||
import { getMeasurementsSince } from '$lib/db';
|
||||
|
||||
export function GET() {
|
||||
return json(getLast6Hours());
|
||||
export function GET({ url }: { url: URL }) {
|
||||
const hours = Number(url.searchParams.get('hours') ?? 6);
|
||||
const safeHours = isFinite(hours) && hours > 0 ? hours : 6;
|
||||
return json(getMeasurementsSince(safeHours));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user