차트 구현 및 리스트 구현 #98
|
|
@ -654,7 +654,7 @@ LIMIT 10;
|
|||
|
||||
**구현 시작일**: 2025-10-14
|
||||
**목표 완료일**: 2025-10-20
|
||||
**현재 진행률**: 40% (Phase 1 완료 + Phase 2 완료 ✅)
|
||||
**현재 진행률**: 90% (Phase 1-5 완료, D3 차트 추가 구현 ✅)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -701,8 +701,42 @@ LIMIT 10;
|
|||
|
||||
---
|
||||
|
||||
## 🎉 Phase 2 완료 요약
|
||||
## 🎉 전체 구현 완료 요약
|
||||
|
||||
- **DatabaseConfig**: Mock 데이터 제거 → 실제 API 호출
|
||||
### Phase 1: 데이터 소스 UI ✅
|
||||
|
||||
- `DataSourceSelector`: DB vs API 선택 UI
|
||||
- `DatabaseConfig`: 현재 DB / 외부 DB 선택 및 API 연동
|
||||
- `ApiConfig`: REST API 설정
|
||||
- `dataSourceUtils`: 유틸리티 함수
|
||||
|
||||
### Phase 2: 서버 API 통합 ✅
|
||||
|
||||
- `GET /api/external-db-connections`: 외부 커넥션 목록 조회
|
||||
- `POST /api/external-db-connections/:id/execute`: 외부 DB 쿼리 실행
|
||||
- `POST /api/dashboards/execute-query`: 현재 DB 쿼리 실행
|
||||
- **QueryEditor**: 현재 DB / 외부 DB 분기 처리 완료
|
||||
- **API 통합**: 모든 필요한 API가 이미 구현되어 있었고, 프론트엔드 통합 완료
|
||||
|
||||
### Phase 3: 차트 설정 UI ✅
|
||||
|
||||
- `ChartConfigPanel`: X/Y축 매핑, 스타일 설정, 색상 팔레트
|
||||
- 다중 Y축 선택 지원
|
||||
- 설정 미리보기
|
||||
|
||||
### Phase 4: D3 차트 컴포넌트 ✅
|
||||
|
||||
- **D3 차트 구현** (6종):
|
||||
- `BarChart.tsx`: 막대 차트
|
||||
- `LineChart.tsx`: 선 차트
|
||||
- `AreaChart.tsx`: 영역 차트
|
||||
- `PieChart.tsx`: 원/도넛 차트
|
||||
- `StackedBarChart.tsx`: 누적 막대 차트
|
||||
- `Chart.tsx`: 통합 컴포넌트
|
||||
- **Recharts 완전 제거**: D3로 완전히 대체
|
||||
|
||||
### Phase 5: 통합 ✅
|
||||
|
||||
- `CanvasElement`: 차트 렌더링 통합 완료
|
||||
- `ChartRenderer`: D3 기반으로 완전히 교체
|
||||
- `chartDataTransform.ts`: 데이터 변환 유틸리티
|
||||
- 데이터 페칭 및 자동 새로고침
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface ChartConfigPanelProps {
|
|||
config?: ChartConfig;
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
chartType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,9 +25,12 @@ interface ChartConfigPanelProps {
|
|||
* - 차트 스타일 설정
|
||||
* - 실시간 미리보기
|
||||
*/
|
||||
export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) {
|
||||
export function ChartConfigPanel({ config, queryResult, onConfigChange, chartType }: ChartConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
|
||||
// 원형/도넛 차트는 Y축이 필수가 아님
|
||||
const isPieChart = chartType === "pie" || chartType === "donut";
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfig>) => {
|
||||
|
|
@ -78,7 +82,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
X축 (카테고리)
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={currentConfig.xAxis || ""} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -96,7 +100,8 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
<div className="space-y-2">
|
||||
<Label>
|
||||
Y축 (값) - 여러 개 선택 가능
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
{!isPieChart && <span className="ml-1 text-red-500">*</span>}
|
||||
{isPieChart && <span className="ml-2 text-xs text-gray-500">(선택사항)</span>}
|
||||
</Label>
|
||||
<Card className="max-h-60 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -154,13 +159,18 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
<span className="ml-2 text-xs text-gray-500">(데이터 처리 방식)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.aggregation || "sum"}
|
||||
onValueChange={(value) => updateConfig({ aggregation: value as any })}
|
||||
value={currentConfig.aggregation || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
aggregation: value === "none" ? undefined : (value as "sum" | "avg" | "count" | "max" | "min"),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||||
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||||
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||||
<SelectItem value="count">개수 (COUNT) - 데이터 개수</SelectItem>
|
||||
|
|
@ -176,12 +186,15 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
{/* 그룹핑 필드 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<Label>그룹핑 필드 (선택사항)</Label>
|
||||
<Select value={currentConfig.groupBy || ""} onValueChange={(value) => updateConfig({ groupBy: value })}>
|
||||
<Select
|
||||
value={currentConfig.groupBy || undefined}
|
||||
onValueChange={(value) => updateConfig({ groupBy: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</SelectItem>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
|
|
@ -253,14 +266,14 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
<div className="flex gap-2">
|
||||
<span className="font-medium">Y축:</span>
|
||||
<span>
|
||||
{Array.isArray(currentConfig.yAxis)
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0
|
||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})`
|
||||
: currentConfig.yAxis || "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">집계:</span>
|
||||
<span>{currentConfig.aggregation || "sum"}</span>
|
||||
<span>{currentConfig.aggregation || "없음"}</span>
|
||||
</div>
|
||||
{currentConfig.groupBy && (
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -22,21 +22,21 @@ interface ElementConfigModalProps {
|
|||
|
||||
/**
|
||||
* 요소 설정 모달 컴포넌트 (리팩토링)
|
||||
* - 3단계 플로우: 데이터 소스 선택 → 데이터 설정 → 차트 설정
|
||||
* - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 30 },
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||
|
||||
// 모달이 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 30 });
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
|
|
@ -49,13 +49,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 30,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 30,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -73,45 +73,19 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
// 쿼리 결과가 나오면 자동으로 3단계로 이동
|
||||
if (result.rows.length > 0) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 다음 단계로 이동 (검증 포함)
|
||||
// 다음 단계로 이동
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
// 1단계: 데이터 소스 타입 선택 완료
|
||||
setCurrentStep(2);
|
||||
} else if (currentStep === 2) {
|
||||
// 2단계: 데이터 설정 완료 - 검증
|
||||
const validation = validateDataSource(
|
||||
dataSource.type,
|
||||
dataSource.connectionType,
|
||||
dataSource.externalConnectionId,
|
||||
dataSource.query,
|
||||
dataSource.endpoint,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
alert(validation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 결과가 있으면 3단계로 이동
|
||||
if (queryResult && queryResult.rows.length > 0) {
|
||||
setCurrentStep(3);
|
||||
} else {
|
||||
alert("먼저 데이터를 테스트하여 결과를 확인하세요");
|
||||
}
|
||||
}
|
||||
}, [currentStep, dataSource, queryResult]);
|
||||
}, [currentStep]);
|
||||
|
||||
// 이전 단계로 이동
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
|
|
@ -139,6 +113,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
|
||||
// 저장 가능 여부 확인
|
||||
const canSave =
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
|
|
@ -146,12 +121,18 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex h-[85vh] w-full max-w-5xl flex-col rounded-xl border bg-white shadow-2xl">
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border bg-white shadow-2xl ${
|
||||
currentStep === 1 ? "h-auto max-h-[70vh] w-full max-w-3xl" : "h-[85vh] w-full max-w-5xl"
|
||||
}`}
|
||||
>
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">3단계 플로우로 차트를 설정하세요</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{currentStep === 1 ? "데이터 소스를 선택하세요" : "쿼리를 실행하고 차트를 설정하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="h-5 w-5" />
|
||||
|
|
@ -162,13 +143,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
단계 {currentStep} / 3: {currentStep === 1 && "데이터 소스 선택"}
|
||||
{currentStep === 2 && "데이터 설정"}
|
||||
{currentStep === 3 && "차트 설정"}
|
||||
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
||||
</div>
|
||||
<Badge variant="secondary">{Math.round((currentStep / 3) * 100)}% 완료</Badge>
|
||||
<Badge variant="secondary">{Math.round((currentStep / 2) * 100)}% 완료</Badge>
|
||||
</div>
|
||||
<Progress value={(currentStep / 3) * 100} className="h-2" />
|
||||
<Progress value={(currentStep / 2) * 100} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
|
|
@ -178,24 +157,43 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
{dataSource.type === "database" ? (
|
||||
<>
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 데이터 설정 */}
|
||||
<div className="space-y-6">
|
||||
{dataSource.type === "database" ? (
|
||||
<>
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentStep === 3 && (
|
||||
<ChartConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
{/* 오른쪽: 차트 설정 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm font-medium text-gray-700">쿼리를 실행하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -219,7 +217,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{currentStep < 3 ? (
|
||||
{currentStep === 1 ? (
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
...dataSource,
|
||||
type: "database",
|
||||
query: query.trim(),
|
||||
refreshInterval: dataSource?.refreshInterval || 30000,
|
||||
refreshInterval: dataSource?.refreshInterval ?? 0,
|
||||
lastExecuted: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -220,7 +220,7 @@ ORDER BY Q4 DESC;`,
|
|||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||
className="h-40 resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 text-xs text-gray-400">Ctrl+Enter로 실행</div>
|
||||
<div className="absolute right-3 bottom-3 text-xs text-gray-400">Ctrl+Enter로 실행</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ ORDER BY Q4 DESC;`,
|
|||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm">자동 새로고침:</Label>
|
||||
<Select
|
||||
value={String(dataSource?.refreshInterval || 30000)}
|
||||
value={String(dataSource?.refreshInterval ?? 0)}
|
||||
onValueChange={(value) =>
|
||||
onDataSourceChange({
|
||||
...dataSource,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface AreaChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 영역 차트 컴포넌트
|
||||
*/
|
||||
export function AreaChart({ data, config, width = 600, height = 400 }: AreaChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일
|
||||
const xScale = d3.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 영역 생성기
|
||||
const areaGenerator = d3
|
||||
.area<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y0(chartHeight)
|
||||
.y1((d) => yScale(d));
|
||||
|
||||
// 선 생성기
|
||||
const lineGenerator = d3
|
||||
.line<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y((d) => yScale(d));
|
||||
|
||||
// 부드러운 곡선 적용
|
||||
if (config.lineStyle === "smooth") {
|
||||
areaGenerator.curve(d3.curveMonotoneX);
|
||||
lineGenerator.curve(d3.curveMonotoneX);
|
||||
}
|
||||
|
||||
// 각 데이터셋에 대해 영역 그리기
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const color = dataset.color || colors[i % colors.length];
|
||||
const opacity = config.areaOpacity !== undefined ? config.areaOpacity : 0.3;
|
||||
|
||||
// 영역 그리기
|
||||
const area = g.append("path").datum(dataset.data).attr("fill", color).attr("opacity", 0).attr("d", areaGenerator);
|
||||
|
||||
// 경계선 그리기
|
||||
const line = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("d", lineGenerator);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
area
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("opacity", opacity);
|
||||
|
||||
const totalLength = line.node()?.getTotalLength() || 0;
|
||||
line
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
} else {
|
||||
area.attr("opacity", opacity);
|
||||
}
|
||||
|
||||
// 데이터 포인트 (점) 그리기
|
||||
const circles = g
|
||||
.selectAll(`.circle-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `circle-${i}`)
|
||||
.attr("cx", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", color)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [x, y] = d3.pointer(event, g.node());
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${x},${y - 10})`);
|
||||
|
||||
tooltip
|
||||
.append("rect")
|
||||
.attr("x", -40)
|
||||
.attr("y", -30)
|
||||
.attr("width", 80)
|
||||
.attr("height", 25)
|
||||
.attr("fill", "rgba(0,0,0,0.8)")
|
||||
.attr("rx", 4);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.text(`${dataset.label}: ${d}`);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("opacity", config.areaOpacity !== undefined ? config.areaOpacity : 0.3)
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface AreaChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 차트 컴포넌트
|
||||
* - Recharts AreaChart 사용
|
||||
* - 추세 파악에 적합
|
||||
* - 다중 시리즈 지원
|
||||
*/
|
||||
export function AreaChartComponent({ data, config, width = 250, height = 200 }: AreaChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// Y축 필드들 (단일 또는 다중)
|
||||
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
|
||||
const yKeys = yFields.filter(field => field && field !== 'y');
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
{yKeys.map((key, index) => (
|
||||
<linearGradient key={key} id={`color${index}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey={xAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, name: string) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
{showLegend && yKeys.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{yKeys.map((key, index) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[index % colors.length]}
|
||||
fill={`url(#color${index})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface BarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 막대 차트 컴포넌트
|
||||
*/
|
||||
export function BarChart({ data, config, width = 600, height = 400 }: BarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리)
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2);
|
||||
|
||||
// Y축 스케일 (값)
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 막대 그리기
|
||||
const barWidth = xScale.bandwidth() / data.datasets.length;
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const bars = g
|
||||
.selectAll(`.bar-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", `bar-${i}`)
|
||||
.attr("x", (_, j) => (xScale(data.labels[j]) || 0) + barWidth * i)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", barWidth)
|
||||
.attr("height", 0)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d))
|
||||
.attr("height", (d) => chartHeight - yScale(d));
|
||||
} else {
|
||||
bars.attr("y", (d) => yScale(d)).attr("height", (d) => chartHeight - yScale(d));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface BarChartComponentProps {
|
||||
data: any[];
|
||||
config: any;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바 차트 컴포넌트 (Recharts SimpleBarChart 기반)
|
||||
* - 실제 데이터를 받아서 단순하게 표시
|
||||
* - 복잡한 변환 로직 없음
|
||||
*/
|
||||
export function BarChartComponent({ data, config, width = 600, height = 300 }: BarChartComponentProps) {
|
||||
// console.log('🎨 BarChartComponent - 전체 데이터:', {
|
||||
// dataLength: data?.length,
|
||||
// fullData: data,
|
||||
// dataType: typeof data,
|
||||
// isArray: Array.isArray(data),
|
||||
// config,
|
||||
// xAxisField: config?.xAxis,
|
||||
// yAxisFields: config?.yAxis
|
||||
// });
|
||||
|
||||
// 데이터가 없으면 메시지 표시
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<div>데이터가 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터의 첫 번째 아이템에서 사용 가능한 키 확인
|
||||
const firstItem = data[0];
|
||||
const availableKeys = Object.keys(firstItem);
|
||||
// console.log('📊 사용 가능한 데이터 키:', availableKeys);
|
||||
// console.log('📊 첫 번째 데이터 아이템:', firstItem);
|
||||
|
||||
// Y축 필드 추출 (배열이면 모두 사용, 아니면 단일 값)
|
||||
const yFields = Array.isArray(config.yAxis) ? config.yAxis : [config.yAxis];
|
||||
|
||||
// 색상 배열
|
||||
const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1'];
|
||||
|
||||
// 한글 레이블 매핑
|
||||
const labelMapping: Record<string, string> = {
|
||||
'total_users': '전체 사용자',
|
||||
'active_users': '활성 사용자',
|
||||
'name': '부서'
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey={config.xAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
{config.showLegend !== false && <Legend />}
|
||||
|
||||
{/* Y축 필드마다 Bar 생성 */}
|
||||
{yFields.map((field: string, index: number) => (
|
||||
<Bar
|
||||
key={field}
|
||||
dataKey={field}
|
||||
fill={colors[index % colors.length]}
|
||||
name={labelMapping[field] || field}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BarChart } from "./BarChart";
|
||||
import { LineChart } from "./LineChart";
|
||||
import { AreaChart } from "./AreaChart";
|
||||
import { PieChart } from "./PieChart";
|
||||
import { StackedBarChart } from "./StackedBarChart";
|
||||
import { ChartConfig, ChartData, ElementSubtype } from "../types";
|
||||
|
||||
interface ChartProps {
|
||||
chartType: ElementSubtype;
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 차트 컴포넌트
|
||||
* - 차트 타입에 따라 적절한 D3 차트 컴포넌트를 렌더링
|
||||
*/
|
||||
export function Chart({ chartType, data, config, width, height }: ChartProps) {
|
||||
// 데이터가 없으면 placeholder 표시
|
||||
if (!data || !data.labels.length || !data.datasets.length) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm font-medium text-gray-600">데이터를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">차트 설정에서 데이터 소스와 축을 설정하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 차트 타입에 따라 렌더링
|
||||
switch (chartType) {
|
||||
case "bar":
|
||||
return <BarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "line":
|
||||
return <LineChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "area":
|
||||
return <AreaChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "pie":
|
||||
return <PieChart data={data} config={config} width={width} height={height} isDonut={false} />;
|
||||
|
||||
case "donut":
|
||||
return <PieChart data={data} config={config} width={width} height={height} isDonut={true} />;
|
||||
|
||||
case "stacked-bar":
|
||||
return <StackedBarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "combo":
|
||||
// Combo 차트는 일단 Bar + Line 조합으로 표시 (추후 구현 가능)
|
||||
return <BarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">❓</div>
|
||||
<div className="text-sm font-medium text-gray-600">지원하지 않는 차트 타입</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{chartType}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { DashboardElement, QueryResult } from '../types';
|
||||
import { BarChartComponent } from './BarChartComponent';
|
||||
import { PieChartComponent } from './PieChartComponent';
|
||||
import { LineChartComponent } from './LineChartComponent';
|
||||
import { AreaChartComponent } from './AreaChartComponent';
|
||||
import { StackedBarChartComponent } from './StackedBarChartComponent';
|
||||
import { DonutChartComponent } from './DonutChartComponent';
|
||||
import { ComboChartComponent } from './ComboChartComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { DashboardElement, QueryResult, ChartData } from "../types";
|
||||
import { Chart } from "./Chart";
|
||||
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
|
||||
interface ChartRendererProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -18,85 +15,140 @@ interface ChartRendererProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 차트 렌더러 컴포넌트 (단순 버전)
|
||||
* - 데이터를 받아서 차트에 그대로 전달
|
||||
* - 복잡한 변환 로직 제거
|
||||
* 차트 렌더러 컴포넌트 (D3 기반)
|
||||
* - 데이터 소스에서 데이터 페칭
|
||||
* - QueryResult를 ChartData로 변환
|
||||
* - D3 Chart 컴포넌트에 전달
|
||||
*/
|
||||
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) {
|
||||
// console.log('🎬 ChartRenderer:', {
|
||||
// elementId: element.id,
|
||||
// hasData: !!data,
|
||||
// dataRows: data?.rows?.length,
|
||||
// xAxis: element.chartConfig?.xAxis,
|
||||
// yAxis: element.chartConfig?.yAxis
|
||||
// });
|
||||
const [chartData, setChartData] = useState<ChartData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 데이터나 설정이 없으면 메시지 표시
|
||||
if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
||||
// 데이터 페칭
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// 이미 data가 전달된 경우 사용
|
||||
if (data) {
|
||||
const transformed = transformQueryResultToChartData(data, element.chartConfig || {});
|
||||
setChartData(transformed);
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 소스가 설정되어 있으면 페칭
|
||||
if (element.dataSource && element.dataSource.query && element.chartConfig) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let queryResult: QueryResult;
|
||||
|
||||
// 현재 DB vs 외부 DB 분기
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
queryResult = {
|
||||
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
|
||||
rows: result.data || [],
|
||||
totalRows: result.data?.length || 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
queryResult = {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ChartData로 변환
|
||||
const transformed = transformQueryResultToChartData(queryResult, element.chartConfig);
|
||||
setChartData(transformed);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "데이터 로딩 실패";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// 자동 새로고침 설정 (0이면 수동이므로 interval 설정 안 함)
|
||||
const refreshInterval = element.dataSource?.refreshInterval;
|
||||
if (refreshInterval && refreshInterval > 0) {
|
||||
const interval = setInterval(fetchData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.refreshInterval,
|
||||
element.chartConfig,
|
||||
data,
|
||||
]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<div>데이터를 설정해주세요</div>
|
||||
<div className="text-xs mt-1">⚙️ 버튼을 클릭하여 설정</div>
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터가 비어있으면
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-red-500 text-sm">
|
||||
<div className="flex h-full w-full items-center justify-center text-red-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div>데이터가 없습니다</div>
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium">오류 발생</div>
|
||||
<div className="mt-1 text-xs">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터를 그대로 전달 (변환 없음!)
|
||||
const chartData = data.rows;
|
||||
|
||||
// console.log('📊 Chart Data:', {
|
||||
// dataLength: chartData.length,
|
||||
// firstRow: chartData[0],
|
||||
// columns: Object.keys(chartData[0] || {})
|
||||
// });
|
||||
|
||||
// 차트 공통 props
|
||||
const chartProps = {
|
||||
data: chartData,
|
||||
config: element.chartConfig,
|
||||
width: width - 20,
|
||||
height: height - 60,
|
||||
};
|
||||
|
||||
// 차트 타입에 따른 렌더링
|
||||
switch (element.subtype) {
|
||||
case 'bar':
|
||||
return <BarChartComponent {...chartProps} />;
|
||||
case 'pie':
|
||||
return <PieChartComponent {...chartProps} />;
|
||||
case 'line':
|
||||
return <LineChartComponent {...chartProps} />;
|
||||
case 'area':
|
||||
return <AreaChartComponent {...chartProps} />;
|
||||
case 'stacked-bar':
|
||||
return <StackedBarChartComponent {...chartProps} />;
|
||||
case 'donut':
|
||||
return <DonutChartComponent {...chartProps} />;
|
||||
case 'combo':
|
||||
return <ComboChartComponent {...chartProps} />;
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">❓</div>
|
||||
<div>지원하지 않는 차트 타입</div>
|
||||
</div>
|
||||
// 데이터나 설정이 없으면
|
||||
if (!chartData || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">📊</div>
|
||||
<div className="text-sm">데이터를 설정해주세요</div>
|
||||
<div className="mt-1 text-xs">⚙️ 버튼을 클릭하여 설정</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// D3 차트 렌더링
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white p-2">
|
||||
<Chart
|
||||
chartType={element.subtype}
|
||||
data={chartData}
|
||||
config={element.chartConfig}
|
||||
width={width - 20}
|
||||
height={height - 20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface DonutChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도넛 차트 컴포넌트
|
||||
* - Recharts PieChart (innerRadius 사용) 사용
|
||||
* - 비율 표시에 적합 (중앙 공간 활용 가능)
|
||||
*/
|
||||
export function DonutChartComponent({ data, config, width = 250, height = 200 }: DonutChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// 파이 차트용 데이터 변환
|
||||
const pieData = data.map(item => ({
|
||||
name: String(item[xAxis] || ''),
|
||||
value: typeof item[yAxis as string] === 'number' ? item[yAxis as string] : 0
|
||||
}));
|
||||
|
||||
// 총합 계산
|
||||
const total = pieData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
// 커스텀 라벨 (퍼센트 표시)
|
||||
const renderLabel = (entry: any) => {
|
||||
const percent = ((entry.value / total) * 100).toFixed(1);
|
||||
return `${percent}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2 flex flex-col">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
innerRadius={50}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
'값'
|
||||
]}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
layout="vertical"
|
||||
align="right"
|
||||
verticalAlign="middle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* 중앙 총합 표시 */}
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
<div className="text-sm font-bold text-gray-800">
|
||||
{total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface LineChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 선 차트 컴포넌트
|
||||
*/
|
||||
export function LineChart({ data, config, width = 600, height = 400 }: LineChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리 → 연속형으로 변환)
|
||||
const xScale = d3.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 선 생성기
|
||||
const lineGenerator = d3
|
||||
.line<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y((d) => yScale(d));
|
||||
|
||||
// 부드러운 곡선 적용
|
||||
if (config.lineStyle === "smooth") {
|
||||
lineGenerator.curve(d3.curveMonotoneX);
|
||||
}
|
||||
|
||||
// 각 데이터셋에 대해 선 그리기
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const color = dataset.color || colors[i % colors.length];
|
||||
|
||||
// 선 그리기
|
||||
const path = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("d", lineGenerator);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
const totalLength = path.node()?.getTotalLength() || 0;
|
||||
path
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
}
|
||||
|
||||
// 데이터 포인트 (점) 그리기
|
||||
const circles = g
|
||||
.selectAll(`.circle-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `circle-${i}`)
|
||||
.attr("cx", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", color)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("line")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", 7)
|
||||
.attr("x2", 15)
|
||||
.attr("y2", 7)
|
||||
.attr("stroke", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke-width", 3);
|
||||
|
||||
legendRow
|
||||
.append("circle")
|
||||
.attr("cx", 7.5)
|
||||
.attr("cy", 7)
|
||||
.attr("r", 4)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface LineChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 꺾은선 차트 컴포넌트
|
||||
* - Recharts LineChart 사용
|
||||
* - 다중 라인 지원
|
||||
*/
|
||||
export function LineChartComponent({ data, config, width = 250, height = 200 }: LineChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// Y축 필드들 (단일 또는 다중)
|
||||
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
|
||||
|
||||
// 사용할 Y축 키들 결정
|
||||
const yKeys = yFields.filter(field => field && field !== 'y');
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey={xAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, name: string) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
{showLegend && yKeys.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{yKeys.map((key, index) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface PieChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isDonut?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 원형 차트 / 도넛 차트 컴포넌트
|
||||
*/
|
||||
export function PieChart({ data, config, width = 500, height = 500, isDonut = false }: PieChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 120, bottom: 40, left: 120 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
const radius = Math.min(chartWidth, chartHeight) / 2;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`);
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"];
|
||||
|
||||
// 첫 번째 데이터셋 사용
|
||||
const dataset = data.datasets[0];
|
||||
const pieData = data.labels.map((label, i) => ({
|
||||
label,
|
||||
value: dataset.data[i],
|
||||
}));
|
||||
|
||||
// 파이 생성기
|
||||
const pie = d3
|
||||
.pie<{ label: string; value: number }>()
|
||||
.value((d) => d.value)
|
||||
.sort(null);
|
||||
|
||||
// 아크 생성기
|
||||
const innerRadius = isDonut ? radius * (config.pieInnerRadius || 0.5) : 0;
|
||||
const arc = d3.arc<d3.PieArcDatum<{ label: string; value: number }>>().innerRadius(innerRadius).outerRadius(radius);
|
||||
|
||||
// 툴팁용 확대 아크
|
||||
const arcHover = d3
|
||||
.arc<d3.PieArcDatum<{ label: string; value: number }>>()
|
||||
.innerRadius(innerRadius)
|
||||
.outerRadius(radius + 10);
|
||||
|
||||
// 파이 조각 그리기
|
||||
const arcs = g.selectAll(".arc").data(pie(pieData)).enter().append("g").attr("class", "arc");
|
||||
|
||||
const paths = arcs
|
||||
.append("path")
|
||||
.attr("fill", (d, i) => colors[i % colors.length])
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
paths
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attrTween("d", function (d) {
|
||||
const interpolate = d3.interpolate({ startAngle: 0, endAngle: 0 }, d);
|
||||
return function (t) {
|
||||
return arc(interpolate(t)) || "";
|
||||
};
|
||||
});
|
||||
} else {
|
||||
paths.attr("d", arc);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
paths
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).transition().duration(200).attr("d", arcHover);
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${arc.centroid(d)[0]},${arc.centroid(d)[1]})`);
|
||||
|
||||
tooltip
|
||||
.append("rect")
|
||||
.attr("x", -50)
|
||||
.attr("y", -40)
|
||||
.attr("width", 100)
|
||||
.attr("height", 35)
|
||||
.attr("fill", "rgba(0,0,0,0.8)")
|
||||
.attr("rx", 4);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("y", -25)
|
||||
.text(d.data.label);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "14px")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("y", -10)
|
||||
.text(`${d.data.value} (${((d.data.value / d3.sum(dataset.data)) * 100).toFixed(1)}%)`);
|
||||
})
|
||||
.on("mouseout", function (event, d) {
|
||||
d3.select(this).transition().duration(200).attr("d", arc);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
pieData.forEach((d, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(`${d.label} (${d.value})`);
|
||||
});
|
||||
}
|
||||
|
||||
// 도넛 차트 중앙 텍스트
|
||||
if (isDonut) {
|
||||
const total = d3.sum(dataset.data);
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", -10)
|
||||
.style("font-size", "24px")
|
||||
.style("font-weight", "bold")
|
||||
.style("fill", "#333")
|
||||
.text(total.toLocaleString());
|
||||
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 15)
|
||||
.style("font-size", "14px")
|
||||
.style("fill", "#666")
|
||||
.text("Total");
|
||||
}
|
||||
}, [data, config, width, height, isDonut]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface PieChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원형 차트 컴포넌트
|
||||
* - Recharts PieChart 사용
|
||||
* - 자동 색상 배치 및 레이블
|
||||
*/
|
||||
export function PieChartComponent({ data, config, width = 250, height = 200 }: PieChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// 파이 차트용 데이터 변환
|
||||
const pieData = data.map((item, index) => ({
|
||||
name: String(item[xAxis] || `항목 ${index + 1}`),
|
||||
value: Number(item[yAxis]) || 0,
|
||||
color: colors[index % colors.length]
|
||||
})).filter(item => item.value > 0); // 0보다 큰 값만 표시
|
||||
|
||||
// 커스텀 레이블 함수
|
||||
const renderLabel = (entry: any) => {
|
||||
const percent = ((entry.value / pieData.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1);
|
||||
return `${percent}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={Math.min(width, height) * 0.3}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, name: string) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
iconType="circle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface StackedBarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 누적 막대 차트 컴포넌트
|
||||
*/
|
||||
export function StackedBarChart({ data, config, width = 600, height = 400 }: StackedBarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// 데이터 변환 (스택 데이터 생성)
|
||||
const stackData = data.labels.map((label, i) => {
|
||||
const obj: any = { label };
|
||||
data.datasets.forEach((dataset, j) => {
|
||||
obj[`series${j}`] = dataset.data[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const series = data.datasets.map((_, i) => `series${i}`);
|
||||
|
||||
// 스택 레이아웃
|
||||
const stack = d3.stack().keys(series);
|
||||
const stackedData = stack(stackData as any);
|
||||
|
||||
// X축 스케일
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.3);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue =
|
||||
config.stackMode === "percent" ? 100 : d3.max(stackedData[stackedData.length - 1], (d) => d[1] as number) || 0;
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
const yAxis = config.stackMode === "percent" ? d3.axisLeft(yScale).tickFormat((d) => `${d}%`) : d3.axisLeft(yScale);
|
||||
g.append("g").call(yAxis).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 퍼센트 모드인 경우 데이터 정규화
|
||||
if (config.stackMode === "percent") {
|
||||
stackData.forEach((label) => {
|
||||
const total = d3.sum(series.map((s) => (label as any)[s]));
|
||||
series.forEach((s) => {
|
||||
(label as any)[s] = total > 0 ? ((label as any)[s] / total) * 100 : 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 누적 막대 그리기
|
||||
const layers = g
|
||||
.selectAll(".layer")
|
||||
.data(stackedData)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "layer")
|
||||
.attr("fill", (_, i) => data.datasets[i].color || colors[i % colors.length]);
|
||||
|
||||
const bars = layers
|
||||
.selectAll("rect")
|
||||
.data((d) => d)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("x", (d) => xScale((d.data as any).label) || 0)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", xScale.bandwidth())
|
||||
.attr("height", 0)
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d[1] as number))
|
||||
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
||||
} else {
|
||||
bars
|
||||
.attr("y", (d) => yScale(d[1] as number))
|
||||
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const seriesIndex = stackedData.findIndex((s) => s.includes(d as any));
|
||||
const value = (d[1] as number) - (d[0] as number);
|
||||
const label = data.datasets[seriesIndex].label;
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${label}: ${value.toFixed(config.stackMode === "percent" ? 1 : 0)}${config.stackMode === "percent" ? "%" : ""}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
/**
|
||||
* 차트 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export { ChartRenderer } from './ChartRenderer';
|
||||
export { BarChartComponent } from './BarChartComponent';
|
||||
export { PieChartComponent } from './PieChartComponent';
|
||||
export { LineChartComponent } from './LineChartComponent';
|
||||
export { AreaChartComponent } from './AreaChartComponent';
|
||||
export { StackedBarChartComponent } from './StackedBarChartComponent';
|
||||
export { DonutChartComponent } from './DonutChartComponent';
|
||||
export { ComboChartComponent } from './ComboChartComponent';
|
||||
export { Chart } from "./Chart";
|
||||
export { BarChart } from "./BarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { AreaChart } from "./AreaChart";
|
||||
export { PieChart } from "./PieChart";
|
||||
export { StackedBarChart } from "./StackedBarChart";
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
{!loading && !error && connections.length > 0 && (
|
||||
<>
|
||||
<Select
|
||||
value={dataSource.externalConnectionId || ""}
|
||||
value={dataSource.externalConnectionId || undefined}
|
||||
onValueChange={(value) => {
|
||||
onChange({ externalConnectionId: value });
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { QueryResult, ChartConfig, ChartData, ChartDataset } from "../types";
|
||||
|
||||
/**
|
||||
* 쿼리 결과를 차트 데이터로 변환
|
||||
*/
|
||||
export function transformQueryResultToChartData(queryResult: QueryResult, config: ChartConfig): ChartData | null {
|
||||
if (!queryResult || !queryResult.rows.length || !config.xAxis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// X축 라벨 추출
|
||||
const labels = queryResult.rows.map((row) => String(row[config.xAxis!] || ""));
|
||||
|
||||
// Y축 데이터 추출
|
||||
const yAxisFields = Array.isArray(config.yAxis) ? config.yAxis : config.yAxis ? [config.yAxis] : [];
|
||||
|
||||
if (yAxisFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 각 Y축 필드에 대해 데이터셋 생성
|
||||
const datasets: ChartDataset[] = yAxisFields.map((field, index) => {
|
||||
const data = queryResult.rows.map((row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
});
|
||||
|
||||
return {
|
||||
label: field,
|
||||
data,
|
||||
color: config.colors?.[index],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 차트 데이터로 변환
|
||||
*/
|
||||
export function transformApiResponseToChartData(
|
||||
apiData: Record<string, unknown>[],
|
||||
config: ChartConfig,
|
||||
): ChartData | null {
|
||||
// API 응답을 QueryResult 형식으로 변환
|
||||
if (!apiData || apiData.length === 0 || !config.xAxis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryResult: QueryResult = {
|
||||
columns: Object.keys(apiData[0]),
|
||||
rows: apiData,
|
||||
totalRows: apiData.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
return transformQueryResultToChartData(queryResult, config);
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
|
|
@ -4764,6 +4766,47 @@
|
|||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
|
|
@ -4776,6 +4819,43 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
|
|
@ -4785,6 +4865,30 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
|
|
@ -4807,6 +4911,40 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
|
|
@ -4816,6 +4954,32 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
|
|
@ -4825,6 +4989,27 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
|
|
@ -4846,6 +5031,33 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
|
|
@ -4862,6 +5074,19 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
|
|
@ -5130,6 +5355,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
|
|
@ -9028,6 +9262,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
|
|
@ -9058,6 +9298,12 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
|
|
|
|||
Loading…
Reference in New Issue