[st] feat: 통계 검증 상세 차트 구현

This commit is contained in:
2026-03-12 14:59:34 +09:00
parent bf577ddc81
commit 969d24736d
4 changed files with 110 additions and 96 deletions
@@ -5,6 +5,7 @@ import { ValueType } from "realgrid";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "@/entities/code/api/code-service";
import { useStAnlsRsltList } from "@/entities/statistics/api/queries";
import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api";
import type { StAnlsRsltResponse } from "@/entities/statistics/model/statistics-analysis-result-interface";
import {
@@ -80,22 +81,10 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
);
}, [regionCodes, selectedSido]);
const { data: resultData, isLoading } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"list",
anlsSn,
vrfcTrgtSn,
],
queryFn: () =>
stAnlsRsltApi.getResultList({
anlsSn,
vrfcTrgtSn: Number(vrfcTrgtSn),
page: 0,
size: 1000,
}),
enabled: !!vrfcTrgtSn,
});
const { data: resultData, isLoading } = useStAnlsRsltList(
anlsSn,
vrfcTrgtSn,
);
const rowData = useMemo(() => {
let data = (resultData?.content ?? []) as StAnlsRsltResponse[];
@@ -4,6 +4,8 @@ import { Scatter } from "react-chartjs-2";
import { useQuery } from "@tanstack/react-query";
import {
Chart as ChartJS,
type ChartData,
type ChartOptions,
Legend,
LinearScale,
LineController,
@@ -11,12 +13,16 @@ import {
PointElement,
ScatterController,
Tooltip,
type TooltipItem,
} from "chart.js";
import zoomPlugin from "chartjs-plugin-zoom";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "@/entities/code/api/code-service";
import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api";
import {
useStAnlsChartData,
useStAnlsRsltList,
} from "@/entities/statistics/api/queries";
import type { StAnlsRsltResponse } from "@/entities/statistics/model/statistics-analysis-result-interface";
import { LoadingWithAnimation } from "@common/components";
@@ -37,6 +43,15 @@ interface ScatterChartProps {
selectedSido: string | null;
}
interface LinePoint {
x: number;
y: number;
}
interface DataPoint extends LinePoint {
item: StAnlsRsltResponse;
}
const ScatterChart: React.FC<ScatterChartProps> = ({
anlsSn,
vrfcTrgtSn,
@@ -54,110 +69,79 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
);
}, [regionCodes, selectedSido]);
const { data: resultData, isLoading: isLoadingGrid } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"list",
anlsSn,
vrfcTrgtSn,
],
queryFn: () =>
stAnlsRsltApi.getResultList({
anlsSn,
vrfcTrgtSn: Number(vrfcTrgtSn),
page: 0,
size: 1000,
}),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
const { data: chartData, isLoading: isLoadingChart } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"chart",
anlsSn,
vrfcTrgtSn,
],
queryFn: () => stAnlsRsltApi.getChartData(anlsSn, Number(vrfcTrgtSn)),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
const { data: resultData, isLoading: isLoadingGrid } = useStAnlsRsltList(
anlsSn,
vrfcTrgtSn,
);
const { data: chartData, isLoading: isLoadingChart } = useStAnlsChartData(
anlsSn,
vrfcTrgtSn,
);
const isLoading = isLoadingGrid || isLoadingChart;
const chartConfig = useMemo(() => {
if (!resultData || !chartData) return null;
const rawPoints = (resultData.content ?? []) as StAnlsRsltResponse[];
const rawPoints = resultData.content ?? [];
const boundaries = chartData.list ?? [];
const sortedBoundaries = [...boundaries].sort(
(a, b) => a.xPrev - b.xPrev,
(a, b) => a.xprev - b.xprev,
);
const upperLineData = sortedBoundaries.map((b) => ({
x: b.xPrev,
y: b.yUpper,
}));
const lowerLineData = sortedBoundaries.map((b) => ({
x: b.xPrev,
y: b.yLower,
const upperLineData: LinePoint[] = sortedBoundaries.map((b) => ({
x: b.yupper,
y: b.xprev,
}));
const normalPoints: {
x: number;
y: number;
item: StAnlsRsltResponse;
}[] = [];
const outlierPoints: {
x: number;
y: number;
item: StAnlsRsltResponse;
}[] = [];
const lowerLineData: LinePoint[] = sortedBoundaries.map((b) => ({
x: b.xprev,
y: b.ylower,
}));
const normalPoints: DataPoint[] = [];
const outlierPoints: DataPoint[] = [];
rawPoints.forEach((item) => {
if (selectedSidoName && item.sido !== selectedSidoName) {
return;
}
const pt = {
const pt: DataPoint = {
x: Number(item.prvyrVl ?? 0),
y: Number(item.cryrVl ?? 0),
item,
};
if (item.otlrYn.toLowerCase() === "y") {
if (item.otlrYn === "0") {
normalPoints.push(pt);
} else {
outlierPoints.push(pt);
}
});
const data = {
const data: ChartData<"scatter"> = {
datasets: [
{
label: "상한선",
data: upperLineData,
type: "line" as const,
showLine: true,
borderColor: "rgba(255, 99, 132, 0.8)",
borderWidth: 2,
fill: false,
pointRadius: 0,
tension: 0.1,
},
{
label: "하한선",
data: lowerLineData,
type: "line" as const,
showLine: true,
borderColor: "rgba(54, 162, 235, 0.8)",
borderWidth: 2,
fill: false,
pointRadius: 0,
tension: 0.1,
},
{
label: "이상치",
data: outlierPoints,
backgroundColor: "rgba(255, 99, 132, 0.7)",
pointRadius: 5,
pointRadius: 4,
pointHoverRadius: 8,
},
{
@@ -165,19 +149,19 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
data: normalPoints,
backgroundColor: "rgba(75, 192, 192, 0.7)",
pointRadius: 4,
pointHoverRadius: 7,
pointHoverRadius: 8,
},
],
};
const options = {
const options: ChartOptions<"scatter"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
zoom: {
pan: {
enabled: true,
mode: "xy" as const,
mode: "xy",
},
zoom: {
wheel: {
@@ -186,36 +170,34 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
pinch: {
enabled: true,
},
mode: "xy" as const,
mode: "xy",
},
},
tooltip: {
callbacks: {
label: (context: any) => {
if (context.dataset.type === "line") {
return `${context.dataset.label}: (${context.parsed.x.toFixed(2)}, ${context.parsed.y.toFixed(2)})`;
label: (context: TooltipItem<"scatter">) => {
const dataset = context.dataset;
if (dataset.showLine) {
return `${context.dataset.label}: (${context?.parsed?.x?.toFixed(2) ?? "-"}, ${context?.parsed?.y?.toFixed(2) ?? "-"})`;
}
const pt = context.raw as {
x: number;
y: number;
item: StAnlsRsltResponse;
};
return `${pt.item.sido} ${pt.item.gugun}: 이전(${pt.x}), 당해(${pt.y})`;
const pt = (context.raw as DataPoint) ?? {};
return `${pt?.item?.sido ?? "시/도"} ${pt?.item?.gugun ?? "구/군"}: 이전(${pt?.x ?? "-"}), 당해(${pt?.y ?? "-"})`;
},
},
},
},
scales: {
x: {
type: "linear" as const,
position: "bottom" as const,
type: "linear",
position: "bottom",
title: {
display: true,
text: "이전년도 값",
},
},
y: {
type: "linear" as const,
type: "linear",
title: {
display: true,
text: "당해년도 값",
@@ -242,8 +224,8 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
) : chartConfig ? (
<div className="h-[48rem] w-full">
<Scatter
data={chartConfig.data as any}
options={chartConfig.options as any}
data={chartConfig.data}
options={chartConfig.options}
/>
</div>
) : (
@@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { stAnlsRsltApi } from "./statistics-analysis-result-api";
export const useStAnlsRsltList = (
anlsSn: number,
vrfcTrgtSn: string | null,
) => {
return useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"list",
anlsSn,
vrfcTrgtSn,
],
queryFn: () =>
stAnlsRsltApi.getResultList({
anlsSn,
vrfcTrgtSn: Number(vrfcTrgtSn),
page: 0,
size: 1000,
}),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
};
export const useStAnlsChartData = (
anlsSn: number,
vrfcTrgtSn: string | null,
) => {
return useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"chart",
anlsSn,
vrfcTrgtSn,
],
queryFn: () => stAnlsRsltApi.getChartData(anlsSn, Number(vrfcTrgtSn)),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
};
@@ -35,9 +35,9 @@ export interface StAnlsRsltSearchParams extends PageableParams {
export type StAnlsRsltListApiResponse = PageableList<StAnlsRsltResponse>;
export interface ChartBoundaryPointDto {
xPrev: number; // 전년도 값 (x축)
yLower: number; // 이상치 하한 당년값 (y축)
yUpper: number; // 이상치 상한 당년값 (y축)
xprev: number; // 전년도 값 (x축)
ylower: number; // 이상치 하한 당년값 (y축)
yupper: number; // 이상치 상한 당년값 (y축)
}
export interface ChartRetDto {