first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceDot,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
CHICKEN_CHILL_FACTOR_POINTS,
|
||||
chickenChillFactorFromAge,
|
||||
formatChillFactor,
|
||||
} from './chillFactor.ts';
|
||||
|
||||
const CHILL_FACTOR_BASE_MAX_DAY =
|
||||
CHICKEN_CHILL_FACTOR_POINTS[CHICKEN_CHILL_FACTOR_POINTS.length - 1]!.age;
|
||||
|
||||
export interface IotPanelChillFactorCardProps {
|
||||
dayAge: number | null | undefined;
|
||||
chillFactor: number | null | undefined;
|
||||
}
|
||||
|
||||
const IotPanelChillFactorCard: React.FC<IotPanelChillFactorCardProps> = ({
|
||||
dayAge,
|
||||
chillFactor,
|
||||
}) => {
|
||||
const dayAgeValue =
|
||||
dayAge != null && Number.isFinite(Number(dayAge)) ? Math.max(0, Number(dayAge)) : null;
|
||||
const chartMinDay = CHICKEN_CHILL_FACTOR_POINTS[0]!.age;
|
||||
const chartMaxDay = Math.max(CHILL_FACTOR_BASE_MAX_DAY, Math.ceil(dayAgeValue ?? 0));
|
||||
const chillFactorChartData = Array.from({ length: chartMaxDay - chartMinDay + 1 }, (_, idx) => {
|
||||
const day = chartMinDay + idx;
|
||||
return { day, factor: chickenChillFactorFromAge(day) ?? 0 };
|
||||
});
|
||||
const chillFactorXAxisTicks = Array.from(new Set([0, 7, 14, 21, 28, 35, 42, chartMaxDay])).filter(
|
||||
(day) => day <= chartMaxDay
|
||||
);
|
||||
const currentChillPoint =
|
||||
dayAgeValue !== null && chillFactor != null && Number.isFinite(Number(chillFactor))
|
||||
? { day: dayAgeValue, factor: Number(chillFactor) }
|
||||
: null;
|
||||
const chillFactorChartLabel = currentChillPoint
|
||||
? `Grafik chill factor. Umur ayam hari ${currentChillPoint.day}, chill factor ${formatChillFactor(currentChillPoint.factor)}.`
|
||||
: 'Grafik chill factor belum tersedia karena umur ayam tidak tersedia.';
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-slate-200 bg-gradient-to-br from-sky-50 via-cyan-50 to-blue-50 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-800">Grafik Chill Factor</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Grafik berdasarkan umur ayam untuk estimasi efek angin pada suhu terasa.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white px-3 py-1 text-lg font-bold text-sky-700 shadow-sm">
|
||||
{formatChillFactor(chillFactor)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-56 rounded-lg border border-sky-100 bg-white/75 p-2"
|
||||
role="img"
|
||||
aria-label={chillFactorChartLabel}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chillFactorChartData}
|
||||
margin={{ top: 12, right: 16, left: -18, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
type="number"
|
||||
domain={[chartMinDay, chartMaxDay]}
|
||||
ticks={chillFactorXAxisTicks}
|
||||
tickFormatter={(value) => `${Number(value)}`}
|
||||
stroke="#64748b"
|
||||
fontSize={11}
|
||||
label={{
|
||||
value: 'Umur (hari)',
|
||||
position: 'insideBottom',
|
||||
offset: -2,
|
||||
fill: '#64748b',
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[3, 8]}
|
||||
ticks={[3, 3.5, 4.5, 6, 7, 8]}
|
||||
tickFormatter={(value) => `${Number(value).toString().replace('.', ',')}`}
|
||||
stroke="#64748b"
|
||||
fontSize={11}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(day) => `Hari ${Number(day).toFixed(0)}`}
|
||||
formatter={(value) => [formatChillFactor(Number(value)), 'Chill factor']}
|
||||
/>
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="factor"
|
||||
name="Chill factor"
|
||||
stroke="#0284c7"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 2 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
{currentChillPoint && (
|
||||
<>
|
||||
<ReferenceLine x={currentChillPoint.day} stroke="#0f766e" strokeDasharray="4 4" />
|
||||
<ReferenceDot
|
||||
x={currentChillPoint.day}
|
||||
y={currentChillPoint.factor}
|
||||
r={6}
|
||||
fill="#0f766e"
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default IotPanelChillFactorCard;
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
import { formatChillFactor, type ExperienceCalc } from './chillFactor.ts';
|
||||
|
||||
const DEGREE_C = '\u00B0C';
|
||||
|
||||
const oneDecimal = (value: number | null): string =>
|
||||
value === null ? '--' : formatNumber(value, 1);
|
||||
|
||||
const formatSigned = (value: number | null): string => {
|
||||
if (value === null) return '--';
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${oneDecimal(value)} ${DEGREE_C}`;
|
||||
};
|
||||
|
||||
export interface IotPanelExperienceTempCardProps {
|
||||
calc: ExperienceCalc;
|
||||
apiExperienceTemp: number | null | undefined;
|
||||
}
|
||||
|
||||
const IotPanelExperienceTempCard: React.FC<IotPanelExperienceTempCardProps> = ({
|
||||
calc,
|
||||
apiExperienceTemp,
|
||||
}) => {
|
||||
const calculationRows = [
|
||||
{
|
||||
label: 'Kecepatan angin',
|
||||
value: calc.windSpeed === null ? '--' : `${oneDecimal(calc.windSpeed)} m/s`,
|
||||
note: 'Dari sensor angin kandang',
|
||||
},
|
||||
{
|
||||
label: 'Chill factor',
|
||||
value: formatChillFactor(calc.chillFactor),
|
||||
note: 'Berdasarkan grafik chill factor',
|
||||
},
|
||||
{
|
||||
label: 'Koreksi RH',
|
||||
value: formatSigned(calc.humidityAdjustment),
|
||||
note: '(RH - 70) / 5',
|
||||
},
|
||||
{
|
||||
label: 'Suhu rata-rata',
|
||||
value: calc.averageTemp === null ? '--' : `${oneDecimal(calc.averageTemp)} ${DEGREE_C}`,
|
||||
note: 'Suhu rata-rata dari Panel',
|
||||
},
|
||||
{
|
||||
label: 'Chill effect',
|
||||
value: formatSigned(calc.chillEffect),
|
||||
note: '(Angin x chill factor) - koreksi RH',
|
||||
},
|
||||
];
|
||||
|
||||
const apiDiffers =
|
||||
apiExperienceTemp != null &&
|
||||
calc.experienceTemp != null &&
|
||||
Math.abs(apiExperienceTemp - calc.experienceTemp) > 0.05;
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-sky-200 bg-gradient-to-br from-sky-50 to-blue-50 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
<i className="fa-solid fa-temperature-half mr-2 text-sky-600" />
|
||||
Experience Suhu
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">Suhu yang dirasakan oleh ayam.</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-3xl font-bold text-sky-600">
|
||||
{calc.experienceTemp === null ? 'N/A' : `${oneDecimal(calc.experienceTemp)} ${DEGREE_C}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{calculationRows.map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-sky-100 bg-white/70 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-slate-500">{item.label}</span>
|
||||
<strong className="text-sm text-slate-800">{item.value}</strong>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{item.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg bg-white/60 px-3 py-2 text-xs text-slate-500">
|
||||
Experience suhu = Suhu rata-rata - chill effect.
|
||||
{apiDiffers && apiExperienceTemp != null && (
|
||||
<span className="mt-1 block">
|
||||
Nilai tercatat Panel: {oneDecimal(apiExperienceTemp)} {DEGREE_C}.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default IotPanelExperienceTempCard;
|
||||
@@ -0,0 +1,79 @@
|
||||
export const CHICKEN_CHILL_FACTOR_POINTS = [
|
||||
{ age: 0, factor: 8 },
|
||||
{ age: 1, factor: 8 },
|
||||
{ age: 7, factor: 7 },
|
||||
{ age: 14, factor: 6 },
|
||||
{ age: 21, factor: 4.5 },
|
||||
{ age: 28, factor: 3.5 },
|
||||
{ age: 35, factor: 3.5 },
|
||||
{ age: 42, factor: 3 },
|
||||
] as const;
|
||||
|
||||
export function chickenChillFactorFromAge(day: number | null | undefined): number | null {
|
||||
const age = Number(day);
|
||||
if (!Number.isFinite(age) || age < 0) return null;
|
||||
|
||||
const firstPoint = CHICKEN_CHILL_FACTOR_POINTS[0]!;
|
||||
if (age <= firstPoint.age) return firstPoint.factor;
|
||||
|
||||
for (let idx = 1; idx < CHICKEN_CHILL_FACTOR_POINTS.length; idx += 1) {
|
||||
const previousPoint = CHICKEN_CHILL_FACTOR_POINTS[idx - 1]!;
|
||||
const nextPoint = CHICKEN_CHILL_FACTOR_POINTS[idx]!;
|
||||
if (age > nextPoint.age) continue;
|
||||
|
||||
const ageRange = nextPoint.age - previousPoint.age;
|
||||
if (ageRange <= 0) return nextPoint.factor;
|
||||
|
||||
const progress = (age - previousPoint.age) / ageRange;
|
||||
return previousPoint.factor + (nextPoint.factor - previousPoint.factor) * progress;
|
||||
}
|
||||
|
||||
return CHICKEN_CHILL_FACTOR_POINTS[CHICKEN_CHILL_FACTOR_POINTS.length - 1]!.factor;
|
||||
}
|
||||
|
||||
export type ExperienceCalc = {
|
||||
averageTemp: number | null;
|
||||
windSpeed: number | null;
|
||||
humidity: number | null;
|
||||
humidityAdjustment: number | null;
|
||||
chillFactor: number | null;
|
||||
chillEffect: number | null;
|
||||
experienceTemp: number | null;
|
||||
};
|
||||
|
||||
export function computeExperienceCalc(args: {
|
||||
averageTemp: number | null | undefined;
|
||||
windSpeed: number | null | undefined;
|
||||
humidity: number | null | undefined;
|
||||
dayAge: number | null | undefined;
|
||||
}): ExperienceCalc {
|
||||
const toNumber = (value: number | null | undefined): number | null =>
|
||||
value != null && Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
|
||||
const averageTemp = toNumber(args.averageTemp);
|
||||
const windSpeed = toNumber(args.windSpeed);
|
||||
const humidity = toNumber(args.humidity);
|
||||
const chillFactor = chickenChillFactorFromAge(args.dayAge);
|
||||
const humidityAdjustment = humidity === null ? null : (humidity - 70) / 5;
|
||||
const chillEffect =
|
||||
windSpeed !== null && chillFactor !== null && humidityAdjustment !== null
|
||||
? windSpeed * chillFactor - humidityAdjustment
|
||||
: null;
|
||||
const experienceTemp =
|
||||
averageTemp !== null && chillEffect !== null ? averageTemp - chillEffect : null;
|
||||
|
||||
return {
|
||||
averageTemp,
|
||||
windSpeed,
|
||||
humidity,
|
||||
humidityAdjustment,
|
||||
chillFactor,
|
||||
chillEffect,
|
||||
experienceTemp,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatChillFactor(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '--';
|
||||
return Number(value).toFixed(3).replace('.', ',').replace(/0+$/, '').replace(/,$/, '');
|
||||
}
|
||||
Reference in new issue
Block a user