This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_NAME = 'greenhome-1766173610406';
|
const CACHE_NAME = 'greenhome-1766182760520';
|
||||||
const STATIC_CACHE_NAME = 'greenhome-static-1766173610406';
|
const STATIC_CACHE_NAME = 'greenhome-static-1766182760520';
|
||||||
|
|
||||||
// Static assets to cache on install
|
// Static assets to cache on install
|
||||||
const STATIC_FILES_TO_CACHE = [
|
const STATIC_FILES_TO_CACHE = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "1766173610406"
|
"version": "1766182760520"
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,32 @@ function DailyReportContent() {
|
|||||||
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
|
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
|
||||||
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
||||||
|
|
||||||
|
// Map summary param to chart key
|
||||||
|
const paramToChartKey = useCallback((param: string): string => {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
temperature: 'temp',
|
||||||
|
humidity: 'hum',
|
||||||
|
gas: 'gas',
|
||||||
|
soil: 'soil',
|
||||||
|
lux: 'lux',
|
||||||
|
}
|
||||||
|
return mapping[param] || param
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle card click: switch to charts tab and scroll to chart
|
||||||
|
const handleCardClick = useCallback((param: string) => {
|
||||||
|
const chartKey = paramToChartKey(param)
|
||||||
|
setActiveTab('charts')
|
||||||
|
|
||||||
|
// Wait for tab to switch and chart to render, then scroll
|
||||||
|
setTimeout(() => {
|
||||||
|
const chartElement = document.getElementById(`chart-${chartKey}`)
|
||||||
|
if (chartElement) {
|
||||||
|
chartElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}, [paramToChartKey])
|
||||||
|
|
||||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||||
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
|
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
|
||||||
|
|
||||||
@@ -217,7 +243,7 @@ function DailyReportContent() {
|
|||||||
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
|
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} />,
|
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} onCardClick={handleCardClick} />,
|
||||||
charts: (
|
charts: (
|
||||||
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||||
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />
|
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import React from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { Line } from 'react-chartjs-2'
|
import { Line } from 'react-chartjs-2'
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
@@ -11,15 +11,40 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Filler
|
Filler
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import annotationPlugin from 'chartjs-plugin-annotation'
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
|
||||||
Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler, annotationPlugin)
|
Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler)
|
||||||
|
|
||||||
// تابع تبدیل ارقام انگلیسی به فارسی
|
// Constants
|
||||||
function toPersianDigits(str: string | number): string {
|
const FONT_FAMILY = "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
||||||
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
|
const FONT_SIZE = 10
|
||||||
return str.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
|
|
||||||
}
|
const GAP_ANNOTATION_COLORS = {
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const RESPONSIVE_CONFIG = {
|
||||||
|
mobile: {
|
||||||
|
pointRadius: 0.5,
|
||||||
|
pointHoverRadius: 3,
|
||||||
|
animationDuration: 300,
|
||||||
|
},
|
||||||
|
desktop: {
|
||||||
|
pointRadius: 1,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
animationDuration: 1000,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const CHART_LAYOUT_PADDING = {
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 10,
|
||||||
|
bottom: 0,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const AXIS_BORDER_COLOR = 'rgba(0, 0, 0, 0.1)'
|
||||||
|
|
||||||
type Series = { label: string; data: (number | null)[]; borderColor: string; backgroundColor?: string; fill?: boolean }
|
type Series = { label: string; data: (number | null)[]; borderColor: string; backgroundColor?: string; fill?: boolean }
|
||||||
|
|
||||||
@@ -37,9 +62,9 @@ type Props = {
|
|||||||
dataGaps?: DataGapAnnotation[] // Indices where gaps occur
|
dataGaps?: DataGapAnnotation[] // Indices where gaps occur
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
export function Panel({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
|
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
|
||||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
|
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
|
||||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,47 +76,15 @@ export function Panel({ title, children }: { title: string; children: React.Reac
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||||
// Find gap annotations based on null values in data
|
// Check if mobile device
|
||||||
const gapAnnotations = React.useMemo(() => {
|
const isMobile = useMemo(() => {
|
||||||
const annotations: Record<string, {
|
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
type: 'box';
|
}, [])
|
||||||
xMin: number;
|
|
||||||
xMax: number;
|
|
||||||
backgroundColor: string;
|
|
||||||
borderColor: string;
|
|
||||||
borderWidth: number;
|
|
||||||
borderDash: number[];
|
|
||||||
label: { display: boolean };
|
|
||||||
}> = {}
|
|
||||||
let gapCount = 0
|
|
||||||
|
|
||||||
// Find nulls in the first series data
|
// Get responsive config
|
||||||
if (series.length > 0) {
|
const responsiveConfig = useMemo(() => {
|
||||||
const data = series[0].data
|
return isMobile ? RESPONSIVE_CONFIG.mobile : RESPONSIVE_CONFIG.desktop
|
||||||
for (let i = 0; i < data.length; i++) {
|
}, [isMobile])
|
||||||
if (data[i] === null) {
|
|
||||||
// Find the gap range
|
|
||||||
const startIdx = Math.max(0, i - 1)
|
|
||||||
const endIdx = Math.min(data.length - 1, i + 1)
|
|
||||||
|
|
||||||
annotations[`gap-${gapCount++}`] = {
|
|
||||||
type: 'box' as const,
|
|
||||||
xMin: startIdx,
|
|
||||||
xMax: endIdx,
|
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
|
||||||
borderColor: 'rgba(239, 68, 68, 0.3)',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderDash: [5, 5],
|
|
||||||
label: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return annotations
|
|
||||||
}, [series])
|
|
||||||
|
|
||||||
// Calculate hour range and determine which hours to show (not currently used but kept for potential future use)
|
// Calculate hour range and determine which hours to show (not currently used but kept for potential future use)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@@ -130,135 +123,191 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
|||||||
}, [labels])
|
}, [labels])
|
||||||
|
|
||||||
// Create map of label index to hour
|
// Create map of label index to hour
|
||||||
const labelHours = React.useMemo(() => {
|
const labelHours = useMemo(() => {
|
||||||
return labels.map(label => {
|
return labels.map(label => {
|
||||||
if (!label) return null
|
if (!label) return null
|
||||||
const [hour] = label.split(':').map(Number)
|
const [hour] = label.split(':').map(Number)
|
||||||
return hour
|
return hour
|
||||||
})
|
})
|
||||||
}, [labels])
|
}, [labels])
|
||||||
|
// X-axis tick callback - optimized with useCallback
|
||||||
|
const xAxisTickCallback = useCallback((value: unknown, index: number) => {
|
||||||
|
// Skip if index is out of bounds
|
||||||
|
if (index < 0 || index >= labelHours.length) return ''
|
||||||
|
|
||||||
|
const hour = labelHours[index]
|
||||||
|
if (hour === null) return ''
|
||||||
|
|
||||||
|
// Find if this is the first occurrence of this hour
|
||||||
|
const firstIndexOfHour = labelHours.findIndex(h => h === hour)
|
||||||
|
|
||||||
|
// Only show label if this is the first occurrence of this hour
|
||||||
|
if (firstIndexOfHour !== index) return ''
|
||||||
|
|
||||||
|
// Show only the hour number
|
||||||
|
return toPersianDigits(hour.toString())
|
||||||
|
}, [labelHours])
|
||||||
|
|
||||||
|
// Tooltip callbacks
|
||||||
|
const tooltipCallbacks = useMemo(() => ({
|
||||||
|
label: (context: { dataset: { label?: string }; parsed: { y: number } }) => {
|
||||||
|
const label = context.dataset.label || ''
|
||||||
|
return `${label}: ${toPersianDigits(context.parsed.y.toFixed(2))}`
|
||||||
|
},
|
||||||
|
title: (context: Array<{ label?: string }>) => {
|
||||||
|
const label = context[0]?.label || ''
|
||||||
|
return `ساعت: ${label}`
|
||||||
|
}
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
// Font configuration
|
||||||
|
const fontConfig = useMemo(() => ({
|
||||||
|
family: FONT_FAMILY,
|
||||||
|
size: FONT_SIZE,
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
// Dataset configuration with segment for gaps
|
||||||
|
const datasets = useMemo(() => {
|
||||||
|
return series.map((s, seriesIndex) => {
|
||||||
|
return {
|
||||||
|
label: s.label,
|
||||||
|
data: s.data,
|
||||||
|
borderColor: s.borderColor,
|
||||||
|
backgroundColor: s.backgroundColor ?? s.borderColor,
|
||||||
|
fill: s.fill ?? false,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: responsiveConfig.pointRadius,
|
||||||
|
pointHoverRadius: responsiveConfig.pointHoverRadius,
|
||||||
|
borderWidth: 1,
|
||||||
|
spanGaps: true, // Must be true for segment to work on gap connections
|
||||||
|
// Use segment to style gaps
|
||||||
|
segment: {
|
||||||
|
borderDash: (ctx: {p0DataIndex: number, p1DataIndex: number}) => {
|
||||||
|
const index0 = ctx.p0DataIndex
|
||||||
|
const index1 = ctx.p1DataIndex
|
||||||
|
const data = s.data
|
||||||
|
|
||||||
|
// If either point is null, it's a gap
|
||||||
|
if (data[index0] === null || data[index1] === null) {
|
||||||
|
return [10, 10]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any null values between these two points
|
||||||
|
const start = Math.min(index0, index1)
|
||||||
|
const end = Math.max(index0, index1)
|
||||||
|
for (let i = start + 1; i < end; i++) {
|
||||||
|
if (data[i] === null) {
|
||||||
|
return [10, 10] // There's a gap between these points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
borderColor: (ctx: {p0DataIndex: number, p1DataIndex: number}) => {
|
||||||
|
const index0 = ctx.p0DataIndex
|
||||||
|
const index1 = ctx.p1DataIndex
|
||||||
|
const data = s.data
|
||||||
|
|
||||||
|
// If either point is null, use gap color
|
||||||
|
if (data[index0] === null || data[index1] === null) {
|
||||||
|
return GAP_ANNOTATION_COLORS.borderColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any null values between these two points
|
||||||
|
const start = Math.min(index0, index1)
|
||||||
|
const end = Math.max(index0, index1)
|
||||||
|
for (let i = start + 1; i < end; i++) {
|
||||||
|
if (data[i] === null) {
|
||||||
|
return GAP_ANNOTATION_COLORS.borderColor // There's a gap between these points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.borderColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [series, responsiveConfig])
|
||||||
|
|
||||||
|
// Chart options
|
||||||
|
const chartOptions = useMemo(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
animation: {
|
||||||
|
duration: responsiveConfig.animationDuration,
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index' as const,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
padding: CHART_LAYOUT_PADDING,
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
family: FONT_FAMILY,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
displayColors: false,
|
||||||
|
titleFont: fontConfig,
|
||||||
|
bodyFont: fontConfig,
|
||||||
|
callbacks: tooltipCallbacks,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
ticks: {
|
||||||
|
font: fontConfig,
|
||||||
|
maxRotation: 0,
|
||||||
|
minRotation: 0,
|
||||||
|
autoSkip: false,
|
||||||
|
callback: xAxisTickCallback,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
display: true,
|
||||||
|
color: AXIS_BORDER_COLOR
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
min: yAxisMin !== undefined ? yAxisMin : undefined,
|
||||||
|
max: yAxisMax !== undefined ? yAxisMax : undefined,
|
||||||
|
ticks: {
|
||||||
|
font: fontConfig,
|
||||||
|
padding: 8,
|
||||||
|
callback: (value: unknown) => {
|
||||||
|
if (typeof value === 'number' || typeof value === 'string') {
|
||||||
|
return toPersianDigits(value.toString())
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
display: true,
|
||||||
|
color: AXIS_BORDER_COLOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [responsiveConfig, fontConfig, tooltipCallbacks, xAxisTickCallback, yAxisMin, yAxisMax])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="persian-number -mx-2">
|
<div className="persian-number -mx-2">
|
||||||
<Line
|
<Line
|
||||||
data={{
|
data={{
|
||||||
labels,
|
labels,
|
||||||
datasets: series.map(s => ({
|
datasets,
|
||||||
label: s.label,
|
|
||||||
data: s.data,
|
|
||||||
borderColor: s.borderColor,
|
|
||||||
backgroundColor: s.backgroundColor ?? s.borderColor,
|
|
||||||
fill: s.fill ?? false,
|
|
||||||
tension: 0.3,
|
|
||||||
pointRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 2 : 1.5, // Smaller points on mobile
|
|
||||||
pointHoverRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 3 : 4,
|
|
||||||
borderWidth: 2,
|
|
||||||
spanGaps: false // Don't connect points across null values (gaps)
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
options={{
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: true,
|
|
||||||
animation: {
|
|
||||||
duration: typeof window !== 'undefined' && window.innerWidth < 768 ? 300 : 1000, // Faster animations on mobile
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
intersect: false,
|
|
||||||
mode: 'index' as const,
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
padding: {
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 10,
|
|
||||||
bottom: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
font: {
|
|
||||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false // Hide legend
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
titleFont: {
|
|
||||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
|
||||||
},
|
|
||||||
bodyFont: {
|
|
||||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
|
||||||
},
|
|
||||||
callbacks: {
|
|
||||||
label: function(context) {
|
|
||||||
return `${context.dataset.label}: ${toPersianDigits(context.parsed.y.toFixed(2))}`
|
|
||||||
},
|
|
||||||
title: function(context) {
|
|
||||||
return `ساعت: ${context[0].label}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
annotation: {
|
|
||||||
annotations: gapAnnotations
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
display: true,
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
|
||||||
size: 10
|
|
||||||
},
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
autoSkip: false,
|
|
||||||
callback: function(value, index) {
|
|
||||||
// Get hour for this index
|
|
||||||
const hour = labelHours[index]
|
|
||||||
if (hour === null) return ''
|
|
||||||
|
|
||||||
// Find if this is the first occurrence of this hour
|
|
||||||
const firstIndexOfHour = labelHours.findIndex(h => h === hour)
|
|
||||||
|
|
||||||
// Only show label if this is the first occurrence of this hour
|
|
||||||
if (firstIndexOfHour !== index) return ''
|
|
||||||
|
|
||||||
// Show only the hour number
|
|
||||||
return toPersianDigits(hour.toString())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
display: false // Remove grid lines
|
|
||||||
},
|
|
||||||
border: {
|
|
||||||
display: true,
|
|
||||||
color: 'rgba(0, 0, 0, 0.1)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
display: true,
|
|
||||||
min: yAxisMin !== undefined ? yAxisMin : undefined,
|
|
||||||
max: yAxisMax !== undefined ? yAxisMax : undefined,
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
|
||||||
size: 10
|
|
||||||
},
|
|
||||||
padding: 8,
|
|
||||||
callback: function(value) {
|
|
||||||
return toPersianDigits(value.toString())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
display: false // Remove grid lines
|
|
||||||
},
|
|
||||||
border: {
|
|
||||||
display: true,
|
|
||||||
color: 'rgba(0, 0, 0, 0.1)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
options={chartOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const ChartsTab = memo(function ChartsTab({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{charts.map(chart => (
|
{charts.map(chart => (
|
||||||
<Panel key={chart.key} title={chart.title}>
|
<Panel key={chart.key} title={chart.title} id={`chart-${chart.key}`}>
|
||||||
<LineChart
|
<LineChart
|
||||||
labels={chartLabels}
|
labels={chartLabels}
|
||||||
series={[
|
series={[
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ type SummaryCardProps = {
|
|||||||
minValue: number
|
minValue: number
|
||||||
maxValue: number
|
maxValue: number
|
||||||
data: number[]
|
data: number[]
|
||||||
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SummaryCard({ param, currentValue, minValue, maxValue, data }: SummaryCardProps) {
|
export function SummaryCard({ param, currentValue, minValue, maxValue, data, onClick }: SummaryCardProps) {
|
||||||
const config = paramConfig[param]
|
const config = paramConfig[param]
|
||||||
if (!config) return null
|
if (!config) return null
|
||||||
|
|
||||||
@@ -35,7 +36,10 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data }: S
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden`}>
|
<div
|
||||||
|
className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 pt-3 pb-1">
|
<div className="px-4 pt-3 pb-1">
|
||||||
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>
|
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ type SummaryTabProps = {
|
|||||||
lux: number[]
|
lux: number[]
|
||||||
forecastWeather?: WeatherData | null
|
forecastWeather?: WeatherData | null
|
||||||
forecastWeatherLoading?: boolean
|
forecastWeatherLoading?: boolean
|
||||||
|
onCardClick?: (param: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false }: SummaryTabProps) {
|
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick }: SummaryTabProps) {
|
||||||
const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
|
const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
|
||||||
|
|
||||||
const alertsCount = useMemo(() => {
|
const alertsCount = useMemo(() => {
|
||||||
@@ -104,6 +105,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
|||||||
minValue={temperatureSummary.min}
|
minValue={temperatureSummary.min}
|
||||||
maxValue={temperatureSummary.max}
|
maxValue={temperatureSummary.max}
|
||||||
data={temperature}
|
data={temperature}
|
||||||
|
onClick={() => onCardClick?.('temperature')}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="humidity"
|
param="humidity"
|
||||||
@@ -111,13 +113,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
|||||||
minValue={humiditySummary.min}
|
minValue={humiditySummary.min}
|
||||||
maxValue={humiditySummary.max}
|
maxValue={humiditySummary.max}
|
||||||
data={humidity}
|
data={humidity}
|
||||||
/>
|
onClick={() => onCardClick?.('humidity')}
|
||||||
<SummaryCard
|
|
||||||
param="soil"
|
|
||||||
currentValue={soilSummary.current}
|
|
||||||
minValue={soilSummary.min}
|
|
||||||
maxValue={soilSummary.max}
|
|
||||||
data={soil}
|
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="gas"
|
param="gas"
|
||||||
@@ -125,6 +121,15 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
|||||||
minValue={gasSummary.min}
|
minValue={gasSummary.min}
|
||||||
maxValue={gasSummary.max}
|
maxValue={gasSummary.max}
|
||||||
data={gas}
|
data={gas}
|
||||||
|
onClick={() => onCardClick?.('gas')}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
param="soil"
|
||||||
|
currentValue={soilSummary.current}
|
||||||
|
minValue={soilSummary.min}
|
||||||
|
maxValue={soilSummary.max}
|
||||||
|
data={soil}
|
||||||
|
onClick={() => onCardClick?.('soil')}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="lux"
|
param="lux"
|
||||||
@@ -132,6 +137,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
|||||||
minValue={luxSummary.min}
|
minValue={luxSummary.min}
|
||||||
maxValue={luxSummary.max}
|
maxValue={luxSummary.max}
|
||||||
data={lux}
|
data={lux}
|
||||||
|
onClick={() => onCardClick?.('lux')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { NormalizedTelemetry } from './types'
|
|||||||
|
|
||||||
export const BASE_CHART_CONFIGS: ChartConfig[] = [
|
export const BASE_CHART_CONFIGS: ChartConfig[] = [
|
||||||
{
|
{
|
||||||
key: 'soil',
|
key: 'temp',
|
||||||
title: 'رطوبت خاک',
|
title: 'دما',
|
||||||
seriesLabel: 'رطوبت خاک (%)',
|
seriesLabel: 'دما (°C)',
|
||||||
color: '#16a34a',
|
color: '#ef4444',
|
||||||
bgColor: '#dcfce7',
|
bgColor: '#fee2e2',
|
||||||
getValue: (t: NormalizedTelemetry) => t.soil,
|
getValue: (t: NormalizedTelemetry) => t.temp,
|
||||||
yAxisMin: 0,
|
yAxisMin: 0,
|
||||||
yAxisMax: 100,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'hum',
|
key: 'hum',
|
||||||
@@ -23,13 +22,24 @@ export const BASE_CHART_CONFIGS: ChartConfig[] = [
|
|||||||
yAxisMax: 100,
|
yAxisMax: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'temp',
|
key: 'gas',
|
||||||
title: 'دما',
|
title: 'گاز CO',
|
||||||
seriesLabel: 'دما (°C)',
|
seriesLabel: 'CO (ppm)',
|
||||||
color: '#ef4444',
|
color: '#f59e0b',
|
||||||
bgColor: '#fee2e2',
|
bgColor: '#fef3c7',
|
||||||
getValue: (t: NormalizedTelemetry) => t.temp,
|
getValue: (t: NormalizedTelemetry) => t.gas,
|
||||||
yAxisMin: 0,
|
yAxisMin: 0,
|
||||||
|
yAxisMax: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'soil',
|
||||||
|
title: 'رطوبت خاک',
|
||||||
|
seriesLabel: 'رطوبت خاک (%)',
|
||||||
|
color: '#16a34a',
|
||||||
|
bgColor: '#dcfce7',
|
||||||
|
getValue: (t: NormalizedTelemetry) => t.soil,
|
||||||
|
yAxisMin: 0,
|
||||||
|
yAxisMax: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'lux',
|
key: 'lux',
|
||||||
@@ -40,15 +50,5 @@ export const BASE_CHART_CONFIGS: ChartConfig[] = [
|
|||||||
getValue: (t: NormalizedTelemetry) => t.lux,
|
getValue: (t: NormalizedTelemetry) => t.lux,
|
||||||
yAxisMin: 0,
|
yAxisMin: 0,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'gas',
|
|
||||||
title: 'گاز CO',
|
|
||||||
seriesLabel: 'CO (ppm)',
|
|
||||||
color: '#f59e0b',
|
|
||||||
bgColor: '#fef3c7',
|
|
||||||
getValue: (t: NormalizedTelemetry) => t.gas,
|
|
||||||
yAxisMin: 0,
|
|
||||||
yAxisMax: 100,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export function useTelemetryCharts({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const chartLabels = useMemo(
|
const chartLabels = useMemo(
|
||||||
() => filteredTelemetry.map(t => t.label),
|
() => {
|
||||||
|
const labels = filteredTelemetry
|
||||||
|
.map(t => t.label)
|
||||||
|
|
||||||
|
return labels
|
||||||
|
},
|
||||||
[filteredTelemetry]
|
[filteredTelemetry]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react'
|
import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react'
|
||||||
import { TelemetryDto } from '@/lib/api'
|
import { TelemetryDto } from '@/lib/api'
|
||||||
import { NormalizedTelemetry } from './types'
|
import { NormalizedTelemetry } from './types'
|
||||||
|
import { toPersianDigits } from '@/lib/format';
|
||||||
|
|
||||||
// Weather code to description and icon mapping
|
// Weather code to description and icon mapping
|
||||||
export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
|
export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||||
@@ -213,7 +214,7 @@ export function normalizeTelemetryData(telemetry: TelemetryDto[]): NormalizedTel
|
|||||||
return {
|
return {
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
minute: h * 60 + m,
|
minute: h * 60 + m,
|
||||||
label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`,
|
label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`,
|
||||||
soil: Number(t.soilPercent ?? 0),
|
soil: Number(t.soilPercent ?? 0),
|
||||||
temp: Number(t.temperatureC ?? 0),
|
temp: Number(t.temperatureC ?? 0),
|
||||||
hum: Number(t.humidityPercent ?? 0),
|
hum: Number(t.humidityPercent ?? 0),
|
||||||
|
|||||||
Reference in New Issue
Block a user