diff --git a/src/entities/chart/index.ts b/src/entities/chart/index.ts new file mode 100644 index 0000000..fcb073f --- /dev/null +++ b/src/entities/chart/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/entities/chart/types/chart.type.ts b/src/entities/chart/types/chart.type.ts new file mode 100644 index 0000000..98cee81 --- /dev/null +++ b/src/entities/chart/types/chart.type.ts @@ -0,0 +1,21 @@ +/** + * 차트 관련 entities + */ + +// 차트 라인 데이터 +export interface ChartLine { + key: string; + name: string; +} + +// 차트 데이터 포인트 +export interface ChartDataPoint { + [key: string]: string | number; +} + +// 차트 데이터 +export interface ChartData { + title: string; + lines: ChartLine[]; + data: ChartDataPoint[]; +} diff --git a/src/entities/chart/types/index.ts b/src/entities/chart/types/index.ts new file mode 100644 index 0000000..45df87b --- /dev/null +++ b/src/entities/chart/types/index.ts @@ -0,0 +1,2 @@ +export * from './chart.type'; +export * from './risk-analysis.type'; diff --git a/src/entities/chart/types/risk-analysis.type.ts b/src/entities/chart/types/risk-analysis.type.ts new file mode 100644 index 0000000..f0fd2ce --- /dev/null +++ b/src/entities/chart/types/risk-analysis.type.ts @@ -0,0 +1,58 @@ +/** + * 위험도 분석 관련 entities + */ + +// 위험 요인 +export type RiskFactor = { + name: string; + percent: number; +}; + +// 위험도 요약 +export type RiskSummary = { + score: number; + factors: RiskFactor[]; +}; + +// 임대인 정보 +export type Landlord = { + landlordId: number; + name: string; + normalizedKey: string; + ownedCount: number; + grade: string; + createdAt: string; + updatedAt: string; +}; + +// 임대인 신뢰도 +export type LandlordTrust = { + trustScore: number; + subrogationCount: number; + arrearsCount: number; + litigationCount: number; + ownedUnsoldCount: number; + grade: string; +}; + +// 임대인 소유 매물 +export type LandlordPlace = { + placeId: number; + label: string; + address: string; + addressDetail: string; +}; + +// 위험도 분석 응답 데이터 +export type RiskAnalysisResponse = { + data: { + riskSummary: RiskSummary; + landlord: Landlord; + landlordTrust: LandlordTrust; + landlordPlaces: LandlordPlace[]; + }; + status: string; + serverDateTime: string; + errorCode: string | null; + errorMessage: string | null; +}; diff --git a/src/entities/index.ts b/src/entities/index.ts index e69de29..5a0bd7b 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -0,0 +1,3 @@ +export * from './auth'; +export * from './chart'; +export * from './risk-analysis'; diff --git a/src/features/main/components/common/ChartAxis.tsx b/src/features/main/components/common/ChartAxis.tsx new file mode 100644 index 0000000..b7dd6e3 --- /dev/null +++ b/src/features/main/components/common/ChartAxis.tsx @@ -0,0 +1,18 @@ +import { XAxis, YAxis } from 'recharts'; + +import { useChartConfig } from '../../hooks'; + +type Props = { + dataKey: string; +}; + +export const ChartAxis = ({ dataKey }: Props) => { + const { xAxisConfig, yAxisConfig } = useChartConfig(); + + return ( + <> + + + + ); +}; diff --git a/src/features/main/components/common/ChartGrid.tsx b/src/features/main/components/common/ChartGrid.tsx new file mode 100644 index 0000000..0b021b7 --- /dev/null +++ b/src/features/main/components/common/ChartGrid.tsx @@ -0,0 +1,16 @@ +import { CartesianGrid } from 'recharts'; + +import { useChartConfig } from '../../hooks'; + +export const ChartGrid = () => { + const { gridConfig } = useChartConfig(); + + return ( + <> + {/* 가로선 - 실선 */} + + {/* 세로선 - 점선 */} + + + ); +}; diff --git a/src/features/main/components/common/ChartLineItem.tsx b/src/features/main/components/common/ChartLineItem.tsx new file mode 100644 index 0000000..a082bfb --- /dev/null +++ b/src/features/main/components/common/ChartLineItem.tsx @@ -0,0 +1,17 @@ +import { Line } from 'recharts'; + +import type { ChartLine } from '@/entities'; + +import { useChartConfig } from '../../hooks'; + +type Props = { + line: ChartLine; + index: number; +}; + +export const ChartLineItem = ({ line, index }: Props) => { + const { getLineConfig } = useChartConfig(); + const config = getLineConfig(line, index); + + return ; +}; diff --git a/src/features/main/components/common/LegendLine.tsx b/src/features/main/components/common/LegendLine.tsx new file mode 100644 index 0000000..c0dde2f --- /dev/null +++ b/src/features/main/components/common/LegendLine.tsx @@ -0,0 +1,25 @@ +import type { ChartLine } from '@/entities'; + +import { useChartConfig } from '../../hooks'; + +type Props = { + lines: ChartLine[]; +}; + +export const LegendLine = ({ lines }: Props) => { + const { getLegendItemConfig } = useChartConfig(); + + return ( +
    + {lines.map((line, index) => { + const config = getLegendItemConfig(line, index); + return ( +
  • +
    + {config.name} +
  • + ); + })} +
