diff --git a/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx b/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx
index 0aba8ec..bf74950 100644
--- a/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx
+++ b/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx
@@ -1,72 +1,121 @@
import { useMemo } from 'react'
-// 최근 N일 활성 고객 수 — 미니멀 라인.
-// 선 + 점 + 각 점 위 숫자 + 아래 날짜만. (격자/영역/그림자/툴팁 없음)
-const W = 720, H = 180
-const X0 = 30, X1 = W - 30 // 좌우 여백
-const YTOP = 52, YBOT = 132 // 선이 그려지는 세로 밴드
-const ACCENT = '#2e7d32'
+// 최근 N일: 위 = 활성 고객 수(선), 아래 = 일별 신규(+)/해지(−) 막대. x축(날짜) 공유.
+// 절대 수와 일별 증감은 스케일이 완전히 달라서 한 영역에 겹치지 않고 위/아래로 분리.
+const W = 720, H = 320
+const X0 = 40, X1 = W - 28
+const L_TOP = 44, L_BOT = 118 // 위: 선 영역
+const B_TOP = 182, B_BOT = 268 // 아래: 막대 영역
+const DATE_Y = H - 10
+const ACCENT = '#2e7d32' // 선 / 신규(+)
+const REM = '#d64545' // 해지(−)
const parseYmd = (s) => {
- const [y, m, d] = String(s).split('-').map(Number)
+ const [, m, d] = String(s).split('-').map(Number)
return { m, d }
}
export default function ActiveCustomerTrendLine({ points }) {
const model = useMemo(() => {
- const pts = (points ?? [])
- .map((p) => ({ ...parseYmd(p.date), count: Number(p.count ?? 0) }))
- .filter((p) => Number.isFinite(p.count))
+ const pts = (points ?? []).map((p) => ({
+ ...parseYmd(p.date),
+ count: Number(p.count ?? 0),
+ added: Number(p.added ?? 0),
+ removed: Number(p.removed ?? 0),
+ }))
if (!pts.length) return { pts: [] }
- const counts = pts.map((p) => p.count)
- let lo = Math.min(...counts), hi = Math.max(...counts)
- // 선이 밴드 가운데쯤 오도록 살짝 여백
- const pad = lo === hi ? Math.max(1, Math.round(Math.abs(hi) * 0.08)) : (hi - lo) * 0.6
- lo -= pad; hi += pad
- if (lo === hi) hi = lo + 1
-
const n = pts.length
const xOf = (i) => (n === 1 ? (X0 + X1) / 2 : X0 + ((X1 - X0) * i) / (n - 1))
- const yOf = (v) => YBOT - ((v - lo) / (hi - lo)) * (YBOT - YTOP)
- const xy = pts.map((p, i) => [xOf(i), yOf(p.count)])
- const linePath = xy
- .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p[0].toFixed(2)},${p[1].toFixed(2)}`)
+ // 위: 선 스케일 (절대 수 — 변화가 작으니 데이터 범위에 맞춰 확대)
+ const cs = pts.map((p) => p.count)
+ let lo = Math.min(...cs), hi = Math.max(...cs)
+ const pad = lo === hi ? Math.max(1, Math.round(Math.abs(hi) * 0.02)) : (hi - lo) * 0.6
+ lo -= pad; hi += pad
+ if (lo === hi) hi = lo + 1
+ const yLine = (v) => L_BOT - ((v - lo) / (hi - lo)) * (L_BOT - L_TOP)
+ const linePath = pts
+ .map((p, i) => `${i === 0 ? 'M' : 'L'} ${xOf(i).toFixed(2)},${yLine(p.count).toFixed(2)}`)
.join(' ')
- return { pts, xOf, yOf, linePath }
+ // 아래: 막대 스케일 (그 주 증감 최대치 기준 단일 스케일)
+ const maxUp = Math.max(0, ...pts.map((p) => p.added))
+ const maxDown = Math.max(0, ...pts.map((p) => p.removed))
+ const denom = maxUp + maxDown
+ const ppu = denom > 0 ? (B_BOT - B_TOP) / denom : 0
+ const baseline = denom > 0 ? B_TOP + maxUp * ppu : (B_TOP + B_BOT) / 2
+ const spacing = n > 1 ? (X1 - X0) / (n - 1) : (X1 - X0)
+ const bw = Math.min(26, Math.max(12, spacing * 0.42))
+
+ return { pts, xOf, yLine, linePath, lastIdx: n - 1, ppu, baseline, bw, hasBars: denom > 0 }
}, [points])
if (!model.pts.length) {
- return
최근 활성 고객 데이터가 없습니다.
+ return 최근 데이터가 없습니다.
}
- const { pts, xOf, yOf, linePath } = model
- const lastIdx = pts.length - 1
+ const { pts, xOf, yLine, linePath, lastIdx, ppu, baseline, bw, hasBars } = model
return (
-