차트 구현 1차 완료(바 차트 작동)

This commit is contained in:
dohyeons 2025-10-14 15:25:11 +09:00
parent 3db7feb36b
commit 4cc5f1344f
21 changed files with 1783 additions and 667 deletions

View File

@ -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`: 데이터 변환 유틸리티
- 데이터 페칭 및 자동 새로고침

View File

@ -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">

View File

@ -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" />

View File

@ -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,

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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" }} />;
}

View File

@ -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>
);
}

View File

@ -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" }} />;
}

View File

@ -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";

View File

@ -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 });
}}

View File

@ -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);
}

View File

@ -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",

View File

@ -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",