Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/dashboard/charts/useAnomalyMarkerPos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export function useAnomalyMarkerPos(
const marker = container.querySelector<SVGCircleElement>(
".apexcharts-point-annotation-marker",
);
if (!marker) return;
if (!marker) {
setMarkerPos(null);
return;
}

// getBoundingClientRect으로 뷰포트 기준 위치 계산 후 컨테이너 기준으로 변환
const markerRect = marker.getBoundingClientRect();
Expand Down
157 changes: 155 additions & 2 deletions src/components/dashboard/platform/PlatformTrafficChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useMemo } from "react";
import { memo, useCallback, useId, useMemo, useRef, useState } from "react";
import ReactApexChart from "react-apexcharts";
import type { ApexOptions } from "apexcharts";

Expand All @@ -16,23 +16,74 @@ import {
import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp";

import { Skeleton } from "@/components/common/skeleton/Skeleton";
import { useAnomalyMarkerPos } from "@/components/dashboard/charts/useAnomalyMarkerPos";

interface IPlatformTrafficChartProps {
data: IClickStreamItem | null;
platform: TProviderType;
isError?: boolean;
suspectDetail: IClickStreamItem["suspectDetail"] | null;
}

// 이상 징후 상세 버블
const AnomalyBubble = memo(function AnomalyBubble({
id,
x,
y,
message,
campaignName,
adName,
}: {
id: string;
x: number;
y: number;
message?: string;
campaignName?: string;
adName?: string;
}) {
const GAP = 12;
return (
<div
id={id}
role="tooltip"
className="pointer-events-none absolute transition-transform duration-200 ease-out"
style={{
left: x,
top: y - GAP,
transform: "translateX(-50%) translateY(-100%)",
}}
>
<div className="relative min-w-40 rounded-lg border border-surface-400 bg-surface-100 px-5 py-4">
<div className="mb-1.5 flex items-center justify-center gap-1.5">
<span className="inline-block size-2 shrink-0 rounded-full bg-info-red" />
<p className="font-label text-text-title">클릭 이상 징후 감지</p>
</div>
<div className="text-center">
{campaignName && (
<p className="font-caption text-text-muted">{campaignName}</p>
)}
{adName && <p className="font-caption text-text-muted">{adName}</p>}
{message && <p className="font-caption text-text-muted">{message}</p>}
</div>
</div>
</div>
);
});

const PlatformTrafficChart = memo(function PlatformTrafficChart({
data,
platform,
isError = false,
suspectDetail = null,
}: IPlatformTrafficChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const anomalyBubbleId = useId();
const seriesData = useMemo(() => {
if (!data) return [];
return data.timeSeriesData.map((d) => ({
x: parseMinuteToTimestamp(d.minute),
y: d.count,
minute: d.minute,
}));
}, [data]);

Expand All @@ -59,6 +110,41 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({
return Math.ceil((maxCount * 1.2) / unit) * unit;
}, [seriesData]);

// 이상 징후 발생 시 마커 좌표
// timestamp가 있으면 분 매칭, 없으면/실패 시 클릭수 최대 지점 fallback
const { anomalyTimestamp, anomalyY } = useMemo(() => {
if (!data?.hasSuspect || seriesData.length === 0) {
return { anomalyTimestamp: undefined, anomalyY: undefined };
}

let matchIdx = -1;

if (suspectDetail?.timestamp) {
const ts = new Date(suspectDetail.timestamp);
if (!Number.isNaN(ts.getTime())) {
const minute =
ts.getFullYear().toString() +
String(ts.getMonth() + 1).padStart(2, "0") +
String(ts.getDate()).padStart(2, "0") +
String(ts.getHours()).padStart(2, "0") +
String(ts.getMinutes()).padStart(2, "0");
matchIdx = seriesData.findIndex((p) => p.minute === minute);
}
Comment thread
YermIm marked this conversation as resolved.
Resolved
}

if (matchIdx === -1) {
matchIdx = seriesData.reduce(
(maxI, cur, i, arr) => (cur.y > arr[maxI].y ? i : maxI),
0,
);
}

return {
anomalyTimestamp: seriesData[matchIdx]?.x,
anomalyY: seriesData[matchIdx]?.y,
};
}, [data?.hasSuspect, seriesData, suspectDetail]);

const chartOptions: ApexOptions = {
chart: {
type: "area",
Expand All @@ -84,6 +170,23 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({
},
colors: [platformColor],
markers: { size: 0, hover: { size: 5 } },
annotations: {
points:
anomalyTimestamp !== undefined && anomalyY !== undefined
? [
{
x: anomalyTimestamp,
y: anomalyY,
marker: {
size: 3,
fillColor: "var(--color-info-red)",
strokeColor: "var(--color-info-red)",
strokeWidth: 1,
},
},
]
: [],
},
xaxis: {
type: "numeric",
min: xMin,
Expand Down Expand Up @@ -138,6 +241,17 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({
},
];

// Apex annotation 점의 DOM 좌표 → 버블/링 위치
const markerPos = useAnomalyMarkerPos(containerRef, anomalyTimestamp);
const [isAnomalyHovered, setIsAnomalyHovered] = useState(false);
const [isAnomalyFocused, setIsAnomalyFocused] = useState(false);

const handleFocus = useCallback(() => setIsAnomalyFocused(true), []);
const handleBlur = useCallback(() => setIsAnomalyFocused(false), []);
const handlePointerEnter = useCallback(() => setIsAnomalyHovered(true), []);
const handlePointerLeave = useCallback(() => setIsAnomalyHovered(false), []);
const showBubble = isAnomalyHovered || isAnomalyFocused;

if (isError && !data) {
return (
<div className="flex h-75 items-center justify-center font-body2 text-text-muted">
Expand Down Expand Up @@ -165,13 +279,52 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({
연결이 원활하지 않아 마지막 데이터를 표시합니다.
</p>
)}
<div className="min-h-0 flex-1">
<div
ref={containerRef}
data-hide-tooltip={showBubble || undefined}
className="relative min-h-0 flex-1 [&[data-hide-tooltip]_.apexcharts-tooltip]:pointer-events-none [&[data-hide-tooltip]_.apexcharts-tooltip]:invisible"
>
<ReactApexChart
options={chartOptions}
series={series}
type="area"
height={360}
/>
{anomalyTimestamp !== undefined &&
anomalyY !== undefined &&
markerPos && (
<>
<span
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-info-red opacity-60 [animation-duration:2s]"
style={{ left: markerPos.x, top: markerPos.y }}
/>
<span
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-info-red opacity-40 [animation-delay:1s] [animation-duration:2s]"
style={{ left: markerPos.x, top: markerPos.y }}
/>
<button
type="button"
className="absolute size-6 -translate-x-1/2 -translate-y-1/2 opacity-0"
style={{ left: markerPos.x, top: markerPos.y }}
aria-label="클릭 이상 징후 상세 보기"
aria-describedby={showBubble ? anomalyBubbleId : undefined}
onFocus={handleFocus}
onBlur={handleBlur}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
{showBubble && (
<AnomalyBubble
id={anomalyBubbleId}
x={markerPos.x}
y={markerPos.y}
message={suspectDetail?.message}
campaignName={suspectDetail?.campaignName}
adName={suspectDetail?.adName}
/>
)}
</>
)}
</div>
</div>
);
Expand Down
15 changes: 9 additions & 6 deletions src/components/dashboard/platform/SinglePlatformView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ export default function SinglePlatformView({
isError: isMetricFactsError,
} = usePlatformMetricFacts(platform, viewRange);

const { data: clickStreamData, isError: isClickStreamError } = useClickStream(
{
mode: "dummy",
providerType: platform,
},
);
const {
data: clickStreamData,
suspectDetail,
isError: isClickStreamError,
} = useClickStream({
mode: "dummy",
providerType: platform,
});

const budgetPct = budget
? Math.round((budget.spent / budget.totalBudget) * 100)
Expand Down Expand Up @@ -159,6 +161,7 @@ export default function SinglePlatformView({
data={clickStreamData}
platform={platform}
isError={isClickStreamError}
suspectDetail={suspectDetail}
/>
</Card>

Expand Down
2 changes: 1 addition & 1 deletion src/types/dashboard/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@ export interface IClickStreamItem {
campaignName?: string;
adName?: string;
message: string;
timestamp: string;
timestamp?: string;
} | null;
}
Loading