Compare commits

...

3 commits

2 changed files with 358 additions and 135 deletions

View file

@ -128,16 +128,17 @@
import * as Chart from "$lib/components/ui/chart/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { scaleUtc } from "d3-scale";
import { Area, AreaChart, ChartClipPath } from "layerchart";
import { curveNatural } from "d3-shape";
import ChartContainer from "$lib/components/ui/chart/chart-container.svelte";
import { cubicInOut } from "svelte/easing";
import { ChevronDownIcon, Columns2 } from "@lucide/svelte";
let chartData = $state([]);
let newChartData = $derived(
chartData.filter((item: object) => {
if (item.sensorId === selectedESP) {
return { ...item };
}
}),
);
let timeRange = $state("90d");
const selectedLabel = $derived.by(() => {
@ -154,8 +155,8 @@
});
const filteredData = $derived(
chartData.filter((item) => {
const referenceDate = new Date("2024-06-30");
newChartData.filter((item) => {
const now = new Date();
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
@ -163,75 +164,132 @@
daysToSubtract = 7;
}
referenceDate.setDate(referenceDate.getDate() - daysToSubtract);
return item.date >= referenceDate;
const cutoffDate = new Date(now.getTime() - daysToSubtract * 24 * 60 * 60 * 1000);
return item.date >= cutoffDate;
}),
);
const chartConfig = {
temperature: { label: "Temperature", color: "var(--chart-1)" },
humidity: { label: "Humidity", color: "var(--chart-2)" },
altitude: { label: "Altitude", color: "var(--chart-1)" },
pressure: { label: "Pressure", color: "var(--chart-2)" },
temperature: { label: "Temperature (°C)", color: "var(--chart-1)" },
humidity: { label: "Humidity (%)", color: "var(--chart-2)" },
altitude: { label: "Altitude (m)", color: "var(--chart-3)" },
pressure: { label: "Pressure (kPa)", color: "var(--chart-4)" },
} satisfies Chart.ChartConfig;
const table = $state([
let selectedSensor = $state("temperature");
const sensorLabel = $derived.by(() => {
switch (selectedSensor) {
case "altitude":
return "Altitude (m)";
case "humidity":
return "Humidity (%)";
case "pressure":
return "Pressure (kPa)";
case "temperature":
default:
return "Temperature (°C)";
}
});
const sensorOptions = [
{
visible: true,
key: "altitude",
label: chartConfig.altitude.label,
color: chartConfig.altitude.color,
key: "temperature",
label: chartConfig.temperature.label,
color: chartConfig.temperature.color,
},
{
visible: true,
key: "humidity",
label: chartConfig.humidity.label,
color: chartConfig.humidity.color,
},
{
visible: true,
key: "pressure",
label: chartConfig.pressure.label,
color: chartConfig.pressure.color,
},
{
visible: true,
key: "temperature",
label: chartConfig.temperature.label,
color: chartConfig.temperature.color,
key: "altitude",
label: chartConfig.altitude.label,
color: chartConfig.altitude.color,
},
]);
];
const tables = $derived(
table
.filter((item) => {
if (item.visible) return true;
return false;
})
.map((item) => {
return { key: item.key, label: item.label, color: item.color };
}),
const selectedSensorConfig = $derived(
sensorOptions.find((s) => s.key === selectedSensor) || sensorOptions[0],
);
let onOpenChange = $state(async (open: boolean) => {
if (open) {
const response = await fetch("/" + data.slug + "/stats");
const number = await response.json();
console.log(number);
let esps = $state([]);
let selectedESP = $state("Select ESP sensor");
chartData = number.map((obj: any) => {
return { ...obj, date: new Date(obj.date) };
});
let isLoading = $state(false);
let hasError = $state(false);
let onOpenChange = $state(async (open: boolean) => {
console.log("onOpenChange called with:", open);
if (open) {
console.log("Starting to load statistics...");
isLoading = true;
hasError = false;
try {
console.log(`Fetching stats for floor: ${data.slug}`);
const response = await fetch(`/${data.slug}/stats`);
console.log("Stats response status:", response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const rawData = await response.json();
console.log("Raw stats data:", rawData);
const allesps = rawData.map((i: any) => {
return i.sensorId;
});
esps = allesps.filter((v: any, i: any, a: any) => a.indexOf(v) === i);
if (!rawData || rawData.length === 0) {
console.log("No data available for statistics");
chartData = [];
} else {
chartData = rawData.map((obj: any) => {
return { ...obj, date: new Date(obj.date) };
});
console.log("Processed chart data:", chartData);
}
} catch (error) {
console.error("Error loading statistics:", error);
hasError = true;
chartData = [];
} finally {
isLoading = false;
}
}
});
function exportCSV(): void {
let csv = "data:text/csv;charset=utf-8,";
const headers = "time," + selectedSensorConfig.key;
csv += headers + "\n";
newChartData.forEach((obj: any) => {
csv += obj.date.toISOString() + "," + obj[selectedSensorConfig.key] + "\n";
});
const encodedUri = encodeURI(csv);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", selectedESP + "_" + selectedSensorConfig.key + "_data.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
<div class="container mx-auto p-6">
<!-- <div style="display: flex; justify-content: center; align-items: center; height: 100%;"> -->
<h1 class="mb-6 text-center text-4xl font-bold">Floor {data.slug}</h1>
{#if data.hasConfig && data.floorConfig}
<!-- Display saved floor plan configuration -->
<div class="relative mx-auto max-w-4xl">
{#if data.floorConfig.image}
<div class="relative overflow-hidden rounded-lg border bg-gray-50">
@ -308,7 +366,6 @@
{/if}
</div>
{:else}
<!-- Fallback to canvas drawing -->
<div class="flex flex-col items-center">
<span class="mb-4">{mqttMessage}</span>
<canvas bind:this={canvasEl} class="border" width="800" height="600"></canvas>
@ -316,7 +373,7 @@
{/if}
<Dialog.Root bind:onOpenChange>
<Dialog.Trigger class={buttonVariants({ variant: "outline" })}>Statistics</Dialog.Trigger>
<Dialog.Content class="sm:max-h-2/3 sm:max-w-2/3">
<Dialog.Content class="sm:max-h-[90%] sm:max-w-[90%]">
<Dialog.Header>
<div class="grid flex-1 gap-1 text-center sm:text-left">
<Card.Title>Statistics for floor {data.slug}</Card.Title>
@ -325,28 +382,31 @@
</Dialog.Header>
<div class="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="rounded-lg sm:ml-auto">
{#snippet child({ props })}
<Button variant="outline" size="sm" {...props}>
<Columns2 />
<span class="hidden lg:inline">Customize Data</span>
<span class="lg:hidden">Columns</span>
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-56">
{#each table as column}
<DropdownMenu.CheckboxItem class="capitalize" bind:checked={column.visible}>
{column.key}
</DropdownMenu.CheckboxItem>
<Select.Root type="single" bind:value={selectedESP}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a sensor">
{selectedESP}
</Select.Trigger>
<Select.Content class="rounded-xl">
{#each esps as esp}
<Select.Item value={esp} class="rounded-lg">{esp}</Select.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</Select.Content>
</Select.Root>
<Select.Root type="single" bind:value={selectedSensor}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a metric">
{sensorLabel}
</Select.Trigger>
<Select.Content class="rounded-xl">
<Select.Item value="altitude" class="rounded-lg">Altitude (m)</Select.Item>
<Select.Item value="humidity" class="rounded-lg">Humidity (%)</Select.Item>
<Select.Item value="pressure" class="rounded-lg">Pressure (kPa)</Select.Item>
<Select.Item value="temperature" class="rounded-lg">Temperature (°C)</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root type="single" bind:value={timeRange}>
<Select.Trigger class="w-[160px] rounded-lg sm:ml-auto" aria-label="Select a value">
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a time range">
{selectedLabel}
</Select.Trigger>
<Select.Content class="rounded-xl">
@ -356,77 +416,221 @@
</Select.Content>
</Select.Root>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" onclick={() => exportCSV()}>
<DownloadIcon />
<span class="hidden lg:inline">Export Data</span>
</Button>
</div>
<ChartContainer config={chartConfig} class="aspect-auto h-[800px] w-full">
<AreaChart
legend
data={filteredData}
x="date"
xScale={scaleUtc()}
series={tables}
seriesLayout="stack"
props={{
area: {
curve: curveNatural,
"fill-opacity": 0.4,
line: { class: "stroke-1" },
motion: "tween",
},
xAxis: {
ticks: timeRange === "7d" ? 7 : undefined,
format: (v) => {
return v.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
},
},
{#if isLoading}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"
></div>
<p class="text-muted-foreground">Loading statistics...</p>
</div>
</div>
{:else if hasError}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-red-600">Error loading statistics</p>
<p class="text-muted-foreground text-sm">Please try again later</p>
</div>
</div>
{:else if newChartData.length === 0}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-lg font-semibold">No data available</p>
<p class="text-muted-foreground text-sm">
Statistics will appear once sensor data is collected
</p>
</div>
</div>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="text-xl">{selectedSensorConfig.label}</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-4">
{#if filteredData.length > 0}
<!-- Statistics Summary -->
<div class="grid grid-cols-3 gap-4 rounded border p-4 text-center">
<div>
<div class="text-muted-foreground text-sm font-medium">Latest</div>
<div class="text-lg font-semibold">
{filteredData[filteredData.length - 1]?.[selectedSensor]?.toFixed(1)}
{#if selectedSensor === "temperature"}°C
{:else if selectedSensor === "humidity"}%
{:else if selectedSensor === "pressure"}kPa
{:else if selectedSensor === "altitude"}m
{/if}
</div>
</div>
<div>
<div class="text-muted-foreground text-sm font-medium">Average</div>
<div class="text-lg font-semibold">
{(
filteredData.reduce((sum, item) => sum + item[selectedSensor], 0) /
filteredData.length
).toFixed(1)}
{#if selectedSensor === "temperature"}°C
{:else if selectedSensor === "humidity"}%
{:else if selectedSensor === "pressure"}kPa
{:else if selectedSensor === "altitude"}m
{/if}
</div>
</div>
<div>
<div class="text-muted-foreground text-sm font-medium">Range</div>
<div class="text-lg font-semibold">
{Math.min(...filteredData.map((d) => d[selectedSensor])).toFixed(1)} - {Math.max(
...filteredData.map((d) => d[selectedSensor]),
).toFixed(1)}
{#if selectedSensor === "temperature"}°C
{:else if selectedSensor === "humidity"}%
{:else if selectedSensor === "pressure"}kPa
{:else if selectedSensor === "altitude"}m
{/if}
</div>
</div>
</div>
yAxis: { format: () => "" },
}}
>
{#snippet marks({ series, getAreaProps })}
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stop-color="var(--color-desktop)" stop-opacity={1.0} />
<stop offset="95%" stop-color="var(--color-desktop)" stop-opacity={0.1} />
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stop-color="var(--color-mobile)" stop-opacity={0.8} />
<stop offset="95%" stop-color="var(--color-mobile)" stop-opacity={0.1} />
</linearGradient>
</defs>
<ChartClipPath
initialWidth={0}
motion={{
width: { type: "tween", duration: 1000, easing: cubicInOut },
}}
>
{#each series as s, i (s.key)}
<Area
{...getAreaProps(s, i)}
fill={s.key === "desktop" ? "url(#fillDesktop)" : "url(#fillMobile)"}
/>
{/each}
</ChartClipPath>
{/snippet}
{#snippet tooltip()}
<Chart.Tooltip
labelFormatter={(v: Date) => {
return v.toLocaleDateString("en-US", {
month: "long",
});
}}
indicator="line"
/>
{/snippet}
</AreaChart>
</ChartContainer>
<!-- Simple SVG Line Chart -->
<div class="rounded border bg-white p-4">
<svg viewBox="0 0 800 400" class="h-80 w-full">
<!-- Chart area -->
{#if filteredData.length > 1}
{@const minValue = Math.min(...filteredData.map((d) => d[selectedSensor]))}
{@const maxValue = Math.max(...filteredData.map((d) => d[selectedSensor]))}
{@const valueRange = maxValue - minValue || 1}
{@const padding = 30}
{@const chartWidth = 800 - padding * 2}
{@const chartHeight = 400 - padding * 2}
<!-- Background grid -->
<defs>
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
<path
d="M 50 0 L 0 0 0 40"
fill="none"
stroke="#f0f0f0"
stroke-width="1"
/>
</pattern>
</defs>
<rect
x={padding}
y={padding}
width={chartWidth}
height={chartHeight}
fill="url(#grid)"
/>
<!-- Y-axis labels -->
<text x="25" y={padding + 5} class="fill-gray-600 text-xs" text-anchor="end"
>{maxValue.toFixed(1)}</text
>
<text
x="25"
y={padding + chartHeight / 2 + 5}
class="fill-gray-600 text-xs"
text-anchor="end">{((minValue + maxValue) / 2).toFixed(1)}</text
>
<text
x="25"
y={padding + chartHeight + 5}
class="fill-gray-600 text-xs"
text-anchor="end">{minValue.toFixed(1)}</text
>
<!-- X-axis labels -->
{#if filteredData.length > 0}
{@const firstDate = filteredData[0].date}
{@const lastDate = filteredData[filteredData.length - 1].date}
{@const midIndex = Math.floor(filteredData.length / 2)}
{@const midDate = filteredData[midIndex].date}
<!-- Format dates based on time range -->
{@const formatDate = (date) => {
if (timeRange === "7d") {
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
} else if (timeRange === "30d") {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} else {
return date.toLocaleDateString("en-US", { month: "short" });
}
}}
<text
x={padding}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="start">{formatDate(firstDate)}</text
>
<text
x={padding + chartWidth / 2}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="middle">{formatDate(midDate)}</text
>
<text
x={padding + chartWidth}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="end">{formatDate(lastDate)}</text
>
{/if}
<!-- Data line -->
<polyline
fill="none"
stroke="#3b82f6"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
points={filteredData
.map((d, i) => {
const x = padding + (i / (filteredData.length - 1)) * chartWidth;
const y =
padding +
chartHeight -
((d[selectedSensor] - minValue) / valueRange) * chartHeight;
return `${x},${y}`;
})
.join(" ")}
/>
{/if}
</svg>
</div>
<div class="text-muted-foreground text-center text-xs">
Data from {filteredData[0]?.date?.toLocaleDateString()} to {filteredData[
filteredData.length - 1
]?.date?.toLocaleDateString()}
</div>
{:else}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-lg font-semibold">No data available</p>
<p class="text-muted-foreground text-sm">
No data found for the selected time period
</p>
</div>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
{/if}
<Dialog.Footer></Dialog.Footer>
</Dialog.Content>

View file

@ -1,9 +1,28 @@
import { db } from "$lib/server/db";
import * as table from "$lib/server/db/schema"
import { eq } from "drizzle-orm";
export const GET = async () => {
const data = await db.select({ altitude: table.sensorData.altitude, humidity: table.sensorData.humidity, pressure: table.sensorData.pressure, temperature: table.sensorData.temperature, date: table.sensorData.time, }).from(table.sensorData);
console.log(data);
export const GET = async (event) => {
// Get all sensor data (like the original working version)
console.log(event.locals.session.userId)
const rawData = await db.select({
altitude: table.sensorData.altitude,
humidity: table.sensorData.humidity,
pressure: table.sensorData.pressure,
temperature: table.sensorData.temperature,
date: table.sensorData.time,
sensorId: table.sensorData.sensor,
user: table.sensors.user,
}).from(table.sensorData).innerJoin(table.sensors, eq(table.sensors.id, table.sensorData.sensor)).where(eq(table.sensors.user, event.locals.session.userId));
// Scale pressure values to be in a similar range as other sensors
// Divide by 1000 to convert from Pa to kPa (more reasonable scale)
const data = rawData.map(item => ({
...item,
pressure: Math.round((item.pressure / 1000) * 10) / 10 // Convert to kPa with 1 decimal place
}));
console.log(`Returning ${data.length} data points`);
return new Response(JSON.stringify(data), {
headers: {