goi-web/src/features/crm/CsHome/ActiveCustomerTrendLine.jsx

122 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo } from 'react'
// 최근 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 [, 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),
added: Number(p.added ?? 0),
removed: Number(p.removed ?? 0),
}))
if (!pts.length) return { pts: [] }
const n = pts.length
const xOf = (i) => (n === 1 ? (X0 + X1) / 2 : X0 + ((X1 - X0) * i) / (n - 1))
// 위: 선 스케일 (절대 수 — 변화가 작으니 데이터 범위에 맞춰 확대)
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(' ')
// 아래: 막대 스케일 (그 주 증감 최대치 기준 단일 스케일)
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 <div className="cshome-chart-empty">최근 데이터가 없습니다.</div>
}
const { pts, xOf, yLine, linePath, lastIdx, ppu, baseline, bw, hasBars } = model
return (
<svg className="cshome-chart" viewBox={`0 0 ${W} ${H}`} role="img"
aria-label="활성 고객 추이 및 일별 신규·해지">
{/* 영역 라벨 */}
<text x={8} y={16} fontSize="9.5" fill="#9aa5a0">활성 고객 ()</text>
<text x={8} y={B_TOP - 16} fontSize="9.5" fill="#9aa5a0">일별 증감</text>
{/* 위: 활성 수 선 */}
<path d={linePath} fill="none" stroke={ACCENT} strokeWidth="2"
strokeLinejoin="round" strokeLinecap="round" />
{pts.map((p, i) => {
const cx = xOf(i), cy = yLine(p.count), isLast = i === lastIdx
return (
<g key={`l${i}`}>
<circle cx={cx} cy={cy} r={isLast ? 4 : 3} fill={ACCENT} />
<text x={cx} y={cy - 9} textAnchor="middle"
fontSize={isLast ? 10.5 : 9.5} fontWeight={isLast ? 700 : 500}
fill={isLast ? ACCENT : '#5b6b60'}>{p.count.toLocaleString()}</text>
</g>
)
})}
{/* 아래: 0 기준선 + 신규/해지 막대 */}
<line x1={X0} y1={baseline} x2={X1} y2={baseline} stroke="#d8e0db" />
{pts.map((p, i) => {
const cx = xOf(i)
const upH = p.added * ppu, downH = p.removed * ppu
const none = p.added === 0 && p.removed === 0
return (
<g key={`b${i}`}>
{p.added > 0 && (
<>
<rect x={cx - bw / 2} y={baseline - upH} width={bw} height={upH} rx="3" fill={ACCENT} />
<text x={cx} y={baseline - upH - 6} textAnchor="middle"
fontSize="11" fontWeight="700" fill={ACCENT}>+{p.added.toLocaleString()}</text>
</>
)}
{p.removed > 0 && (
<>
<rect x={cx - bw / 2} y={baseline} width={bw} height={downH} rx="3" fill={REM} />
<text x={cx} y={baseline + downH + 14} textAnchor="middle"
fontSize="11" fontWeight="700" fill={REM}>{p.removed.toLocaleString()}</text>
</>
)}
{none && (
<text x={cx} y={baseline - 6} textAnchor="middle" fontSize="10" fill="#c4ccc6">0</text>
)}
<text x={cx} y={DATE_Y} textAnchor="middle" fontSize="9.5" fill="#aab2ac">
{p.m}/{p.d}
</text>
</g>
)
})}
{!hasBars && (
<text x={W / 2} y={baseline - 12} textAnchor="middle" fontSize="11" fill="#9aa5a0">
기간 변동 없음
</text>
)}
</svg>
)
}