This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = 'greenhome-1766173610406';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1766173610406';
|
||||
const CACHE_NAME = 'greenhome-1766182760520';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1766182760520';
|
||||
|
||||
// Static assets to cache on install
|
||||
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 [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 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"
|
||||
>
|
||||
{{
|
||||
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: (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client"
|
||||
import React from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Line } from 'react-chartjs-2'
|
||||
import {
|
||||
Chart,
|
||||
@@ -11,15 +11,40 @@ import {
|
||||
Tooltip,
|
||||
Filler
|
||||
} 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)
|
||||
|
||||
// تابع تبدیل ارقام انگلیسی به فارسی
|
||||
function toPersianDigits(str: string | number): string {
|
||||
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
|
||||
return str.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
|
||||
}
|
||||
// Constants
|
||||
const FONT_FAMILY = "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
||||
const FONT_SIZE = 10
|
||||
|
||||
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 }
|
||||
|
||||
@@ -37,9 +62,9 @@ type Props = {
|
||||
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 (
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
@@ -51,48 +76,16 @@ export function Panel({ title, children }: { title: string; children: React.Reac
|
||||
}
|
||||
|
||||
export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||
// Find gap annotations based on null values in data
|
||||
const gapAnnotations = React.useMemo(() => {
|
||||
const annotations: Record<string, {
|
||||
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
|
||||
if (series.length > 0) {
|
||||
const data = series[0].data
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
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])
|
||||
|
||||
// Check if mobile device
|
||||
const isMobile = useMemo(() => {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||
}, [])
|
||||
|
||||
// Get responsive config
|
||||
const responsiveConfig = useMemo(() => {
|
||||
return isMobile ? RESPONSIVE_CONFIG.mobile : RESPONSIVE_CONFIG.desktop
|
||||
}, [isMobile])
|
||||
|
||||
// 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
|
||||
const hourConfig = React.useMemo(() => {
|
||||
@@ -130,135 +123,191 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||
}, [labels])
|
||||
|
||||
// Create map of label index to hour
|
||||
const labelHours = React.useMemo(() => {
|
||||
const labelHours = useMemo(() => {
|
||||
return labels.map(label => {
|
||||
if (!label) return null
|
||||
const [hour] = label.split(':').map(Number)
|
||||
return hour
|
||||
})
|
||||
}, [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 (
|
||||
<div className="persian-number -mx-2">
|
||||
<Line
|
||||
data={{
|
||||
labels,
|
||||
datasets: series.map(s => ({
|
||||
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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
datasets,
|
||||
}}
|
||||
options={chartOptions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ export const ChartsTab = memo(function ChartsTab({
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{charts.map(chart => (
|
||||
<Panel key={chart.key} title={chart.title}>
|
||||
<Panel key={chart.key} title={chart.title} id={`chart-${chart.key}`}>
|
||||
<LineChart
|
||||
labels={chartLabels}
|
||||
series={[
|
||||
|
||||
@@ -10,9 +10,10 @@ type SummaryCardProps = {
|
||||
minValue: number
|
||||
maxValue: 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]
|
||||
if (!config) return null
|
||||
|
||||
@@ -35,7 +36,10 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data }: S
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>
|
||||
|
||||
@@ -14,9 +14,10 @@ type SummaryTabProps = {
|
||||
lux: number[]
|
||||
forecastWeather?: WeatherData | null
|
||||
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 alertsCount = useMemo(() => {
|
||||
@@ -104,6 +105,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
minValue={temperatureSummary.min}
|
||||
maxValue={temperatureSummary.max}
|
||||
data={temperature}
|
||||
onClick={() => onCardClick?.('temperature')}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="humidity"
|
||||
@@ -111,13 +113,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
minValue={humiditySummary.min}
|
||||
maxValue={humiditySummary.max}
|
||||
data={humidity}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="soil"
|
||||
currentValue={soilSummary.current}
|
||||
minValue={soilSummary.min}
|
||||
maxValue={soilSummary.max}
|
||||
data={soil}
|
||||
onClick={() => onCardClick?.('humidity')}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="gas"
|
||||
@@ -125,6 +121,15 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
minValue={gasSummary.min}
|
||||
maxValue={gasSummary.max}
|
||||
data={gas}
|
||||
onClick={() => onCardClick?.('gas')}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="soil"
|
||||
currentValue={soilSummary.current}
|
||||
minValue={soilSummary.min}
|
||||
maxValue={soilSummary.max}
|
||||
data={soil}
|
||||
onClick={() => onCardClick?.('soil')}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="lux"
|
||||
@@ -132,6 +137,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
minValue={luxSummary.min}
|
||||
maxValue={luxSummary.max}
|
||||
data={lux}
|
||||
onClick={() => onCardClick?.('lux')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ import { NormalizedTelemetry } from './types'
|
||||
|
||||
export const BASE_CHART_CONFIGS: ChartConfig[] = [
|
||||
{
|
||||
key: 'soil',
|
||||
title: 'رطوبت خاک',
|
||||
seriesLabel: 'رطوبت خاک (%)',
|
||||
color: '#16a34a',
|
||||
bgColor: '#dcfce7',
|
||||
getValue: (t: NormalizedTelemetry) => t.soil,
|
||||
key: 'temp',
|
||||
title: 'دما',
|
||||
seriesLabel: 'دما (°C)',
|
||||
color: '#ef4444',
|
||||
bgColor: '#fee2e2',
|
||||
getValue: (t: NormalizedTelemetry) => t.temp,
|
||||
yAxisMin: 0,
|
||||
yAxisMax: 100,
|
||||
},
|
||||
{
|
||||
key: 'hum',
|
||||
@@ -23,13 +22,24 @@ export const BASE_CHART_CONFIGS: ChartConfig[] = [
|
||||
yAxisMax: 100,
|
||||
},
|
||||
{
|
||||
key: 'temp',
|
||||
title: 'دما',
|
||||
seriesLabel: 'دما (°C)',
|
||||
color: '#ef4444',
|
||||
bgColor: '#fee2e2',
|
||||
getValue: (t: NormalizedTelemetry) => t.temp,
|
||||
key: 'gas',
|
||||
title: 'گاز CO',
|
||||
seriesLabel: 'CO (ppm)',
|
||||
color: '#f59e0b',
|
||||
bgColor: '#fef3c7',
|
||||
getValue: (t: NormalizedTelemetry) => t.gas,
|
||||
yAxisMin: 0,
|
||||
yAxisMax: 100,
|
||||
},
|
||||
{
|
||||
key: 'soil',
|
||||
title: 'رطوبت خاک',
|
||||
seriesLabel: 'رطوبت خاک (%)',
|
||||
color: '#16a34a',
|
||||
bgColor: '#dcfce7',
|
||||
getValue: (t: NormalizedTelemetry) => t.soil,
|
||||
yAxisMin: 0,
|
||||
yAxisMax: 100,
|
||||
},
|
||||
{
|
||||
key: 'lux',
|
||||
@@ -40,15 +50,5 @@ export const BASE_CHART_CONFIGS: ChartConfig[] = [
|
||||
getValue: (t: NormalizedTelemetry) => t.lux,
|
||||
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(
|
||||
() => filteredTelemetry.map(t => t.label),
|
||||
() => {
|
||||
const labels = filteredTelemetry
|
||||
.map(t => t.label)
|
||||
|
||||
return labels
|
||||
},
|
||||
[filteredTelemetry]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react'
|
||||
import { TelemetryDto } from '@/lib/api'
|
||||
import { NormalizedTelemetry } from './types'
|
||||
import { toPersianDigits } from '@/lib/format';
|
||||
|
||||
// Weather code to description and icon mapping
|
||||
export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
@@ -213,7 +214,7 @@ export function normalizeTelemetryData(telemetry: TelemetryDto[]): NormalizedTel
|
||||
return {
|
||||
timestamp: ts,
|
||||
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),
|
||||
temp: Number(t.temperatureC ?? 0),
|
||||
hum: Number(t.humidityPercent ?? 0),
|
||||
|
||||
Reference in New Issue
Block a user