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",
|
"name": "bodenfeuchte",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
+2
-2
@@ -27,8 +27,8 @@ export function insertMeasurement(soilMoisture: number): void {
|
|||||||
stmt.run(Date.now(), soilMoisture);
|
stmt.run(Date.now(), soilMoisture);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLast6Hours(): { timestamp: number; soil_moisture: number }[] {
|
export function getMeasurementsSince(hours: number): { timestamp: number; soil_moisture: number }[] {
|
||||||
const since = Date.now() - 6 * 60 * 60 * 1000;
|
const since = Date.now() - hours * 60 * 60 * 1000;
|
||||||
return getDb()
|
return getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT timestamp, soil_moisture FROM measurements WHERE timestamp >= ? ORDER BY timestamp ASC'
|
'SELECT timestamp, soil_moisture FROM measurements WHERE timestamp >= ? ORDER BY timestamp ASC'
|
||||||
|
|||||||
+101
-10
@@ -8,6 +8,53 @@
|
|||||||
|
|
||||||
type Measurement = { timestamp: number; soil_moisture: number };
|
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 data = $state<Measurement[]>([]);
|
||||||
let lastUpdate = $state('');
|
let lastUpdate = $state('');
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
@@ -15,17 +62,26 @@
|
|||||||
let interval: ReturnType<typeof setInterval>;
|
let interval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
async function fetchData() {
|
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();
|
data = await res.json();
|
||||||
lastUpdate = new Date().toLocaleTimeString('de-DE');
|
lastUpdate = new Date().toLocaleTimeString('de-DE');
|
||||||
updateChart();
|
updateChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectRange(idx: number) {
|
||||||
|
selectedRange = idx;
|
||||||
|
await fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
const now = Date.now();
|
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.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.options.scales!['x']!.max = now;
|
||||||
chart.update();
|
chart.update();
|
||||||
}
|
}
|
||||||
@@ -60,21 +116,42 @@
|
|||||||
},
|
},
|
||||||
adapters: { date: { locale: de } },
|
adapters: { date: { locale: de } },
|
||||||
afterBuildTicks(scale: any) {
|
afterBuildTicks(scale: any) {
|
||||||
// Rebuild ticks at exactly 10-minute intervals
|
const range = RANGES[selectedRange];
|
||||||
const min = scale.min;
|
const min = scale.min;
|
||||||
const max = scale.max;
|
const max = scale.max;
|
||||||
const step = 10 * 60 * 1000;
|
const step = range.step;
|
||||||
const start = Math.ceil(min / step) * step;
|
const ticks: { value: number; major: boolean }[] = [];
|
||||||
const ticks = [];
|
|
||||||
for (let t = start; t <= max; t += step) {
|
if (step < 60 * 60 * 1000) {
|
||||||
const d = new Date(t);
|
// Sub-hour: UTC alignment is fine (minutes are TZ-invariant)
|
||||||
ticks.push({ value: t, major: d.getMinutes() === 0 });
|
const start = Math.ceil(min / step) * step;
|
||||||
|
for (let t = start; t <= max; t += step) {
|
||||||
|
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;
|
scale.ticks = ticks;
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
major: { enabled: true },
|
major: { enabled: true },
|
||||||
autoSkip: false,
|
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',
|
color: (ctx: any) => ctx.tick?.major ? '#222' : '#aaa',
|
||||||
font: (ctx: any) => ctx.tick?.major ? { weight: 'bold' as const, size: 13 } : { size: 10 },
|
font: (ctx: any) => ctx.tick?.major ? { weight: 'bold' as const, size: 13 } : { size: 10 },
|
||||||
maxRotation: 0,
|
maxRotation: 0,
|
||||||
@@ -107,6 +184,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const latest = $derived(data.at(-1));
|
const latest = $derived(data.at(-1));
|
||||||
|
const currentRangeLabel = $derived(RANGES[selectedRange].labelText);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -140,9 +218,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- Diagramm -->
|
||||||
<div style="background:white; height:340px;" class="border-y-2 sm:border-2 border-black sm:rounded-lg p-4 pb-8">
|
<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}
|
{#if data.length === 0}
|
||||||
<div class="flex items-center justify-center h-full text-gray-400 text-sm">
|
<div class="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
Noch keine Daten vorhanden
|
Noch keine Daten vorhanden
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getLast6Hours } from '$lib/db';
|
import { getMeasurementsSince } from '$lib/db';
|
||||||
|
|
||||||
export function GET() {
|
export function GET({ url }: { url: URL }) {
|
||||||
return json(getLast6Hours());
|
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