+ ); +}; diff --git a/src/features/main/components/common/Needle.tsx b/src/features/main/components/common/Needle.tsx new file mode 100644 index 0000000..9e62e85 --- /dev/null +++ b/src/features/main/components/common/Needle.tsx @@ -0,0 +1,22 @@ +import { NeedleIcon } from '@/shared'; + +type Props = { + needleAngle: number; +}; + +export const Needle = ({ needleAngle }: Props) => { + const correctedAngle = needleAngle - 90; + + return ( +
+
+ + +
+ ); +}; diff --git a/src/features/main/components/common/index.ts b/src/features/main/components/common/index.ts index e69de29..e70a782 100644 --- a/src/features/main/components/common/index.ts +++ b/src/features/main/components/common/index.ts @@ -0,0 +1,5 @@ +export * from './ChartAxis'; +export * from './ChartGrid'; +export * from './ChartLineItem'; +export * from './LegendLine'; +export * from './Needle'; diff --git a/src/features/main/components/features/chart/ChartBox.tsx b/src/features/main/components/features/chart/ChartBox.tsx index 225d833..42c960f 100644 --- a/src/features/main/components/features/chart/ChartBox.tsx +++ b/src/features/main/components/features/chart/ChartBox.tsx @@ -1,87 +1,24 @@ -import { - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; +import { Legend, LineChart, ResponsiveContainer, Tooltip } from 'recharts'; -import { getChartColor, getChartColorClass } from '@/shared'; +import type { ChartData } from '@/entities'; -type DataPoint = { - year: string; - [key: string]: string | number; -}; +import { ChartAxis, ChartGrid, ChartLineItem, LegendLine } from '../../common'; type Props = { - data: DataPoint[]; - lines: { - key: string; - name: string; - showDot?: boolean; - }[]; - title?: string; - height?: number; - yAxisFormatter?: (value: number) => string; - tooltipFormatter?: (value: number, name: string) => [string, string]; - labelFormatter?: (label: string) => string; + chartData: ChartData; }; -export const ChartBox = ({ - data, - lines, - title, - yAxisFormatter = (value) => value.toFixed(2), - tooltipFormatter = (value, name) => [`${value}%`, name], - labelFormatter = (label) => `20${label}년`, -}: Props) => { +export const ChartBox = ({ chartData }: Props) => { + const { title, lines, data } = chartData; + return ( -
- {title && ( -

{title}

- )} +
+

{title}

- - {/* 가로선 - 실선 */} - - {/* 세로선 - 점선 */} - - - - + + + + { if (!payload || payload.length === 0) return null; - // lines 배열의 순서대로 범례를 생성 - return ( -
    - {lines.map((line, index) => ( -
  • -
    - {line.name} -
  • - ))} -
- ); + return ; }} /> - {lines.map((line, index) => { - const color = getChartColor(index); - return ( - - ); - })} + {lines.map((line, index) => ( + + ))}
diff --git a/src/features/main/components/features/chart/RiskChartBox.tsx b/src/features/main/components/features/chart/RiskChartBox.tsx new file mode 100644 index 0000000..559e0bf --- /dev/null +++ b/src/features/main/components/features/chart/RiskChartBox.tsx @@ -0,0 +1,65 @@ +import { Pie, PieChart, ResponsiveContainer } from 'recharts'; + +import { + type GaugeData, + getGaugeAngle, + getGaugeData, + getRiskBoxShadowClass, + getRiskColorClass, + getRiskScoreColorClass, +} from '@/shared'; + +import { Needle } from '../../common'; + +type Props = { + riskScore: number; +}; + +export const RiskChartBox = ({ riskScore }: Props) => { + const gaugeData: GaugeData = getGaugeData(riskScore); + + const scoreColorClass = getRiskScoreColorClass(riskScore); + + const needleAngle = getGaugeAngle(riskScore); + + return ( +
+

전세 계약 최종 위험도

+

+ 위험 점수 : + {riskScore} + +

+ +
+ + + + + + + {/* 게이지 바늘 */} + + + {/* 위험도 레이블 */} +
+ {gaugeData.label} +
+
+
+ ); +}; diff --git a/src/features/main/components/features/chart/RiskFactorsBox.tsx b/src/features/main/components/features/chart/RiskFactorsBox.tsx new file mode 100644 index 0000000..3788324 --- /dev/null +++ b/src/features/main/components/features/chart/RiskFactorsBox.tsx @@ -0,0 +1,27 @@ +import type { RiskFactor } from '@/entities'; + +type Props = { + riskFactors: RiskFactor[]; +}; + +export const RiskFactorsBox = ({ riskFactors }: Props) => { + return ( +
+

주요 위험 요인

+
+ {riskFactors.map((factor) => ( +
+
+ + {factor.name} +
+ {factor.percent} + % +
+
+
+ ))} +
+
+ ); +}; diff --git a/src/features/main/components/features/chart/index.ts b/src/features/main/components/features/chart/index.ts index a08fef3..a51f5bc 100644 --- a/src/features/main/components/features/chart/index.ts +++ b/src/features/main/components/features/chart/index.ts @@ -1 +1,3 @@ -export * from './ChartBox'; +export { ChartBox } from './ChartBox'; +export { RiskChartBox } from './RiskChartBox'; +export { RiskFactorsBox } from './RiskFactorsBox'; diff --git a/src/features/main/hooks/index.ts b/src/features/main/hooks/index.ts index e69de29..6eb5c2e 100644 --- a/src/features/main/hooks/index.ts +++ b/src/features/main/hooks/index.ts @@ -0,0 +1 @@ +export * from './useChartConfig'; diff --git a/src/features/main/hooks/useChartConfig.ts b/src/features/main/hooks/useChartConfig.ts new file mode 100644 index 0000000..0bcb7ad --- /dev/null +++ b/src/features/main/hooks/useChartConfig.ts @@ -0,0 +1,94 @@ +/** + * 차트 설정 관련 hook + */ +import { getChartColor, getChartColorClass } from '@/shared/utils/chart/chart-colors'; + +import type { ChartLine } from '@/entities'; + +export const useChartConfig = () => { + // 차트 기본 설정 + const chartConfig = { + margin: { top: 5, right: 30, left: 0, bottom: 5 }, + height: 300, + }; + + // X축 설정 + const xAxisConfig = { + stroke: '#BDBDBF', + fontSize: 14, + tickLine: false, + axisLine: true, + tickMargin: 10, + }; + + // Y축 설정 + const yAxisConfig = { + stroke: '#BDBDBF', + fontSize: 12, + tickLine: false, + axisLine: false, + }; + + // 그리드 설정 + const gridConfig = { + horizontal: { + horizontal: true, + vertical: false, + strokeDasharray: '0', + stroke: '#BDBDBF', + }, + vertical: { + horizontal: false, + vertical: true, + strokeDasharray: '3 3', + stroke: '#f0f0f0', + }, + }; + + // 범례 설정 + const legendConfig = { + verticalAlign: 'top' as const, + align: 'left' as const, + height: 36, + wrapperStyle: { + paddingBottom: '10px', + marginLeft: '30px', + marginTop: '-20px', + }, + }; + + // 라인 설정 생성 함수 + const getLineConfig = (line: ChartLine, index: number) => ({ + type: 'monotone' as const, + dataKey: line.key, + stroke: getChartColor(index), + strokeWidth: 2, + dot: { + fill: getChartColor(index), + strokeWidth: 2, + r: 4, + }, + activeDot: { + r: 6, + stroke: getChartColor(index), + strokeWidth: 2, + }, + }); + + // 범례 아이템 설정 생성 함수 + const getLegendItemConfig = (line: ChartLine, index: number) => ({ + key: line.key, + colorClass: getChartColorClass(index), + name: line.name, + }); + + return { + chartConfig, + xAxisConfig, + yAxisConfig, + gridConfig, + legendConfig, + getLineConfig, + getLegendItemConfig, + }; +}; diff --git a/src/features/main/mock/chart-data.mock.ts b/src/features/main/mock/chart-data.mock.ts index f42fc2d..024ef33 100644 --- a/src/features/main/mock/chart-data.mock.ts +++ b/src/features/main/mock/chart-data.mock.ts @@ -1,84 +1,79 @@ -// 임의의 데이터 생성 (2020-2025년) +/** + * 차트 임시 데이터 + */ +import type { ChartData } from '@/entities'; -// 최근 6개월 가격 지수 변동률 - -const CHART_DATA = { - // 최근 6개월 가격 지수 변동률 - RECENT_PRICE_FLUCTUATIONS: [ - { year: '20', 기준치: 0, 변동률: 2.5 }, - { year: '21', 기준치: 0, 변동률: 1.8 }, - { year: '22', 기준치: 0, 변동률: 0.9 }, - { year: '23', 기준치: 0, 변동률: 3.2 }, - { year: '24', 기준치: 0, 변동률: 1.5 }, - { year: '25', 기준치: 0, 변동률: 2.1 }, - ], - // LTV 전세금, 매매가 그래프 - REAL_ESTATE_PRICE_DATA: [ - { year: '20', 전세금: 2.8, 매매가: 3.2, 임대료: 1.5 }, - { year: '21', 전세금: 3.1, 매매가: 3.5, 임대료: 1.8 }, - { year: '22', 전세금: 2.5, 매매가: 2.9, 임대료: 1.2 }, - { year: '23', 전세금: 3.8, 매매가: 4.1, 임대료: 2.1 }, - { year: '24', 전세금: 3.0, 매매가: 3.3, 임대료: 1.6 }, - { year: '25', 전세금: 3.4, 매매가: 3.7, 임대료: 1.9 }, - ], - // 해당 지역 대위변제 발생 빈도 및 증가율 - REGIONAL_INCREASE_RATE: [ - { year: '20', 증가율: 1.2 }, - { year: '21', 증가율: 1.8 }, - { year: '22', 증가율: 0.9 }, - { year: '23', 증가율: 2.5 }, - { year: '24', 증가율: 1.6 }, - { year: '25', 증가율: 2.1 }, - ], -}; - -export const CHART_DATA_CONFIGS = [ +// 차트 데이터 배열 +export const CHART_DATA_LIST: ChartData[] = [ { - title: '최근 6개월 가격 지수 변동률', - data: CHART_DATA.RECENT_PRICE_FLUCTUATIONS, + title: '부동산 가격 변동 추이', lines: [ - { - key: '기준치', - name: '기준치', - showDot: false, - }, - { - key: '변동률', - name: '변동률', - showDot: true, - }, + { key: '전세금', name: '전세금' }, + { key: '매매가', name: '매매가' }, + { key: '임대료', name: '임대료' }, + ], + data: [ + { month: '1월', 전세금: 400, 매매가: 800, 임대료: 50 }, + { month: '2월', 전세금: 420, 매매가: 820, 임대료: 52 }, + { month: '3월', 전세금: 450, 매매가: 850, 임대료: 55 }, + { month: '4월', 전세금: 480, 매매가: 880, 임대료: 58 }, + { month: '5월', 전세금: 500, 매매가: 900, 임대료: 60 }, + { month: '6월', 전세금: 520, 매매가: 920, 임대료: 62 }, + { month: '7월', 전세금: 550, 매매가: 950, 임대료: 65 }, + { month: '8월', 전세금: 580, 매매가: 980, 임대료: 68 }, + { month: '9월', 전세금: 600, 매매가: 1000, 임대료: 70 }, + { month: '10월', 전세금: 620, 매매가: 1020, 임대료: 72 }, + { month: '11월', 전세금: 650, 매매가: 1050, 임대료: 75 }, + { month: '12월', 전세금: 680, 매매가: 1080, 임대료: 78 }, ], }, { - title: 'LTV 전세금, 매매가 그래프', - data: CHART_DATA.REAL_ESTATE_PRICE_DATA, + title: '지역별 가격 변동률', lines: [ - { - key: '전세금', - name: '전세금', - showDot: true, - }, - { - key: '매매가', - name: '매매가', - showDot: true, - }, - { - key: '임대료', - name: '임대료', - showDot: true, - }, + { key: '강남구', name: '강남구' }, + { key: '서초구', name: '서초구' }, + { key: '마포구', name: '마포구' }, + { key: '송파구', name: '송파구' }, + ], + data: [ + { month: '1월', 강남구: 2.1, 서초구: 1.8, 마포구: 1.5, 송파구: 1.9 }, + { month: '2월', 강남구: 2.3, 서초구: 2.0, 마포구: 1.7, 송파구: 2.1 }, + { month: '3월', 강남구: 2.5, 서초구: 2.2, 마포구: 1.9, 송파구: 2.3 }, + { month: '4월', 강남구: 2.8, 서초구: 2.5, 마포구: 2.1, 송파구: 2.6 }, + { month: '5월', 강남구: 3.0, 서초구: 2.7, 마포구: 2.3, 송파구: 2.8 }, + { month: '6월', 강남구: 3.2, 서초구: 2.9, 마포구: 2.5, 송파구: 3.0 }, + { month: '7월', 강남구: 3.5, 서초구: 3.1, 마포구: 2.7, 송파구: 3.2 }, + { month: '8월', 강남구: 3.7, 서초구: 3.3, 마포구: 2.9, 송파구: 3.4 }, + { month: '9월', 강남구: 3.9, 서초구: 3.5, 마포구: 3.1, 송파구: 3.6 }, + { month: '10월', 강남구: 4.1, 서초구: 3.7, 마포구: 3.3, 송파구: 3.8 }, + { month: '11월', 강남구: 4.3, 서초구: 3.9, 마포구: 3.5, 송파구: 4.0 }, + { month: '12월', 강남구: 4.5, 서초구: 4.1, 마포구: 3.7, 송파구: 4.2 }, ], }, { - title: '해당 지역 대위변제 발생 빈도 및 증가율', - data: CHART_DATA.REGIONAL_INCREASE_RATE, + title: '대위변제 발생률 추이', lines: [ - { - key: '증가율', - name: '증가율', - showDot: true, - }, + { key: '발생률', name: '발생률' }, + { key: '평균', name: '평균' }, + ], + data: [ + { month: '1월', 발생률: 1.2, 평균: 1.5 }, + { month: '2월', 발생률: 1.4, 평균: 1.5 }, + { month: '3월', 발생률: 1.1, 평균: 1.5 }, + { month: '4월', 발생률: 1.6, 평균: 1.5 }, + { month: '5월', 발생률: 1.3, 평균: 1.5 }, + { month: '6월', 발생률: 1.8, 평균: 1.5 }, + { month: '7월', 발생률: 1.5, 평균: 1.5 }, + { month: '8월', 발생률: 1.7, 평균: 1.5 }, + { month: '9월', 발생률: 1.4, 평균: 1.5 }, + { month: '10월', 발생률: 1.9, 평균: 1.5 }, + { month: '11월', 발생률: 1.6, 평균: 1.5 }, + { month: '12월', 발생률: 1.3, 평균: 1.5 }, ], }, ]; + +// 개별 차트 데이터 (기존 코드와의 호환성을 위해 유지) +export const REAL_ESTATE_PRICE_DATA = CHART_DATA_LIST[0]; +export const REGIONAL_PRICE_CHANGE_DATA = CHART_DATA_LIST[1]; +export const SUBROGATION_RATE_DATA = CHART_DATA_LIST[2]; diff --git a/src/features/main/mock/index.ts b/src/features/main/mock/index.ts index 67bf2e5..5627d49 100644 --- a/src/features/main/mock/index.ts +++ b/src/features/main/mock/index.ts @@ -1 +1,2 @@ export * from './chart-data.mock'; +export * from './risk-analysis.mock'; diff --git a/src/features/main/mock/risk-analysis.mock.ts b/src/features/main/mock/risk-analysis.mock.ts new file mode 100644 index 0000000..b3ff28f --- /dev/null +++ b/src/features/main/mock/risk-analysis.mock.ts @@ -0,0 +1,90 @@ +/** + * 위험도 분석 임시 데이터 + */ +import type { RiskAnalysisResponse } from '@/entities'; + +// 서버 응답 형태의 임시 데이터들 +export const TEMP_RISK_ANALYSIS_DATA: RiskAnalysisResponse[] = [ + { + data: { + riskSummary: { + score: 50, + factors: [ + { name: '전세가율', percent: 12 }, + { name: '가격하락', percent: 10 }, + { name: '미분양(재고)', percent: 7 }, + { name: '정책/규제', percent: 18 }, + { name: '법적 리스크', percent: 16 }, + ], + }, + landlord: { + landlordId: 1, + name: '홍길동', + normalizedKey: '부산-사업자번호-해시', + ownedCount: 1, + grade: 'B', + createdAt: '2025-08-21T02:26:22.055226', + updatedAt: '2025-08-21T02:26:22.055226', + }, + landlordTrust: { + trustScore: 95, + subrogationCount: 0, + arrearsCount: 0, + litigationCount: 0, + ownedUnsoldCount: 0, + grade: 'A', + }, + landlordPlaces: [ + { + placeId: 1, + label: '부산광역시 해운대구 센텀동로 45', + address: '부산광역시 해운대구 센텀동로 45', + addressDetail: '101동 1001호', + }, + ], + }, + status: 'SUCCESS', + serverDateTime: '2025-08-21T02:28:11.398139', + errorCode: null, + errorMessage: null, + }, +]; + +// 기본 위험도 데이터 (사진과 일치) +export const DEFAULT_RISK_ANALYSIS_DATA: RiskAnalysisResponse = TEMP_RISK_ANALYSIS_DATA[0]; + +// 위험도 구간별 설명 +export const RISK_LEVEL_DESCRIPTIONS = { + SAFE: '매우 안전한 전세 계약입니다.', + MODERATE_SAFE: '안전한 전세 계약입니다.', + GOOD: '양호한 전세 계약입니다.', + MODERATE_RISK: '주의가 필요한 전세 계약입니다.', + RISK: '위험한 전세 계약입니다.', +} as const; + +// 위험도 점수별 등급 매핑 +export const getRiskGrade = (score: number): string => { + if (score <= 20) return '매우 위험'; + if (score <= 40) return '위험'; + if (score <= 60) return '양호'; + if (score <= 80) return '다소 안전'; + return '안전'; +}; + +// 위험도 등급별 색상 +export const getRiskGradeColor = (grade: string): string => { + switch (grade) { + case '매우 위험': + case '위험': + return 'text-red-600'; + case '양호': + return 'text-green-600'; + case '다소 위험': + return 'text-yellow-600'; + case '다소 안전': + case '안전': + return 'text-red-600'; + default: + return 'text-gray-600'; + } +}; diff --git a/src/features/main/model/data/index.ts b/src/features/main/model/data/index.ts new file mode 100644 index 0000000..7c78729 --- /dev/null +++ b/src/features/main/model/data/index.ts @@ -0,0 +1 @@ +export * from './risk-segment'; diff --git a/src/features/main/model/data/risk-segment.ts b/src/features/main/model/data/risk-segment.ts new file mode 100644 index 0000000..86345ac --- /dev/null +++ b/src/features/main/model/data/risk-segment.ts @@ -0,0 +1,8 @@ +// 5개 구간 정의 (각각 20점씩) - 왼쪽부터 안전/다소안전/양호/다소위험/위험 +export const RISK_SEGMENTS = [ + { name: '위험', value: 20, fill: '#ef4444' }, + { name: '다소 위험', value: 20, fill: '#f97316' }, + { name: '양호', value: 20, fill: '#fbbf24' }, + { name: '다소안전', value: 20, fill: '#34d399' }, + { name: '안전', value: 20, fill: '#10b981' }, +]; diff --git a/src/features/main/model/index.ts b/src/features/main/model/index.ts new file mode 100644 index 0000000..3707679 --- /dev/null +++ b/src/features/main/model/index.ts @@ -0,0 +1 @@ +export * from './data'; diff --git a/src/features/main/ui/ChartSection.tsx b/src/features/main/ui/ChartSection.tsx index d9698b2..a9c8737 100644 --- a/src/features/main/ui/ChartSection.tsx +++ b/src/features/main/ui/ChartSection.tsx @@ -1,13 +1,13 @@ import { ChartBox } from '../components'; -import { CHART_DATA_CONFIGS } from '../mock'; +import { CHART_DATA_LIST } from '../mock'; export const ChartSection = () => { return (
-
- {CHART_DATA_CONFIGS.map((config) => ( -
- +
+ {CHART_DATA_LIST.map((chartData, index) => ( +
+
))}
diff --git a/src/features/main/ui/InputSection.tsx b/src/features/main/ui/InputSection.tsx index 193c5ec..bcd4819 100644 --- a/src/features/main/ui/InputSection.tsx +++ b/src/features/main/ui/InputSection.tsx @@ -5,8 +5,8 @@ export const InputSection = () => {
- -

계약 

+ +

계약

하려고 하는 그 집

안심할 수 있는지 확인해봐요! diff --git a/src/features/main/ui/RiskAnalysisSummarySection.tsx b/src/features/main/ui/RiskAnalysisSummarySection.tsx new file mode 100644 index 0000000..0a134cb --- /dev/null +++ b/src/features/main/ui/RiskAnalysisSummarySection.tsx @@ -0,0 +1,19 @@ +import { RiskChartBox, RiskFactorsBox } from '../components'; +import { DEFAULT_RISK_ANALYSIS_DATA } from '../mock'; + +export const RiskAnalysisSummarySection = () => { + // 현재 사용할 임시 데이터 + const currentData = DEFAULT_RISK_ANALYSIS_DATA; // 사진과 일치하는 기본 데이터 + + return ( +
+ {/* 메인 위험도 분석 섹션 */} +
+ {/* 왼쪽: 위험도 게이지 */} + + {/* 오른쪽: 핵심 위험 요인 */} + +
+
+ ); +}; diff --git a/src/features/main/ui/index.ts b/src/features/main/ui/index.ts index 724d6dc..d8dd51d 100644 --- a/src/features/main/ui/index.ts +++ b/src/features/main/ui/index.ts @@ -1,3 +1,4 @@ export * from './ChartSection'; export * from './InputSection'; export * from './MapSection'; +export * from './RiskAnalysisSummarySection'; diff --git a/src/index.css b/src/index.css index 15854c6..e06becf 100644 --- a/src/index.css +++ b/src/index.css @@ -69,6 +69,11 @@ --color-lighthouse-button-secondary: var(--lighthouse-button-secondary); --color-lighthouse-button-secondary-text: var(--lighthouse-button-secondary-text); --color-lighthouse-button-secondary-hover: var(--lighthouse-button-secondary-hover); + --color-risk-very-danger: var(--risk-very-danger); + --color-risk-danger: var(--risk-danger); + --color-risk-good: var(--risk-good); + --color-risk-safe: var(--risk-safe); + --color-risk-very-safe: var(--risk-very-safe); } :root { @@ -122,6 +127,11 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + --risk-very-danger: #ff6f6f; + --risk-danger: #ffba6f; + --risk-good: #ffe93f; + --risk-safe: #d9ff41; + --risk-very-safe: #2cdf44; } .dark { diff --git a/src/pages/main/MainPage.tsx b/src/pages/main/MainPage.tsx index c56903f..3be8d11 100644 --- a/src/pages/main/MainPage.tsx +++ b/src/pages/main/MainPage.tsx @@ -1,4 +1,4 @@ -import { ChartSection, InputSection, MapSection } from '@/features'; +import { ChartSection, InputSection, MapSection, RiskAnalysisSummarySection } from '@/features'; export default function MainPage() { return ( @@ -7,9 +7,8 @@ export default function MainPage() {
-
- -
+ +
); } diff --git a/src/shared/components/common/icons/NeedleIcon.tsx b/src/shared/components/common/icons/NeedleIcon.tsx new file mode 100644 index 0000000..ff52d89 --- /dev/null +++ b/src/shared/components/common/icons/NeedleIcon.tsx @@ -0,0 +1,27 @@ +import { type SVGProps } from 'react'; + +type NeedleIconProps = { + className?: string; +} & SVGProps; + +export const NeedleIcon = ({ className = '', ...props }: NeedleIconProps) => { + return ( + + {/* 바늘 몸체 (삼각형 모양) */} + + {/* 바늘 베이스 (원형) */} + + + ); +}; diff --git a/src/shared/components/common/icons/index.ts b/src/shared/components/common/icons/index.ts new file mode 100644 index 0000000..5964166 --- /dev/null +++ b/src/shared/components/common/icons/index.ts @@ -0,0 +1 @@ +export { NeedleIcon } from './NeedleIcon'; diff --git a/src/shared/components/common/index.ts b/src/shared/components/common/index.ts index 4790b7f..f26d5b1 100644 --- a/src/shared/components/common/index.ts +++ b/src/shared/components/common/index.ts @@ -1,2 +1,3 @@ export * from './logo-section'; export * from './image-preloader'; +export * from './icons'; diff --git a/src/shared/constants/chart-color.ts b/src/shared/constants/chart-color.ts new file mode 100644 index 0000000..d6bf3de --- /dev/null +++ b/src/shared/constants/chart-color.ts @@ -0,0 +1,23 @@ +// CSS 변수 기반 색상 배열 +export const CHART_COLORS = [ + 'var(--chart-orange)', // 첫 번째 (1개일 때) + 'var(--chart-blue)', // 두 번째 + 'var(--chart-green)', // 세 번째 + 'var(--chart-purple)', // 네 번째 + 'var(--chart-red)', // 다섯 번째 + 'var(--chart-yellow)', // 여섯 번째 + 'var(--chart-pink)', // 일곱 번째 + 'var(--chart-indigo)', // 여덟 번째 +] as const; + +// Tailwind CSS 클래스 배열 +export const CHART_COLOR_CLASSES = [ + 'bg-chart-orange', // 첫 번째 (1개일 때) + 'bg-chart-blue', // 두 번째 + 'bg-chart-green', // 세 번째 + 'bg-chart-purple', // 네 번째 + 'bg-chart-red', // 다섯 번째 + 'bg-chart-yellow', // 여섯 번째 + 'bg-chart-pink', // 일곱 번째 + 'bg-chart-indigo', // 여덟 번째 +] as const; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 194fd2d..0899ce9 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1 +1,3 @@ export * from './router-path'; +export * from './risk-chart-segments'; +export * from './chart-color'; diff --git a/src/shared/constants/risk-chart-segments.ts b/src/shared/constants/risk-chart-segments.ts new file mode 100644 index 0000000..1d20f7e --- /dev/null +++ b/src/shared/constants/risk-chart-segments.ts @@ -0,0 +1,63 @@ +/** + * 위험도 차트 구간 정의 + */ + +// 타입 정의 +export type RiskLevel = '매우 위험' | '위험' | '양호' | '안전' | '매우 안전'; + +export type RiskSegment = { + name: RiskLevel; + value: number; + fill: string; +}; + +// 색상 상수 +const COLORS = { + RED: '#ff6f6f', + ORANGE: '#ffba6f', + YELLOW: '#ffe93f', + YELLOW_GREEN: '#d9ff41', + GREEN: '#2cdf44', +} as const; + +// 위험도 점수별 색상 클래스 +export const RISK_SCORE_COLORS = { + VERY_DANGER: 'text-risk-very-danger', // 매우 위험 (0-20점) + DANGER: 'text-risk-danger', // 위험 (21-40점) + CAUTION: 'text-risk-good', // 양호 (41-60점) + SAFE: 'text-risk-safe', // 안전 (61-80점) + VERY_SAFE: 'text-risk-very-safe', // 매우 안전 (81-100점) +} as const; + +// 값 상수 +const VALUES = { + VERY_RISK: 20, + RISK: 20, + GOOD: 20, + SAFE: 20, + VERY_SAFE: 20, +} as const; + +// 이름 상수 +const NAMES = { + VERY_RISK: '매우 위험' as const, + RISK: '위험' as const, + GOOD: '양호' as const, + SAFE: '안전' as const, + VERY_SAFE: '매우 안전' as const, +} as const; + +// 위험도 구간 정의 (왼쪽이 위험, 오른쪽이 안전) +export const RISK_CHART_SEGMENTS: readonly RiskSegment[] = [ + { name: NAMES.VERY_SAFE, value: VALUES.VERY_SAFE, fill: COLORS.GREEN }, + { name: NAMES.SAFE, value: VALUES.SAFE, fill: COLORS.YELLOW_GREEN }, + { name: NAMES.GOOD, value: VALUES.GOOD, fill: COLORS.YELLOW }, + { name: NAMES.RISK, value: VALUES.RISK, fill: COLORS.ORANGE }, + { name: NAMES.VERY_RISK, value: VALUES.VERY_RISK, fill: COLORS.RED }, +]; + +// 구간 인덱스 타입 +export type RiskSegmentIndex = 0 | 1 | 2 | 3 | 4; + +// 점수 범위 타입 +export type RiskScore = number & { __brand: 'RiskScore' }; diff --git a/src/shared/utils/chart/chart-colors.ts b/src/shared/utils/chart/chart-colors.ts index dcc5dcd..39e2b65 100644 --- a/src/shared/utils/chart/chart-colors.ts +++ b/src/shared/utils/chart/chart-colors.ts @@ -1,30 +1,7 @@ /** * 차트 색상 관련 유틸리티 */ - -// CSS 변수 기반 색상 배열 -const CHART_COLORS = [ - 'var(--chart-orange)', // 첫 번째 (1개일 때) - 'var(--chart-blue)', // 두 번째 - 'var(--chart-green)', // 세 번째 - 'var(--chart-purple)', // 네 번째 - 'var(--chart-red)', // 다섯 번째 - 'var(--chart-yellow)', // 여섯 번째 - 'var(--chart-pink)', // 일곱 번째 - 'var(--chart-indigo)', // 여덟 번째 -] as const; - -// Tailwind CSS 클래스 배열 -const CHART_COLOR_CLASSES = [ - 'bg-chart-orange', // 첫 번째 (1개일 때) - 'bg-chart-blue', // 두 번째 - 'bg-chart-green', // 세 번째 - 'bg-chart-purple', // 네 번째 - 'bg-chart-red', // 다섯 번째 - 'bg-chart-yellow', // 여섯 번째 - 'bg-chart-pink', // 일곱 번째 - 'bg-chart-indigo', // 여덟 번째 -] as const; +import { CHART_COLORS, CHART_COLOR_CLASSES, RISK_SCORE_COLORS } from '../../constants'; /** * 인덱스에 따른 차트 색상 CSS 변수를 반환 @@ -44,6 +21,47 @@ export const getChartColorClass = (index: number): string => { return CHART_COLOR_CLASSES[index % CHART_COLOR_CLASSES.length]; }; +/** + * 위험도 점수에 따른 색상 클래스를 반환 + * @param score - 위험도 점수 (0-100, 낮을수록 위험) + * @returns Tailwind CSS 색상 클래스명 + */ +export const getRiskScoreColorClass = (score: number): string => { + if (score <= 20) return RISK_SCORE_COLORS.VERY_DANGER; // 매우 위험 + if (score <= 40) return RISK_SCORE_COLORS.DANGER; // 위험 + if (score <= 60) return RISK_SCORE_COLORS.CAUTION; // 양호 + if (score <= 80) return RISK_SCORE_COLORS.SAFE; // 안전 + return RISK_SCORE_COLORS.VERY_SAFE; // 매우 안전 +}; + +/** + * 위험도 색상 HEX 값을 Tailwind CSS 클래스로 변환 + * @param hexColor - HEX 색상 값 + * @returns Tailwind CSS box-shadow 클래스 + */ +export const getRiskColorClass = (hexColor: string): string => { + const colorMap: Record = { + '#ff6f6f': 'bg-risk-very-danger', // 매우 위험 + '#ffba6f': 'bg-risk-danger', // 위험 + '#ffe93f': 'bg-risk-good', // 양호 + '#d9ff41': 'bg-risk-safe', // 안전 + '#2cdf44': 'bg-risk-very-safe', // 매우 안전 + }; + + return colorMap[hexColor] || 'bg-gray-400'; +}; + +export const getRiskBoxShadowClass = (hexColor: string): string => { + const colorMap: Record = { + '#ff6f6f': 'shadow-[0_0_12px_var(--color-risk-very-danger)]', // 매우 위험 + '#ffba6f': 'shadow-[0_0_12px_var(--color-risk-danger)]', // 위험 + '#ffe93f': 'shadow-[0_0_12px_var(--color-risk-good)]', // 양호 + '#d9ff41': 'shadow-[0_0_12px_var(--color-risk-safe)]', // 안전 + '#2cdf44': 'shadow-[0_0_12px_var(--color-risk-very-safe)]', // 매우 안전 + }; + return colorMap[hexColor] || 'shadow-[0_0_12px_rgba(156,163,175,0.5)]'; +}; + /** * 차트 색상 개수 */ diff --git a/src/shared/utils/chart/index.ts b/src/shared/utils/chart/index.ts index e616679..951d09b 100644 --- a/src/shared/utils/chart/index.ts +++ b/src/shared/utils/chart/index.ts @@ -1 +1,2 @@ export * from './chart-colors'; +export * from './risk-chart'; diff --git a/src/shared/utils/chart/risk-chart.ts b/src/shared/utils/chart/risk-chart.ts new file mode 100644 index 0000000..c7ef34f --- /dev/null +++ b/src/shared/utils/chart/risk-chart.ts @@ -0,0 +1,121 @@ +/** + * 위험도 게이지 차트 관련 유틸리티 + */ +import { + RISK_CHART_SEGMENTS, + type RiskScore, + type RiskSegment, + type RiskSegmentIndex, +} from '../../constants'; + +// 게이지 데이터 타입 정의 +export type GaugeData = { + data: RiskSegment[]; + color: string; + label: string; + angle: number; +}; + +// 상수 정의 +const SEGMENT_SIZE = 20; + +// 점수 범위 검증 함수 +export const isValidRiskScore = (score: number): score is RiskScore => { + return score >= 0 && score <= 100; +}; + +// 안전한 점수 변환 함수 +export const toRiskScore = (score: number): RiskScore => { + return Math.max(0, Math.min(100, score)) as RiskScore; +}; + +/** + * 위험도 점수에 따른 구간 인덱스를 계산 + * 점수가 낮을수록 위험하므로 인덱스를 반대로 계산 + * @param score - 위험도 점수 (0-100, 낮을수록 위험) + * @returns 구간 인덱스 (0-4, 0=매우 안전, 4=매우 위험) + */ +export const getSegmentIndex = (score: RiskScore): RiskSegmentIndex => { + // 점수가 낮을수록 위험하므로 인덱스를 반대로 계산 + // 0-20점: 4번 인덱스 (매우 위험) + // 21-40점: 3번 인덱스 (위험) + // 41-60점: 2번 인덱스 (양호) + // 61-80점: 1번 인덱스 (안전) + // 81-100점: 0번 인덱스 (매우 안전) + const segmentIndex = Math.floor((score - 1) / SEGMENT_SIZE); + const reversedIndex = 4 - Math.max(0, Math.min(segmentIndex, 4)); + return reversedIndex as RiskSegmentIndex; +}; + +/** + * 위험도 점수에 따른 구간 정보를 반환 + * @param score - 위험도 점수 (0-100, 낮을수록 위험) + * @returns 현재 구간 정보 + */ +export const getRiskSegment = (score: number): RiskSegment => { + const normalizedScore = toRiskScore(score); + const segmentIndex = getSegmentIndex(normalizedScore); + const result = RISK_CHART_SEGMENTS[segmentIndex]; + return result; +}; + +/** + * 위험도 점수를 5구간으로 나눠서 각도로 변환 (이산형) + * 5구간을 36도씩 나누어 표현 (0-36, 36-72, 72-108, 108-144, 144-180) + * @param score - 위험도 점수 (0-100, 낮을수록 위험) + * @returns 바늘 각도 (0-180도) + */ +export const getGaugeAngle = (score: number): number => { + const normalized = Math.max(0, Math.min(100, score)); // 0~100 보정 + + // 5구간을 36도씩 나누어 계산 + let result: number; + if (normalized <= 20) { + // 0-20점: 매우 위험 (0-36도, 왼쪽) + result = (normalized / 20) * 36; + } else if (normalized <= 40) { + // 21-40점: 위험 (36-72도) + result = 36 + ((normalized - 20) / 20) * 36; + } else if (normalized <= 60) { + // 41-60점: 양호 (72-108도, 중앙) + result = 72 + ((normalized - 40) / 20) * 36; + } else if (normalized <= 80) { + // 61-80점: 안전 (108-144도) + result = 108 + ((normalized - 60) / 20) * 36; + } else { + // 81-100점: 매우 안전 (144-180도, 오른쪽) + result = 144 + ((normalized - 80) / 20) * 36; + } + + console.log(`보정점수 : ${normalized} 바늘 위치: ${score}점 → ${result}도`); + return result; +}; + +/** + * 위험도 점수에 따른 게이지 데이터를 생성 + * @param score - 위험도 점수 (0-100, 낮을수록 위험) + * @returns 게이지 차트에 필요한 데이터 + */ +export const getGaugeData = (score: number): GaugeData => { + const normalizedScore = toRiskScore(score); + const currentSegment = getRiskSegment(normalizedScore); + const angle = getGaugeAngle(normalizedScore); + + const result = { + data: RISK_CHART_SEGMENTS as RiskSegment[], + color: currentSegment.fill, + label: currentSegment.name, + angle, + }; + + return result; +}; + +/** + * 위험도 점수가 유효한지 검증 + * @param score - 검증할 점수 + * @returns 유효성 여부 + */ +export const validateRiskScore = (score: number): boolean => { + return isValidRiskScore(score); +};