Merge: 충돌 해결 - CustomMetricWidget 실제 코드 유지

This commit is contained in:
leeheejin
2025-10-28 19:00:24 +09:00
12 changed files with 589 additions and 84 deletions

View File

@@ -389,9 +389,9 @@ export function CanvasElement({
let newX = resizeStart.elementX;
let newY = resizeStart.elementY;
// 최소 크기 설정: 달력은 2x3, 나머지는 2x2
const minWidthCells = 2;
const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2;
// 최소 크기 설정: 모든 위젯 1x1
const minWidthCells = 1;
const minHeightCells = 1;
const minWidth = cellSize * minWidthCells;
const minHeight = cellSize * minHeightCells;
@@ -757,7 +757,7 @@ export function CanvasElement({
<div
ref={elementRef}
data-element-id={element.id}
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
className={`absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
style={{
left: displayPosition.x,
top: displayPosition.y,
@@ -768,7 +768,7 @@ export function CanvasElement({
onMouseDown={handleMouseDown}
>
{/* 헤더 */}
<div className="flex cursor-move items-center justify-between px-4 py-2">
<div className="flex cursor-move items-center justify-between px-2 py-1">
<div className="flex items-center gap-2">
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */}
{element.type === "chart" && (
@@ -779,7 +779,7 @@ export function CanvasElement({
}}
>
<SelectTrigger
className="h-7 w-[140px] text-xs"
className="h-6 w-[120px] text-[11px]"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
@@ -808,7 +808,7 @@ export function CanvasElement({
)}
{/* 제목 */}
{!element.type || element.type !== "chart" ? (
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
<span className="text-xs font-bold text-gray-800">{element.customTitle || element.title}</span>
) : null}
</div>
<div className="flex gap-1">
@@ -816,18 +816,18 @@ export function CanvasElement({
<Button
variant="ghost"
size="icon"
className="element-close hover:bg-destructive h-6 w-6 text-gray-400 hover:text-white"
className="element-close hover:bg-destructive h-5 w-5 text-gray-400 hover:text-white"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
title="삭제"
>
<X className="h-4 w-4" />
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* 내용 */}
<div className="relative h-[calc(100%-50px)] px-4 pb-4">
<div className="relative h-[calc(100%-32px)] px-2 pb-2">
{element.type === "chart" ? (
// 차트 렌더링
<div className="h-full w-full bg-white">
@@ -843,7 +843,7 @@ export function CanvasElement({
element={element}
data={chartData || undefined}
width={element.size.width}
height={element.size.height - 45}
height={element.size.height - 32}
/>
)}
</div>

View File

@@ -277,6 +277,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setElements((prev) => [...prev, newElement]);
setElementCounter((prev) => prev + 1);
setSelectedElement(newElement.id);
// 새 요소 생성 시 자동으로 설정 사이드바 열기
setSidebarElement(newElement);
setSidebarOpen(true);
},
[elementCounter, canvasConfig],
);

View File

@@ -242,12 +242,12 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
// D3 차트 렌더링
const actualWidth = width !== undefined ? width : containerWidth;
// 원형 차트는 더 큰 크기가 필요 (최소 400px)
// 최소 크기 제약 완화 (1x1 위젯 지원)
const isCircularChart = element.subtype === "pie" || element.subtype === "donut";
const minWidth = isCircularChart ? 400 : 200;
const finalWidth = Math.max(actualWidth - 20, minWidth);
// 원형 차트는 범례 공간을 위해 더 많은 여백 필요
const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300);
const minWidth = 35; // 최소 너비 35px
const finalWidth = Math.max(actualWidth - 4, minWidth);
// 최소 높이도 35px로 설정
const finalHeight = Math.max(height - (isCircularChart ? 10 : 4), 35);
console.log("🎨 ChartRenderer:", {
elementSubtype: element.subtype,
@@ -263,7 +263,7 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
});
return (
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-2">
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-0.5">
<div className="flex items-center justify-center">
<Chart
chartType={element.subtype}

View File

@@ -169,7 +169,7 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa
.attr("x", 0)
.attr("y", 18)
.attr("text-anchor", "middle") // 텍스트 중앙 정렬
.style("font-size", "10px")
.style("font-size", "8px")
.style("fill", "#333")
.text(`${d.label} (${d.value})`);
});

View File

@@ -31,8 +31,8 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
const timezoneLabel = timezone ? getTimezoneLabel(timezone) : "";
return (
<div className="flex h-full flex-col items-center justify-center p-2">
<svg viewBox="0 0 200 200" className="h-full max-h-[200px] w-full max-w-[200px]">
<div className="flex h-full flex-col items-center justify-center p-0.5">
<svg viewBox="0 0 200 200" className="h-full w-full">
{/* 시계판 배경 */}
<circle cx="100" cy="100" r="98" fill={colors.background} stroke={colors.border} strokeWidth="2" />
@@ -70,7 +70,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
y={y}
textAnchor="middle"
dominantBaseline="middle"
fontSize="20"
fontSize="16"
fontWeight="bold"
fill={colors.number}
>
@@ -86,7 +86,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
x2={100 + 40 * Math.cos((hourAngle * Math.PI) / 180)}
y2={100 + 40 * Math.sin((hourAngle * Math.PI) / 180)}
stroke={colors.hourHand}
strokeWidth="6"
strokeWidth="5"
strokeLinecap="round"
/>
@@ -97,7 +97,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
x2={100 + 60 * Math.cos((minuteAngle * Math.PI) / 180)}
y2={100 + 60 * Math.sin((minuteAngle * Math.PI) / 180)}
stroke={colors.minuteHand}
strokeWidth="4"
strokeWidth="3"
strokeLinecap="round"
/>
@@ -108,18 +108,18 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
x2={100 + 75 * Math.cos((secondAngle * Math.PI) / 180)}
y2={100 + 75 * Math.sin((secondAngle * Math.PI) / 180)}
stroke={colors.secondHand}
strokeWidth="2"
strokeWidth="1.5"
strokeLinecap="round"
/>
{/* 중심점 */}
<circle cx="100" cy="100" r="6" fill={colors.center} />
<circle cx="100" cy="100" r="3" fill={colors.background} />
<circle cx="100" cy="100" r="4" fill={colors.center} />
<circle cx="100" cy="100" r="2" fill={colors.background} />
</svg>
{/* 타임존 표시 */}
{timezoneLabel && (
<div className="mt-1 text-center text-xs font-medium" style={{ color: colors.number }}>
<div className="mt-0 text-center text-[8px] font-medium" style={{ color: colors.number }}>
{timezoneLabel}
</div>
)}

View File

@@ -56,21 +56,21 @@ export function DigitalClock({
return (
<div
className={`flex h-full flex-col items-center justify-center ${compact ? "p-1" : "p-4"} text-center ${themeClasses.container}`}
className={`flex h-full flex-col items-center justify-center ${compact ? "p-0.5" : "p-2"} text-center ${themeClasses.container}`}
style={themeClasses.style}
>
{/* 날짜 표시 (compact 모드에서는 숨김) */}
{!compact && showDate && dateString && (
<div className={`mb-3 text-sm font-medium ${themeClasses.date}`}>{dateString}</div>
<div className={`mb-1 text-[10px] leading-tight font-medium ${themeClasses.date}`}>{dateString}</div>
)}
{/* 시간 표시 */}
<div className={`font-bold tabular-nums ${themeClasses.time} ${compact ? "text-xl" : "text-5xl"}`}>
<div className={`leading-none font-bold tabular-nums ${themeClasses.time} ${compact ? "text-sm" : "text-2xl"}`}>
{timeString}
</div>
{/* 타임존 표시 */}
<div className={`${compact ? "mt-0.5" : "mt-3"} text-xs font-medium ${themeClasses.timezone}`}>
<div className={`${compact ? "mt-0" : "mt-1"} text-[9px] font-medium ${themeClasses.timezone}`}>
{timezoneLabel}
</div>
</div>

View File

@@ -94,24 +94,19 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
// 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 추가 (기존 컬럼은 유지)
setListConfig((prev) => {
const existingFields = prev.columns.map((col) => col.field);
const newColumns = result.columns
.filter((col) => !existingFields.includes(col))
.map((col, idx) => ({
id: `col_${Date.now()}_${idx}`,
field: col,
label: col,
visible: true,
align: "left" as const,
}));
// 쿼리 실행 시마다 컬럼 설정 초기화 (새로운 쿼리 결과로 덮어쓰기)
const newColumns = result.columns.map((col, idx) => ({
id: `col_${Date.now()}_${idx}`,
field: col,
label: col,
visible: true,
align: "left" as const,
}));
return {
...prev,
columns: [...prev.columns, ...newColumns],
};
});
setListConfig((prev) => ({
...prev,
columns: newColumns,
}));
}, []);
// 컬럼 설정 변경

View File

@@ -31,6 +31,7 @@ interface Yard3DCanvasProps {
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
gridSize?: number; // 그리드 크기 (기본값: 5)
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
}
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
@@ -467,6 +468,75 @@ function MaterialBox({
}
// 3D 씬 컴포넌트
// 카메라 포커스 컨트롤러
function CameraFocusController({
focusOnPlacementId,
placements,
orbitControlsRef,
}: {
focusOnPlacementId?: number | null;
placements: YardPlacement[];
orbitControlsRef: React.RefObject<any>;
}) {
const { camera } = useThree();
useEffect(() => {
console.log("🎥 CameraFocusController triggered");
console.log(" - focusOnPlacementId:", focusOnPlacementId);
console.log(" - orbitControlsRef.current:", orbitControlsRef.current);
console.log(" - placements count:", placements.length);
if (focusOnPlacementId && orbitControlsRef.current) {
const targetPlacement = placements.find((p) => p.id === focusOnPlacementId);
console.log(" - targetPlacement:", targetPlacement);
if (targetPlacement) {
console.log("✅ Starting camera animation to:", targetPlacement.material_name || targetPlacement.id);
const controls = orbitControlsRef.current;
const targetPosition = new THREE.Vector3(
targetPlacement.position_x,
targetPlacement.position_y,
targetPlacement.position_z,
);
// 카메라 위치 계산 (요소 위에서 약간 비스듬히)
const cameraOffset = new THREE.Vector3(15, 15, 15);
const newCameraPosition = targetPosition.clone().add(cameraOffset);
// 부드러운 애니메이션으로 카메라 이동
const startPos = camera.position.clone();
const startTarget = controls.target.clone();
const duration = 1000; // 1초
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeInOutCubic 이징 함수
const eased = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2;
// 카메라 위치 보간
camera.position.lerpVectors(startPos, newCameraPosition, eased);
// 카메라 타겟 보간
controls.target.lerpVectors(startTarget, targetPosition, eased);
controls.update();
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
}
}
}, [focusOnPlacementId, placements, camera, orbitControlsRef]);
return null;
}
function Scene({
placements,
selectedPlacementId,
@@ -474,12 +544,20 @@ function Scene({
onPlacementDrag,
gridSize = 5,
onCollisionDetected,
focusOnPlacementId,
}: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null);
return (
<>
{/* 카메라 포커스 컨트롤러 */}
<CameraFocusController
focusOnPlacementId={focusOnPlacementId}
placements={placements}
orbitControlsRef={orbitControlsRef}
/>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
@@ -551,6 +629,7 @@ export default function Yard3DCanvas({
onPlacementDrag,
gridSize = 5,
onCollisionDetected,
focusOnPlacementId,
}: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
@@ -577,6 +656,7 @@ export default function Yard3DCanvas({
onPlacementDrag={onPlacementDrag}
gridSize={gridSize}
onCollisionDetected={onCollisionDetected}
focusOnPlacementId={focusOnPlacementId}
/>
</Suspense>
</Canvas>

View File

@@ -38,6 +38,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [focusPlacementId, setFocusPlacementId] = useState<number | null>(null); // 카메라 포커스할 요소 ID
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showConfigPanel, setShowConfigPanel] = useState(false);
@@ -203,9 +204,30 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
};
// 요소 선택 (3D 캔버스 또는 목록에서)
const handleSelectPlacement = (placement: YardPlacement) => {
const handleSelectPlacement = (placement: YardPlacement | null) => {
console.log("📍 handleSelectPlacement called with:", placement);
if (!placement) {
// 빈 공간 클릭 시 선택 해제
console.log(" → Deselecting (null placement)");
setSelectedPlacement(null);
setShowConfigPanel(false);
setFocusPlacementId(null);
return;
}
console.log(" → Selecting placement:", placement.id, placement.material_name);
setSelectedPlacement(placement);
setShowConfigPanel(false); // 선택 시에는 설정 패널 닫기
console.log(" → Setting focusPlacementId to:", placement.id);
setFocusPlacementId(placement.id); // 카메라 포커스
// 카메라 애니메이션 완료 후 focusPlacementId 초기화 (재클릭 시 다시 포커스 가능)
setTimeout(() => {
console.log(" → Clearing focusPlacementId");
setFocusPlacementId(null);
}, 1100); // 애니메이션 시간(1000ms)보다 약간 길게
};
// 설정 버튼 클릭
@@ -500,8 +522,9 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement | null)}
onPlacementDrag={handlePlacementDrag}
focusOnPlacementId={focusPlacementId}
onCollisionDetected={() => {
toast({
title: "배치 불가",