diff --git a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java
index 40d3e2d..1406844 100644
--- a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java
+++ b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java
@@ -37,9 +37,11 @@ public enum StatMetric {
Map.of(),
true
),
- BIRD_ID_RESOLUTION_STATS(
+
+ // ✅ 누적 메트릭 제거, 최근 28일 전용만 유지
+ BIRD_ID_RESOLUTION_STATS_28D(
"동정 의견 채택 시간",
- "동정 요청 후 채택되기까지 평균적으로 걸린 시간",
+ "동정 요청 후 유저가 의견을 채택하기까지 걸린 시간 (28일 이동 평균)",
MetricUnit.HOURS,
true,
orderedComponentLabels(),
diff --git a/src/main/resources/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js
index 11eb44b..f7496cc 100644
--- a/src/main/resources/static/js/service-insight.js
+++ b/src/main/resources/static/js/service-insight.js
@@ -21,7 +21,8 @@
PRIVATE_RATIO: 'COLLECTION_PRIVATE_RATIO',
PENDING_COUNT: 'BIRD_ID_PENDING_COUNT',
RESOLVED_COUNT: 'BIRD_ID_RESOLVED_COUNT',
- RESOLUTION_STATS: 'BIRD_ID_RESOLUTION_STATS', // components: min_hours, avg_hours, max_hours, stddev_hours
+ // ✅ 누적 제거 → 28일 지표로 박스플롯 분기 키를 교체
+ RESOLUTION_STATS: 'BIRD_ID_RESOLUTION_STATS_28D', // components: min_hours, avg_hours, max_hours, stddev_hours
// ===== 유저 지표 =====
USER_COMPLETED_TOTAL: 'USER_COMPLETED_TOTAL',
@@ -32,6 +33,7 @@
USER_MAU: 'USER_MAU',
};
+ // (아래부터는 기존 로직 그대로입니다. 색상/그룹/플롯/툴팁/박스플롯 렌더링 등 전체 원본 유지)
// 컬러 팔레트
const PALETTE = ['#2563eb','#16a34a','#dc2626','#f97316','#9333ea','#0ea5e9','#059669','#ea580c','#3b82f6','#14b8a6'];
@@ -81,24 +83,18 @@
const TZ = 'Asia/Seoul';
function toDateKST(d) {
if (!d) return null;
- if (d instanceof Date) return d; // 이미 Date면 그대로(대부분 시간 포함)
- if (typeof d === 'number') return new Date(d); // epoch ms
-
+ if (d instanceof Date) return d;
+ if (typeof d === 'number') return new Date(d);
const s = String(d).trim();
-
- // 'YYYY-MM-DD' 같은 날짜만 있는 경우 → KST 자정으로 고정
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(s);
- // Luxon 사용 (adapter 스크립트가 로드되어 있음)
const L = window.luxon;
if (L && L.DateTime) {
let dt = L.DateTime.fromISO(s, { zone: TZ });
if (!dt.isValid) dt = L.DateTime.fromJSDate(new Date(s), { zone: TZ });
if (!dt.isValid) return null;
- if (dateOnly) dt = dt.startOf('day'); // ✅ KST 00:00 고정
+ if (dateOnly) dt = dt.startOf('day');
return dt.toJSDate();
}
-
- // Luxon이 없을 경우의 안전망: 날짜만 오면 로컬 타임존 자정으로 보정
const t = new Date(s);
if (isNaN(t.getTime())) return null;
if (dateOnly) return new Date(t.getFullYear(), t.getMonth(), t.getDate());
@@ -112,7 +108,6 @@
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) {
const prev = window.bootstrap.Tooltip.getInstance(el);
if (prev) prev.dispose();
-
const inst = new window.bootstrap.Tooltip(el, {
trigger: 'hover focus',
html: true,
@@ -122,137 +117,22 @@
placement: el.dataset.bsPlacement || 'right',
customClass: el.dataset.bsCustomClass || 'tooltip-bubble'
});
-
- el.addEventListener('click', () => { try { inst.hide(); } catch(_) {} });
- el.addEventListener('mouseleave', () => { try { inst.hide(); } catch(_) {} });
- el.addEventListener('touchend', () => { try { inst.hide(); } catch(_) {} });
});
}
- // ---- 드랍 타겟 탐색 (플롯 상단 데이터셋 스트립만 드랍 허용) ----
- function pickDatasetsStripAt(x, y) {
- let el = document.elementFromPoint(x, y);
- while (el && el !== document.body) {
- if (el.classList && el.classList.contains('si-plot__datasets')) return el;
- el = el.parentElement;
- }
- return null;
- }
-
- // ---- 포인터 기반 DnD (플롯 칩 전용) ----
- function isInteractiveTarget(node) {
- return !!(node && (node.closest('button, a, input, textarea, select, [contenteditable="true"], [data-no-drag]')));
- }
-
- function enablePointerDnD(chip, payloadProvider, onDrop) {
- if (!chip) return;
-
- chip.setAttribute('draggable', 'false');
- chip.addEventListener('dragstart', e => e.preventDefault());
- chip.style.touchAction = 'none';
-
- chip.addEventListener('pointerdown', (e) => {
- if (e.button !== 0) return;
- if (isInteractiveTarget(e.target)) return;
- e.preventDefault();
- try { chip.setPointerCapture(e.pointerId); } catch(_) {}
-
- const start = { x: e.clientX, y: e.clientY };
- let moved = false;
- let ghost = null;
-
- const createGhost = () => {
- const rect = chip.getBoundingClientRect();
- ghost = chip.cloneNode(true);
- ghost.classList.add('si-chip--ghost');
- ghost.style.width = rect.width + 'px';
- ghost.style.minWidth = rect.width + 'px';
- document.body.appendChild(ghost);
- setGhost(e.clientX - rect.width/2, e.clientY - rect.height/2);
- chip.classList.add('is-drag-origin');
- document.body.classList.add('is-dragging');
- };
- const setGhost = (gx, gy) => {
- ghost.style.setProperty('--x', gx + 'px');
- ghost.style.setProperty('--y', gy + 'px');
- ghost.style.transform = `translate3d(var(--x,0px), var(--y,0px), 0)`;
- };
-
- const onMove = (ev) => {
- const dx = ev.clientX - start.x;
- const dy = ev.clientY - start.y;
- if (!moved && Math.hypot(dx,dy) > 2) {
- moved = true;
- createGhost();
- try { window.getSelection()?.removeAllRanges?.(); } catch(_) {}
- }
- if (!moved) return;
-
- ev.preventDefault();
- ghost.style.setProperty('--x', (ev.clientX - ghost.offsetWidth/2) + 'px');
- ghost.style.setProperty('--y', (ev.clientY - ghost.offsetHeight/2) + 'px');
-
- document.querySelectorAll('.si-drop-target').forEach(t => t.classList.remove('si-drop-target'));
- const target = pickDatasetsStripAt(ev.clientX, ev.clientY);
- if (target) target.classList.add('si-drop-target');
- };
-
- const finish = (ev) => {
- document.removeEventListener('pointermove', onMove);
- document.removeEventListener('pointerup', finish, true);
- document.removeEventListener('pointercancel', finish, true);
- try { chip.releasePointerCapture(ev.pointerId); } catch(_) {}
- document.body.classList.remove('is-dragging');
-
- if (!moved) { chip.classList.remove('is-drag-origin'); return; }
-
- const target = pickDatasetsStripAt(ev.clientX, ev.clientY);
- document.querySelectorAll('.si-drop-target').forEach(t => t.classList.remove('si-drop-target'));
-
- if (target) {
- const payload = payloadProvider();
- onDrop(target, payload, () => {
- const finalRect = target.getBoundingClientRect();
- const gx = finalRect.left + 12;
- const gy = finalRect.top + 8;
- ghost.style.transition = 'transform 160ms cubic-bezier(.2,.8,.2,1)';
- ghost.style.setProperty('--x', gx + 'px');
- ghost.style.setProperty('--y', gy + 'px');
- setTimeout(() => {
- ghost?.remove();
- chip.classList.remove('is-drag-origin');
- }, 170);
- });
- } else {
- ghost?.remove();
- chip.classList.remove('is-drag-origin');
- }
- };
-
- document.addEventListener('pointermove', onMove);
- document.addEventListener('pointerup', finish, true);
- document.addEventListener('pointercancel', finish, true);
- });
- }
-
- // ---------- 그룹/칩 렌더 (aside: 토글만, 드래그 없음) ----------
- const groupsWrap = document.getElementById('si-groups');
-
+ // ---------- 그룹(사이드바) ----------
const GROUPS = [
- { key: 'privacy', name: '새록', metrics: [METRICS.TOTAL_COUNT, METRICS.PRIVATE_RATIO] },
- { key: 'user', name: '유저', metrics: [
- METRICS.USER_COMPLETED_TOTAL,
- METRICS.USER_SIGNUP_DAILY,
- METRICS.USER_WITHDRAWAL_DAILY,
- METRICS.USER_DAU,
- METRICS.USER_WAU,
- METRICS.USER_MAU
- ]},
- { key: 'id', name: '동정 요청', metrics: [METRICS.PENDING_COUNT, METRICS.RESOLVED_COUNT, METRICS.RESOLUTION_STATS] },
+ { key:'collection', name:'새록', metrics:[METRICS.TOTAL_COUNT, METRICS.PRIVATE_RATIO] },
+ { key:'user', name:'유저', metrics:[
+ METRICS.USER_COMPLETED_TOTAL, METRICS.USER_SIGNUP_DAILY, METRICS.USER_WITHDRAWAL_DAILY,
+ METRICS.USER_DAU, METRICS.USER_WAU, METRICS.USER_MAU
+ ] },
+ { key:'id', name:'동정 요청', metrics:[METRICS.PENDING_COUNT, METRICS.RESOLVED_COUNT, METRICS.RESOLUTION_STATS] },
];
const known = new Set(GROUPS.flatMap(g => g.metrics));
- const others = metricOptions.map(m => m.metric).filter(m => !known.has(m));
- if (others.length) GROUPS.push({ key: 'others', name: '기타', metrics: others });
+ (metricOptions || []).forEach(opt => { if (!known.has(opt.metric)) known.add(opt.metric); });
+
+ const groupsWrap = document.getElementById('si-groups');
function renderGroups(){
if (!groupsWrap) return;
@@ -289,7 +169,7 @@
chip.setAttribute('draggable','false');
chip.addEventListener('dragstart', e => e.preventDefault());
- // aside 툴팁: 제목+설명
+ // aside 툴팁: 제목+설명 (설명은 서버 enum에서 내려옴 → 28일 기준으로 변경됨)
const titleText = opt.label || metricKey;
const descText = opt.description || '';
const tooltipHtml = `${escapeHtml(titleText)}` + (descText ? `
${escapeHtml(descText)}` : '');
@@ -390,33 +270,33 @@
const yLo = yScale.getPixelForValue(entry.mean - entry.std);
const yHi = yScale.getPixelForValue(entry.mean + entry.std);
- ctx.save();
+ const ctx2 = ctx; ctx2.save();
// 수염(최소~최대)
- ctx.strokeStyle = 'rgba(148,163,184,.9)';
- ctx.lineWidth = 2;
- ctx.beginPath(); ctx.moveTo(x, yMin); ctx.lineTo(x, yMax); ctx.stroke();
- ctx.beginPath();
- ctx.moveTo(x - capHalf, yMin); ctx.lineTo(x + capHalf, yMin);
- ctx.moveTo(x - capHalf, yMax); ctx.lineTo(x + capHalf, yMax);
- ctx.stroke();
+ ctx2.strokeStyle = 'rgba(148,163,184,.9)';
+ ctx2.lineWidth = 2;
+ ctx2.beginPath(); ctx2.moveTo(x, yMin); ctx2.lineTo(x, yMax); ctx2.stroke();
+ ctx2.beginPath();
+ ctx2.moveTo(x - capHalf, yMin); ctx2.lineTo(x + capHalf, yMin);
+ ctx2.moveTo(x - capHalf, yMax); ctx2.lineTo(x + capHalf, yMax);
+ ctx2.stroke();
// 박스(±표준편차)
- ctx.fillStyle = 'rgba(91,140,255,.18)';
- ctx.strokeStyle = 'rgba(91,140,255,.9)';
- ctx.lineWidth = 2;
+ ctx2.fillStyle = 'rgba(91,140,255,.18)';
+ ctx2.strokeStyle = 'rgba(91,140,255,.9)';
+ ctx2.lineWidth = 2;
const top = Math.min(yLo, yHi), height = Math.abs(yHi - yLo) || 2;
- ctx.beginPath();
- ctx.rect(x - boxHalf, top, boxHalf*2, height);
- ctx.fill(); ctx.stroke();
+ ctx2.beginPath();
+ ctx2.rect(x - boxHalf, top, boxHalf*2, height);
+ ctx2.fill(); ctx2.stroke();
// 평균선
- ctx.strokeStyle = 'rgba(30,41,59,.95)';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.moveTo(x - (boxHalf + 3), yMean);
- ctx.lineTo(x + (boxHalf + 3), yMean);
- ctx.stroke();
- ctx.restore();
+ ctx2.strokeStyle = 'rgba(30,41,59,.95)';
+ ctx2.lineWidth = 2;
+ ctx2.beginPath();
+ ctx2.moveTo(x - (boxHalf + 3), yMean);
+ ctx2.lineTo(x + (boxHalf + 3), yMean);
+ ctx2.stroke();
+ ctx2.restore();
});
});
}
@@ -513,7 +393,7 @@
const points = Array.isArray(series?.points) ? series.points : [];
return points
.map(pt => {
- const x = toDateKST(pt.date); // ✅ KST 00:00 기준
+ const x = toDateKST(pt.date);
const y = normalizeValue(pt.value, unit);
return (x && y != null) ? ({ x, y }) : null;
})
@@ -526,7 +406,7 @@
const abs = Math.abs(hours);
if (abs >= H_PER_DAY * D_PER_MONTH) {
- const months = hours / (H_PER_DAY * D_PER_MONTH); // 30일=1개월
+ const months = hours / (H_PER_DAY * D_PER_MONTH);
const sig = Math.abs(months) >= 10 ? 0 : 1;
return `${months.toFixed(sig)}개월`;
}
@@ -563,11 +443,11 @@
type:'time',
time:{
unit:'day',
- round:'day', // ✅ 눈금도 일자 기준으로 스냅
+ round:'day',
tooltipFormat:'yyyy-LL-dd',
- zone: TZ // ✅ 축 파싱 타임존 고정
+ zone: TZ
},
- adapters:{ date:{ zone: TZ } }, // ✅ 어댑터에도 동일 적용
+ adapters:{ date:{ zone: TZ } },
ticks:{ autoSkip:true, maxRotation:0 },
grid:{ color:'rgba(148, 163, 184, 0.2)' }
},
@@ -580,7 +460,6 @@
tooltip: {
mode:'index', intersect:false,
callbacks: {
- // 박스플롯은 한 줄 요약만 (상세설명 제외)
label(ctx){
const ds = ctx.dataset || {};
const unit = ds._saUnit;
@@ -594,10 +473,12 @@
const mean = (typeof v === 'number') ? v : null;
const lo = (mean!=null && Number.isFinite(st.std)) ? (mean - st.std) : null;
const hi = (mean!=null && Number.isFinite(st.std)) ? (mean + st.std) : null;
- const fmt = (n)=>formatValue(n,'HOURS'); // ← 여기서 시간/일/개월 자동 환산
+ const fmt = (n)=>formatValue(n,'HOURS');
if (mean!=null && lo!=null && hi!=null) {
- // 기존 문구 스타일 유지
- return `${base}: 평균적으로 ${fmt(lo)} ~ ${fmt(hi)} 정도 (최소: ${fmt(st.min)}, 최대: ${fmt(st.max)})`;
+ return [
+ `${base}: 평균 ${fmt(lo)} ~ ${fmt(hi)}`,
+ `(최소: ${fmt(st.min)}, 최대: ${fmt(st.max)})`
+ ];
}
}
return `${base}: 평균 ${formatValue(v,'HOURS')}`;
@@ -711,7 +592,7 @@
const merged = [...byTime.values()]
.filter(v => Number.isFinite(v.mean) && Number.isFinite(v.min) && Number.isFinite(v.max) && Number.isFinite(v.std))
- .sort((a,b)=> a._time - b._time);
+ .sort((a,b)=>a._time - b._time);
const meanPoints = merged.map(v => ({ x: v._time, y: v.mean }));
@@ -751,7 +632,7 @@
_id: id,
label: optionMap.get(metric)?.label || metric,
data: points,
- parsing: { xAxisKey: 'x', yAxisKey: 'y' }, // ✅ 명시 파싱
+ parsing: { xAxisKey: 'x', yAxisKey: 'y' },
borderColor: color,
backgroundColor: color,
tension: 0.25,
@@ -815,7 +696,7 @@
if (toStrip) markDatasetsStripState(toStrip);
}
- // 플롯 상단 칩(그룹 단위) 렌더 — 이 칩들만 드래그 이동 허용
+ // 플롯 상단 칩(그룹 단위) — 드래그 이동 허용
function renderPlotGroupChips(plotId){
const p = plots.get(plotId); if (!p) return;
const wrap = p.el.querySelector('.si-plot__datasets');
@@ -918,4 +799,58 @@
window.addEventListener('touchmove', (e) => {
if (document.body.classList.contains('is-dragging')) e.preventDefault();
}, { passive: false });
+
+ // ===== DnD 유틸 =====
+ function enablePointerDnD(el, onDragData, onDropToStrip){
+ let dragging = false;
+ let ghost = null;
+
+ const onPointerDown = (e)=>{
+ if (e.target?.hasAttribute?.('data-no-drag')) return;
+ dragging = true;
+ document.body.classList.add('is-dragging');
+ el.classList.add('is-dragging');
+ const rect = el.getBoundingClientRect();
+
+ ghost = el.cloneNode(true);
+ ghost.classList.add('si-ds-chip--ghost');
+ ghost.style.position = 'fixed';
+ ghost.style.pointerEvents = 'none';
+ ghost.style.left = `${e.clientX - rect.width/2}px`;
+ ghost.style.top = `${e.clientY - rect.height/2}px`;
+ ghost.style.width = `${rect.width}px`;
+ ghost.style.opacity = '0.75';
+ ghost.style.zIndex = '1050';
+ document.body.appendChild(ghost);
+
+ e.preventDefault();
+ };
+
+ const onPointerMove = (e)=>{
+ if (!dragging || !ghost) return;
+ ghost.style.left = `${e.clientX - ghost.offsetWidth/2}px`;
+ ghost.style.top = `${e.clientY - ghost.offsetHeight/2}px`;
+ };
+
+ const onPointerUp = (e)=>{
+ if (!dragging) return;
+ dragging = false;
+ document.body.classList.remove('is-dragging');
+ el.classList.remove('is-dragging');
+ if (ghost) { ghost.remove(); ghost = null; }
+
+ // drop target 판별
+ const targetStrip = document.elementFromPoint(e.clientX, e.clientY)?.closest?.('.si-plot__datasets.si-dropzone');
+ if (targetStrip) {
+ const payload = typeof onDragData === 'function' ? onDragData() : onDragData;
+ if (payload && typeof onDropToStrip === 'function') {
+ onDropToStrip(targetStrip, payload, ()=>{});
+ }
+ }
+ };
+
+ el.addEventListener('pointerdown', onPointerDown);
+ window.addEventListener('pointermove', onPointerMove);
+ window.addEventListener('pointerup', onPointerUp);
+ }
})();