first commit

This commit is contained in:
Alberto-Audrix committed 2026-08-26 13:46:05 +07:00
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;
+79
View File
@@ -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(/,$/, '');
}