231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
Area,
|
|
CartesianGrid,
|
|
ComposedChart,
|
|
Legend,
|
|
Line,
|
|
ReferenceArea,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts';
|
|
|
|
/** One plotted line: which field in the row, how to name it, what colour. */
|
|
export interface InsightSeries {
|
|
field: string;
|
|
label: string;
|
|
color: string;
|
|
/** Draw as a dashed reference line — for a target or standard, not a measurement. */
|
|
dashed?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Shade the distance between a measurement and the standard it is judged against.
|
|
*
|
|
* Recharts has no between-two-series area, so the band is two stacked areas: a
|
|
* transparent base up to the lower of the pair, then the gap itself.
|
|
*/
|
|
export interface InsightGapSpec {
|
|
actualField: string;
|
|
targetField: string;
|
|
}
|
|
|
|
export interface InsightTrendChartProps {
|
|
/** FULL history — the analysed window is highlighted, not isolated. */
|
|
rows: Array<Record<string, unknown>>;
|
|
series: InsightSeries[];
|
|
/** Inclusive day range the insight text actually covers. */
|
|
highlight?: { firstDay: number; lastDay: number } | null;
|
|
unit?: string;
|
|
title?: string;
|
|
emptyLabel?: string;
|
|
gap?: InsightGapSpec | null;
|
|
}
|
|
|
|
const dayOf = (row: Record<string, unknown>): number | null => {
|
|
for (const key of ['hari', 'day']) {
|
|
const value = row[key];
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const numOrNull = (value: unknown) =>
|
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
|
|
/**
|
|
* Trend behind an insight, with the analysed period marked.
|
|
*/
|
|
const InsightTrendChart: React.FC<InsightTrendChartProps> = ({
|
|
rows,
|
|
series,
|
|
highlight,
|
|
unit,
|
|
title,
|
|
emptyLabel = 'Belum ada data untuk digambarkan.',
|
|
gap = null,
|
|
}) => {
|
|
const data = (Array.isArray(rows) ? rows : [])
|
|
.map((row) => {
|
|
const day = dayOf(row);
|
|
if (day === null) return null;
|
|
const point: Record<string, number | null> = { hari: day };
|
|
for (const s of series) point[s.field] = numOrNull(row[s.field]);
|
|
if (gap) {
|
|
const actual = numOrNull(row[gap.actualField]);
|
|
const target = numOrNull(row[gap.targetField]);
|
|
point.bandBase = actual !== null && target !== null ? Math.min(actual, target) : null;
|
|
point.bandSize = actual !== null && target !== null ? Math.abs(actual - target) : null;
|
|
}
|
|
return point;
|
|
})
|
|
.filter((point): point is Record<string, number | null> & { hari: number } => point !== null)
|
|
.sort((a, b) => a.hari - b.hari);
|
|
|
|
const drawn = series.filter((s) => data.some((point) => point[s.field] !== null));
|
|
|
|
if (data.length === 0 || drawn.length === 0) {
|
|
return (
|
|
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
|
|
{emptyLabel}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const days = data.map((point) => point.hari);
|
|
const step = Math.max(1, Math.ceil(days.length / 10));
|
|
const ticks = days.filter((_, i) => i % step === 0 || i === days.length - 1);
|
|
|
|
const band =
|
|
highlight && Number.isFinite(highlight.firstDay) && Number.isFinite(highlight.lastDay)
|
|
? highlight
|
|
: null;
|
|
const bandFrom = band
|
|
? band.firstDay === band.lastDay
|
|
? band.firstDay - 0.4
|
|
: band.firstDay
|
|
: 0;
|
|
const bandTo = band ? (band.firstDay === band.lastDay ? band.lastDay + 0.4 : band.lastDay) : 0;
|
|
|
|
const gapFill = (() => {
|
|
if (!gap) return null;
|
|
const last = [...data]
|
|
.reverse()
|
|
.find((point) => point[gap.actualField] !== null && point[gap.targetField] !== null);
|
|
const actual = last?.[gap.actualField];
|
|
const target = last?.[gap.targetField];
|
|
if (typeof actual !== 'number' || typeof target !== 'number' || target === 0) {
|
|
return 'rgba(107, 114, 128, 0.12)';
|
|
}
|
|
const deviation = ((actual - target) / target) * 100;
|
|
if (deviation >= -5) return 'rgba(12, 163, 12, 0.12)';
|
|
if (deviation >= -15) return 'rgba(250, 178, 25, 0.18)';
|
|
return 'rgba(208, 59, 59, 0.14)';
|
|
})();
|
|
|
|
return (
|
|
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
|
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
|
|
{band && (
|
|
<p className="mb-3 text-xs text-gray-500">
|
|
Area berbayang = periode yang dibahas insight
|
|
{band.firstDay === band.lastDay
|
|
? ` (hari ke-${band.lastDay})`
|
|
: ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`}
|
|
</p>
|
|
)}
|
|
<ResponsiveContainer width="100%" height={230}>
|
|
<ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>
|
|
<CartesianGrid stroke="#e5e7eb" vertical={false} />
|
|
{band && (
|
|
<ReferenceArea
|
|
x1={bandFrom}
|
|
x2={bandTo}
|
|
fill="#6b7280"
|
|
fillOpacity={0.12}
|
|
stroke="#6b7280"
|
|
strokeOpacity={0.25}
|
|
/>
|
|
)}
|
|
<XAxis
|
|
dataKey="hari"
|
|
type="number"
|
|
domain={[(days[0] ?? 0) - 0.5, (days[days.length - 1] ?? 0) + 0.5]}
|
|
ticks={ticks}
|
|
allowDecimals={false}
|
|
tick={{ fontSize: 11, fill: '#6b7280' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: '#e5e7eb' }}
|
|
tickFormatter={(value) => `H${value}`}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 11, fill: '#6b7280' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={52}
|
|
tickFormatter={(value) => Number(value).toLocaleString('id-ID')}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
fontSize: 12,
|
|
borderRadius: 8,
|
|
border: '1px solid #d1d5db',
|
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
}}
|
|
labelFormatter={(value) => `Hari ke-${value}`}
|
|
formatter={(value, name) => {
|
|
const label = String(name ?? '');
|
|
if (label === 'bandBase' || label === 'bandSize') return null;
|
|
if (value == null || Array.isArray(value)) return null;
|
|
return [`${Number(value).toLocaleString('id-ID')}${unit ? ` ${unit}` : ''}`, label];
|
|
}}
|
|
/>
|
|
{drawn.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} iconType="plainline" />}
|
|
{gap && gapFill && (
|
|
<>
|
|
<Area
|
|
dataKey="bandBase"
|
|
stackId="gap"
|
|
stroke="none"
|
|
fill="none"
|
|
legendType="none"
|
|
isAnimationActive={false}
|
|
connectNulls
|
|
/>
|
|
<Area
|
|
dataKey="bandSize"
|
|
stackId="gap"
|
|
stroke="none"
|
|
fill={gapFill}
|
|
fillOpacity={1}
|
|
legendType="none"
|
|
isAnimationActive={false}
|
|
connectNulls
|
|
/>
|
|
</>
|
|
)}
|
|
{drawn.map((s) => (
|
|
<Line
|
|
key={s.field}
|
|
type="monotone"
|
|
dataKey={s.field}
|
|
name={s.label}
|
|
stroke={s.color}
|
|
strokeWidth={2}
|
|
strokeDasharray={s.dashed ? '5 4' : undefined}
|
|
dot={!s.dashed && data.length <= 20 ? { r: 3, strokeWidth: 0, fill: s.color } : false}
|
|
activeDot={{ r: 5, strokeWidth: 2, stroke: '#ffffff' }}
|
|
connectNulls
|
|
isAnimationActive={false}
|
|
/>
|
|
))}
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InsightTrendChart;
|