From 516c8ea6e548fd7504a62721382a9c8978a2a773 Mon Sep 17 00:00:00 2001 From: Junhee Han <115782193+soonduck-dreams@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:00:04 +0900 Subject: [PATCH 1/5] Add service insight analytics page (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add service insight metrics page * Keep service insight toggles responsive without Chart.js * chore: 질문형 인사이트 UI 추가 및 CSS/JS 구조 리팩터링 --- .../infra/stat/AdminStatClient.java | 57 +++ .../saerok_admin/infra/stat/StatMetric.java | 110 ++++++ .../infra/stat/dto/StatSeriesResponse.java | 31 ++ .../web/ServiceInsightController.java | 101 +++++ .../serviceinsight/ServiceInsightService.java | 115 ++++++ .../web/view/ServiceInsightViewModel.java | 42 +++ src/main/resources/static/css/admin-theme.css | 160 +++++++- .../resources/static/js/service-insight.js | 355 ++++++++++++++++++ .../templates/fragments/_sidebar.html | 10 + .../templates/service-insight/index.html | 123 ++++++ .../saerok_admin/web/AuthControllerTest.java | 9 + 11 files changed, 1111 insertions(+), 2 deletions(-) create mode 100644 src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java create mode 100644 src/main/java/apu/saerok_admin/infra/stat/StatMetric.java create mode 100644 src/main/java/apu/saerok_admin/infra/stat/dto/StatSeriesResponse.java create mode 100644 src/main/java/apu/saerok_admin/web/ServiceInsightController.java create mode 100644 src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java create mode 100644 src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java create mode 100644 src/main/resources/static/js/service-insight.js create mode 100644 src/main/resources/templates/service-insight/index.html diff --git a/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java b/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java new file mode 100644 index 0000000..cde2d0f --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java @@ -0,0 +1,57 @@ +package apu.saerok_admin.infra.stat; + +import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.stat.dto.StatSeriesResponse; +import java.net.URI; +import java.util.Collection; +import java.util.Objects; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +@Component +public class AdminStatClient { + + private static final String[] ADMIN_STATS_SEGMENTS = {"admin", "stats"}; + private static final String SERIES_SEGMENT = "series"; + + private final RestClient saerokRestClient; + private final String[] missingPrefixSegments; + + public AdminStatClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) { + this.saerokRestClient = saerokRestClient; + this.missingPrefixSegments = saerokApiProps.missingPrefixSegments().toArray(new String[0]); + } + + public StatSeriesResponse fetchSeries(Collection metrics) { + if (metrics == null || metrics.isEmpty()) { + throw new IllegalArgumentException("metrics must not be empty"); + } + + StatSeriesResponse response = saerokRestClient.get() + .uri(uriBuilder -> buildSeriesUri(uriBuilder, metrics)) + .retrieve() + .body(StatSeriesResponse.class); + + if (response == null) { + throw new IllegalStateException("Empty response from admin stats API"); + } + + return response; + } + + private URI buildSeriesUri(UriBuilder builder, Collection metrics) { + if (missingPrefixSegments.length > 0) { + builder.pathSegment(missingPrefixSegments); + } + builder.pathSegment(ADMIN_STATS_SEGMENTS); + builder.pathSegment(SERIES_SEGMENT); + + metrics.stream() + .filter(Objects::nonNull) + .map(Enum::name) + .forEach(metric -> builder.queryParam("metric", metric)); + + return builder.build(); + } +} diff --git a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java new file mode 100644 index 0000000..995ebec --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java @@ -0,0 +1,110 @@ +package apu.saerok_admin.infra.stat; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public enum StatMetric { + COLLECTION_TOTAL_COUNT( + "누적 새록 수", + "지금까지 등록된 새록의 총 개수입니다.", + MetricUnit.COUNT, + false, + Map.of(), + true + ), + COLLECTION_PRIVATE_RATIO( + "비공개 새록 비율", + "전체 새록 중 비공개로 설정된 비율입니다.", + MetricUnit.RATIO, + false, + Map.of(), + false + ), + BIRD_ID_PENDING_COUNT( + "진행 중인 동정 요청", + "아직 해결되지 않은 동정 요청 수입니다.", + MetricUnit.COUNT, + false, + Map.of(), + false + ), + BIRD_ID_RESOLVED_COUNT( + "누적 동정 해결 수", + "동정 제안이 채택되어 해결된 요청의 누적 개수입니다.", + MetricUnit.COUNT, + false, + Map.of(), + true + ), + BIRD_ID_RESOLUTION_STATS( + "동정 해결 시간", + "동정 요청이 해결되기까지 걸린 시간 통계입니다.", + MetricUnit.HOURS, + true, + orderedComponentLabels(), + false + ); + + private final String label; + private final String description; + private final MetricUnit unit; + private final boolean multiSeries; + private final Map componentLabels; + private final boolean defaultActive; + + StatMetric( + String label, + String description, + MetricUnit unit, + boolean multiSeries, + Map componentLabels, + boolean defaultActive + ) { + this.label = label; + this.description = description; + this.unit = unit; + this.multiSeries = multiSeries; + this.componentLabels = componentLabels; + this.defaultActive = defaultActive; + } + + public String label() { + return label; + } + + public String description() { + return description; + } + + public MetricUnit unit() { + return unit; + } + + public boolean multiSeries() { + return multiSeries; + } + + public Map componentLabels() { + return componentLabels; + } + + public boolean defaultActive() { + return defaultActive; + } + + private static Map orderedComponentLabels() { + Map labels = new LinkedHashMap<>(); + labels.put("min_hours", "최소"); + labels.put("max_hours", "최대"); + labels.put("avg_hours", "평균"); + labels.put("stddev_hours", "표준편차"); + return Collections.unmodifiableMap(labels); + } + + public enum MetricUnit { + COUNT, + RATIO, + HOURS + } +} diff --git a/src/main/java/apu/saerok_admin/infra/stat/dto/StatSeriesResponse.java b/src/main/java/apu/saerok_admin/infra/stat/dto/StatSeriesResponse.java new file mode 100644 index 0000000..249d8c2 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/stat/dto/StatSeriesResponse.java @@ -0,0 +1,31 @@ +package apu.saerok_admin.infra.stat.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.LocalDate; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record StatSeriesResponse(List series) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Series( + String metric, + List points, + List components + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ComponentSeries( + String key, + List points + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Point( + LocalDate date, + Number value + ) { + } +} diff --git a/src/main/java/apu/saerok_admin/web/ServiceInsightController.java b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java new file mode 100644 index 0000000..7614aac --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java @@ -0,0 +1,101 @@ +package apu.saerok_admin.web; + +import apu.saerok_admin.web.serviceinsight.ServiceInsightService; +import apu.saerok_admin.web.view.Breadcrumb; +import apu.saerok_admin.web.view.ServiceInsightViewModel; +import apu.saerok_admin.web.view.ToastMessage; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; + +@Controller +public class ServiceInsightController { + + private static final Logger log = LoggerFactory.getLogger(ServiceInsightController.class); + private static final String ERROR_TOAST_ID = "toastServiceInsightError"; + + private final ServiceInsightService serviceInsightService; + private final ObjectMapper objectMapper; + + public ServiceInsightController(ServiceInsightService serviceInsightService, ObjectMapper objectMapper) { + this.serviceInsightService = serviceInsightService; + this.objectMapper = objectMapper; + } + + @GetMapping("/service-insight") + public String serviceInsight(Model model) { + model.addAttribute("pageTitle", "서비스 인사이트"); + model.addAttribute("activeMenu", "serviceInsight"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.active("서비스 인사이트") + )); + ensureToastMessages(model); + + ServiceInsightViewModel viewModel; + try { + viewModel = serviceInsightService.loadViewModel(); + } catch (RestClientResponseException exception) { + log.warn( + "Failed to load service insight stats. status={}, body={}", + exception.getStatusCode(), + exception.getResponseBodyAsString(), + exception + ); + viewModel = serviceInsightService.defaultViewModel(); + attachErrorToast(model); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load service insight stats.", exception); + viewModel = serviceInsightService.defaultViewModel(); + attachErrorToast(model); + } + + model.addAttribute("serviceInsight", viewModel); + model.addAttribute("chartDataJson", toJson(viewModel)); + return "service-insight/index"; + } + + private void ensureToastMessages(Model model) { + if (!model.containsAttribute("toastMessages")) { + model.addAttribute("toastMessages", List.of()); + } + } + + private void attachErrorToast(Model model) { + ToastMessage errorToast = new ToastMessage( + ERROR_TOAST_ID, + "데이터 로드 실패", + "통계 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", + "danger", + false + ); + List messages = new ArrayList<>(); + Object existing = model.getAttribute("toastMessages"); + if (existing instanceof List list) { + for (Object item : list) { + if (item instanceof ToastMessage toastMessage && !ERROR_TOAST_ID.equals(toastMessage.id())) { + messages.add(toastMessage); + } + } + } + messages.add(errorToast); + model.addAttribute("toastMessages", List.copyOf(messages)); + } + + private String toJson(ServiceInsightViewModel viewModel) { + try { + return objectMapper.writeValueAsString(viewModel); + } catch (JsonProcessingException exception) { + log.warn("Failed to serialize service insight payload.", exception); + return "{\"metricOptions\":[],\"series\":[],\"componentLabels\":{}}"; + } + } +} diff --git a/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java new file mode 100644 index 0000000..1f1a3af --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java @@ -0,0 +1,115 @@ +package apu.saerok_admin.web.serviceinsight; + +import apu.saerok_admin.infra.stat.AdminStatClient; +import apu.saerok_admin.infra.stat.StatMetric; +import apu.saerok_admin.infra.stat.dto.StatSeriesResponse; +import apu.saerok_admin.web.view.ServiceInsightViewModel; +import apu.saerok_admin.web.view.ServiceInsightViewModel.ComponentSeries; +import apu.saerok_admin.web.view.ServiceInsightViewModel.MetricOption; +import apu.saerok_admin.web.view.ServiceInsightViewModel.Point; +import apu.saerok_admin.web.view.ServiceInsightViewModel.Series; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +@Service +public class ServiceInsightService { + + private final AdminStatClient adminStatClient; + + public ServiceInsightService(AdminStatClient adminStatClient) { + this.adminStatClient = adminStatClient; + } + + public ServiceInsightViewModel loadViewModel() { + StatSeriesResponse response = adminStatClient.fetchSeries(List.of(StatMetric.values())); + return buildViewModel(response); + } + + public ServiceInsightViewModel defaultViewModel() { + return buildViewModel(null); + } + + private ServiceInsightViewModel buildViewModel(StatSeriesResponse response) { + Map responseMap = Optional.ofNullable(response) + .map(StatSeriesResponse::series) + .orElseGet(List::of) + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap( + StatSeriesResponse.Series::metric, + series -> series, + (left, right) -> right, + LinkedHashMap::new + )); + + List metricOptions = new ArrayList<>(); + List chartSeries = new ArrayList<>(); + Map> componentLabels = new LinkedHashMap<>(); + + for (StatMetric metric : StatMetric.values()) { + metricOptions.add(toMetricOption(metric)); + chartSeries.add(toSeries(metric, responseMap.get(metric.name()))); + if (metric.multiSeries()) { + componentLabels.put(metric.name(), metric.componentLabels()); + } + } + + return new ServiceInsightViewModel(metricOptions, chartSeries, componentLabels); + } + + private MetricOption toMetricOption(StatMetric metric) { + return new MetricOption( + metric.name(), + metric.label(), + metric.description(), + metric.unit(), + metric.multiSeries(), + metric.defaultActive() + ); + } + + private Series toSeries(StatMetric metric, StatSeriesResponse.Series source) { + List points = new ArrayList<>(); + List components = new ArrayList<>(); + + if (source != null) { + if (source.points() != null) { + for (StatSeriesResponse.Point point : source.points()) { + if (point == null || point.date() == null || point.value() == null) { + continue; + } + points.add(new Point(point.date(), point.value().doubleValue())); + } + } + if (source.components() != null) { + for (StatSeriesResponse.ComponentSeries component : source.components()) { + if (component == null || component.key() == null) { + continue; + } + List componentPoints = new ArrayList<>(); + if (component.points() != null) { + for (StatSeriesResponse.Point point : component.points()) { + if (point == null || point.date() == null || point.value() == null) { + continue; + } + componentPoints.add(new Point(point.date(), point.value().doubleValue())); + } + } + components.add(new ComponentSeries(component.key(), componentPoints)); + } + } + } + + if (!metric.multiSeries()) { + components = List.of(); + } + + return new Series(metric.name(), points, components); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java b/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java new file mode 100644 index 0000000..324d87c --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java @@ -0,0 +1,42 @@ +package apu.saerok_admin.web.view; + +import apu.saerok_admin.infra.stat.StatMetric.MetricUnit; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public record ServiceInsightViewModel( + List metricOptions, + List series, + Map> componentLabels +) { + + public record MetricOption( + String metric, + String label, + String description, + MetricUnit unit, + boolean multiSeries, + boolean defaultActive + ) { + } + + public record Series( + String metric, + List points, + List components + ) { + } + + public record Point( + LocalDate date, + double value + ) { + } + + public record ComponentSeries( + String key, + List points + ) { + } +} diff --git a/src/main/resources/static/css/admin-theme.css b/src/main/resources/static/css/admin-theme.css index b37c17a..9715ce2 100644 --- a/src/main/resources/static/css/admin-theme.css +++ b/src/main/resources/static/css/admin-theme.css @@ -31,6 +31,9 @@ --spacing-md: 1.25rem; --spacing-lg: 2rem; --spacing-xl: 3rem; + + /* 추가: 아바타 이미지 변수 기본값 */ + --avatar-image: none; } body { @@ -241,7 +244,6 @@ a:focus { .card .environment-badge--prod { box-shadow: 0 0 0 1px rgba(163,230,53,.35) inset; } } - .table-modern thead th { background: var(--surface-muted); font-size: 0.875rem; @@ -453,7 +455,8 @@ a:focus { justify-content: center; color: var(--text-muted); font-size: 1.5rem; - background-image: var(--avatar-image); + /* 수정: 변수 폴백을 명시해 IDE 경고 제거 */ + background-image: var(--avatar-image, none); background-size: cover; background-position: center; background-repeat: no-repeat; @@ -656,3 +659,156 @@ a:focus { font-size: 1.125rem; font-weight: 600; } + +/* ===== Service Insight ===== */ +.service-insight { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.service-insight__container { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +@media (min-width: 992px) { + .service-insight__container { + flex-direction: row; + align-items: stretch; + } +} + +.service-insight__metrics { + flex: 0 0 280px; + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.service-insight__metrics-header { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.service-insight__metric-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.service-insight__metric-button { + width: 100%; + text-align: left; + background: transparent; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--spacing-sm) var(--spacing-md); + display: flex; + flex-direction: column; + gap: 0.35rem; + color: inherit; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.service-insight__metric-button:hover, +.service-insight__metric-button:focus-visible { + border-color: var(--accent-primary); + box-shadow: 0 12px 32px rgba(59, 130, 246, 0.12); + transform: translateY(-1px); + outline: none; +} + +.service-insight__metric-button.is-active { + border-color: var(--accent-primary); + background: rgba(59, 130, 246, 0.08); + box-shadow: 0 16px 32px rgba(59, 130, 246, 0.18); +} + +.service-insight__metric-label { + font-weight: 600; +} + +.service-insight__metric-badge { + align-self: flex-start; +} + +.service-insight__chart { + flex: 1 1 auto; + min-width: 0; +} + +.service-insight__chart-surface { + position: relative; + height: 360px; /* 명시적 높이 */ +} + +@media (min-width: 1200px) { + .service-insight__chart-surface { + height: 420px; + } +} + +.service-insight__chart-surface canvas { + width: 100% !important; + height: 100%; + display: block; +} + +.service-insight__unit { + font-weight: 600; +} + +.service-insight__empty-state { + border: 1px dashed var(--border-subtle); + border-radius: var(--radius-lg); + background: rgba(15, 23, 42, 0.02); +} + +/* 질문형 인사이트 레이아웃 */ +.siq-grid { display: grid; gap: var(--spacing-lg); } +@media (min-width: 992px) { .siq-grid--two { grid-template-columns: 1fr 1fr; } } + +.siq-card__surface { + position: relative; + height: 360px; +} +@media (min-width: 1200px) { + .siq-card__surface { height: 420px; } +} +.siq-card__surface canvas { + width: 100% !important; + height: 100%; + display: block; +} + +/* ===== 질문 버튼 가독성 & 사이드 폭 확장 ===== */ +:root { --insight-sidebar-width: 360px; } /* 기본 폭 */ +@media (min-width: 1400px) { + :root { --insight-sidebar-width: 420px; } /* 넓은 화면에선 더 여유 */ +} + +.service-insight__metrics { + flex: 0 0 var(--insight-sidebar-width); + min-width: var(--insight-sidebar-width); +} + +@media (max-width: 991.98px) { + .service-insight__metrics { flex: 1 1 auto; min-width: 0; } /* 모바일에선 유동 */ +} + +.service-insight__metric-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: var(--spacing-sm) var(--spacing-md); +} + +.service-insight__metric-label { + white-space: nowrap; /* 줄바꿈 금지 */ + overflow: hidden; /* 넘치면 숨김 */ + text-overflow: ellipsis; /* 말줄임표 */ + width: 100%; +} diff --git a/src/main/resources/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js new file mode 100644 index 0000000..ddd0ddd --- /dev/null +++ b/src/main/resources/static/js/service-insight.js @@ -0,0 +1,355 @@ +(function () { + // ====== 0) 데이터 파싱 ====== + const dataElement = document.getElementById('serviceInsightData'); + if (!dataElement) return; + + let payload = {}; + try { + payload = JSON.parse(dataElement.textContent || '{}'); + } catch (err) { + console.warn('Failed to parse service insight payload.', err); + } + + const metricOptions = Array.isArray(payload.metricOptions) ? payload.metricOptions : []; + const seriesList = Array.isArray(payload.series) ? payload.series : []; + const componentLabels = (payload.componentLabels && typeof payload.componentLabels === 'object') + ? payload.componentLabels : {}; + + // metric → meta/series 매핑 + const optionMap = new Map(metricOptions.map(o => [o.metric, o])); + const seriesMap = new Map(seriesList.map(s => [s.metric, s])); + + // 서버 enum 키 (백엔드와 1:1) : + // COLLECTION_TOTAL_COUNT, COLLECTION_PRIVATE_RATIO, + // BIRD_ID_PENDING_COUNT, BIRD_ID_RESOLVED_COUNT, BIRD_ID_RESOLUTION_STATS + const METRICS = { + TOTAL_COUNT: 'COLLECTION_TOTAL_COUNT', + PRIVATE_RATIO: 'COLLECTION_PRIVATE_RATIO', + PENDING_COUNT: 'BIRD_ID_PENDING_COUNT', + RESOLVED_COUNT: 'BIRD_ID_RESOLVED_COUNT', + RESOLUTION_STATS: 'BIRD_ID_RESOLUTION_STATS' + }; + + // ====== 1) DOM 핸들 ====== + const questionButtons = Array.from(document.querySelectorAll('[data-role="si-question"]')); + const viewPrivacy = document.getElementById('siq-privacy'); + const viewIdRequests = document.getElementById('siq-idRequests'); + const emptyState = document.getElementById('serviceInsightEmptyState'); + + // ====== 2) 차트 생성 유틸 ====== + const PALETTE = [ + '#2563eb', '#16a34a', '#dc2626', '#f97316', '#9333ea', + '#0ea5e9', '#059669', '#ea580c', '#3b82f6', '#14b8a6' + ]; + let colorCursor = 0; + const nextColor = () => PALETTE[(colorCursor++) % PALETTE.length]; + + const axisByUnit = { COUNT: 'count', RATIO: 'ratio', HOURS: 'hours' }; + + function createBaseOptions(yAxisKey) { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'nearest', intersect: false }, + elements: { line: { borderWidth: 2 }, point: { radius: 0, hoverRadius: 4, hitRadius: 8 } }, + scales: { + x: { + type: 'time', + time: { unit: 'day', tooltipFormat: 'yyyy-LL-dd' }, + ticks: { autoSkip: true, maxRotation: 0 }, + grid: { color: 'rgba(148, 163, 184, 0.2)' } + }, + count: { + display: yAxisKey === 'count', + position: 'left', + beginAtZero: true, + ticks: { + precision: 0, + maxTicksLimit: 8, + callback: (v) => (Number.isInteger(v) ? v.toLocaleString() : '') + } + }, + ratio: { + display: yAxisKey === 'ratio', + position: 'right', + beginAtZero: true, + min: 0, max: 100, + grid: { drawOnChartArea: false }, + ticks: { stepSize: 10, maxTicksLimit: 6, callback: (v) => `${v}%` } + }, + hours: { + display: yAxisKey === 'hours', + position: 'right', + beginAtZero: true, + grid: { drawOnChartArea: false }, + ticks: { maxTicksLimit: 6, callback: (v) => (typeof v === 'number' ? v.toFixed(1) : v) } + } + }, + plugins: { + legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, boxHeight: 8 } }, + tooltip: { + mode: 'index', intersect: false, + callbacks: { + label(ctx) { + const unit = ctx.dataset?._saUnit; + const label = ctx.dataset?.label || ''; + const value = ctx.parsed.y; + const formatted = formatValue(value, unit); + return label ? `${label}: ${formatted}` : formatted; + } + } + } + } + }; + } + + function newLineChart(canvasId, yAxisKey) { + const canvas = document.getElementById(canvasId); + if (!canvas || typeof Chart === 'undefined') return null; + return new Chart(canvas.getContext('2d'), { + type: 'line', + data: { datasets: [] }, + options: createBaseOptions(yAxisKey) + }); + } + + function toChartPoints(points, unit) { + if (!Array.isArray(points)) return []; + return points + .filter(p => p && p.date != null && p.value != null) + .map(p => ({ x: p.date, y: normalizeValue(p.value, unit) })); + } + + function normalizeValue(num, unit) { + const v = typeof num === 'number' ? num : Number(num); + if (!Number.isFinite(v)) return null; + if (unit === 'RATIO') return v <= 1.000001 ? v * 100 : v; + return v; + } + + function formatValue(v, unit) { + if (!Number.isFinite(v)) return '-'; + switch (unit) { + case 'RATIO': return `${v.toFixed(1)}%`; + case 'HOURS': return `${v.toFixed(2)}시간`; + default: return Math.round(v).toLocaleString(); + } + } + + // nice scale per chart + function applyNiceYAxis(chart, axisKey) { + if (!chart) return; + const ds = chart.data.datasets || []; + let min = Infinity, max = -Infinity, used = false; + ds.forEach(d => { + if (d.yAxisID !== axisKey) return; + (d.data || []).forEach(pt => { + if (pt && Number.isFinite(pt.y)) { used = true; min = Math.min(min, pt.y); max = Math.max(max, pt.y); } + }); + }); + if (!used) return; + + if (min === max) { min -= 1; max += 1; } + const nice = niceScale(min, max, axisKey === 'count' ? 7 : 6, axisKey === 'count'); + const s = chart.options.scales[axisKey]; + s.min = nice.min; + s.max = nice.max; + s.ticks.stepSize = nice.step; + if (axisKey === 'count') s.ticks.precision = 0; + } + + function niceScale(min, max, maxTicks = 6, integerOnly = false) { + const range = Math.max(1e-9, max - min); + let step = niceNum(range / Math.max(2, (maxTicks - 1)), true); + if (integerOnly) step = Math.max(1, Math.round(step)); + const niceMin = Math.floor(min / step) * step; + const niceMax = Math.ceil(max / step) * step; + return { min: niceMin, max: niceMax, step }; + } + function niceNum(x, round) { + const exp = Math.floor(Math.log10(x)); + const f = x / Math.pow(10, exp); + let nf; + if (round) { nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10; } + else { nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; } + return nf * Math.pow(10, exp); + } + + // ====== 3) 시리즈 접근 ====== + function getPoints(metricKey) { + const s = seriesMap.get(metricKey); + if (!s) return []; + const unit = optionMap.get(metricKey)?.unit || 'COUNT'; + return toChartPoints(s.points || [], unit); + } + + function getResolutionComponents() { + const s = seriesMap.get(METRICS.RESOLUTION_STATS); + const comps = Array.isArray(s?.components) ? s.components : []; + // key → label 매핑 + const labels = componentLabels[METRICS.RESOLUTION_STATS] || {}; + // 필요한 키만 추출 + const byKey = new Map(); + comps.forEach(c => byKey.set(c.key, c.points || [])); + return { + min: { label: labels['min_hours'] || '최소', points: byKey.get('min_hours') || [] }, + avg: { label: labels['avg_hours'] || '평균', points: byKey.get('avg_hours') || [] }, + max: { label: labels['max_hours'] || '최대', points: byKey.get('max_hours') || [] } + // stddev는 시각적 혼잡도 때문에 일단 제외 + }; + } + + // ====== 4) 차트 인스턴스 (필요할 때 생성) ====== + const charts = { + total: null, + ratio: null, + idSummary: null, + idTime: null + }; + + function ensurePrivacyCharts() { + if (!charts.total) charts.total = newLineChart('chart-total-count', 'count'); + if (!charts.ratio) charts.ratio = newLineChart('chart-private-ratio', 'ratio'); + + // 데이터 바인딩 + if (charts.total) { + const unit = optionMap.get(METRICS.TOTAL_COUNT)?.unit || 'COUNT'; + charts.total.data.datasets = [{ + label: optionMap.get(METRICS.TOTAL_COUNT)?.label || '누적 새록 수', + data: getPoints(METRICS.TOTAL_COUNT), + borderColor: '#2563eb', + backgroundColor: '#2563eb', + tension: 0.25, + yAxisID: axisByUnit[unit] || 'count', + _saUnit: unit + }]; + applyNiceYAxis(charts.total, 'count'); + charts.total.update(); + } + + if (charts.ratio) { + const unit = optionMap.get(METRICS.PRIVATE_RATIO)?.unit || 'RATIO'; + charts.ratio.data.datasets = [{ + label: optionMap.get(METRICS.PRIVATE_RATIO)?.label || '비공개 새록 비율', + data: getPoints(METRICS.PRIVATE_RATIO), + borderColor: '#16a34a', + backgroundColor: '#16a34a', + tension: 0.25, + yAxisID: axisByUnit[unit] || 'ratio', + _saUnit: unit + }]; + applyNiceYAxis(charts.ratio, 'ratio'); // 0~100 내에서 step 조정 + charts.ratio.update(); + } + } + + function ensureIdCharts() { + if (!charts.idSummary) charts.idSummary = newLineChart('chart-id-summary', 'count'); + if (!charts.idTime) charts.idTime = newLineChart('chart-id-resolution-time', 'hours'); + + // 요약(동정 요청/해결) : 두 라인 + if (charts.idSummary) { + const pUnit = optionMap.get(METRICS.PENDING_COUNT)?.unit || 'COUNT'; + const rUnit = optionMap.get(METRICS.RESOLVED_COUNT)?.unit || 'COUNT'; + charts.idSummary.data.datasets = [ + { + label: optionMap.get(METRICS.PENDING_COUNT)?.label || '진행 중인 동정 요청', + data: getPoints(METRICS.PENDING_COUNT), + borderColor: '#dc2626', + backgroundColor: '#dc2626', + tension: 0.25, + yAxisID: axisByUnit[pUnit] || 'count', + _saUnit: pUnit + }, + { + label: optionMap.get(METRICS.RESOLVED_COUNT)?.label || '누적 동정 해결 수', + data: getPoints(METRICS.RESOLVED_COUNT), + borderColor: '#9333ea', + backgroundColor: '#9333ea', + tension: 0.25, + borderDash: [6, 4], + yAxisID: axisByUnit[rUnit] || 'count', + _saUnit: rUnit + } + ]; + applyNiceYAxis(charts.idSummary, 'count'); + charts.idSummary.update(); + } + + // 해결 시간 : 평균(굵게) + 최소/최대(점선) + if (charts.idTime) { + const unit = optionMap.get(METRICS.RESOLUTION_STATS)?.unit || 'HOURS'; + const comps = getResolutionComponents(); + charts.idTime.data.datasets = [ + { + label: `평균`, + data: toChartPoints(comps.avg.points, unit), + borderColor: '#0ea5e9', + backgroundColor: '#0ea5e9', + borderWidth: 3, + tension: 0.25, + yAxisID: axisByUnit[unit] || 'hours', + _saUnit: unit + }, + { + label: `최소`, + data: toChartPoints(comps.min.points, unit), + borderColor: '#14b8a6', + backgroundColor: '#14b8a6', + tension: 0.25, + borderDash: [4, 4], + yAxisID: axisByUnit[unit] || 'hours', + _saUnit: unit + }, + { + label: `최대`, + data: toChartPoints(comps.max.points, unit), + borderColor: '#f97316', + backgroundColor: '#f97316', + tension: 0.25, + borderDash: [4, 4], + yAxisID: axisByUnit[unit] || 'hours', + _saUnit: unit + } + ]; + applyNiceYAxis(charts.idTime, 'hours'); + charts.idTime.update(); + } + } + + // ====== 5) 질문 토글 (동시에 하나만 on) ====== + function showQuestion(key) { + // 버튼 상태 + questionButtons.forEach(btn => { + const isActive = btn.getAttribute('data-question-key') === key; + btn.classList.toggle('is-active', isActive); + }); + + // 뷰 전환 + viewPrivacy.classList.toggle('d-none', key !== 'privacy'); + viewIdRequests.classList.toggle('d-none', key !== 'idRequests'); + + // 해당 그룹 차트 lazy 생성/업데이트 + if (key === 'privacy') ensurePrivacyCharts(); + if (key === 'idRequests') ensureIdCharts(); + + // 빈 상태 처리 + const anyData = + (charts.total?.data.datasets?.[0]?.data?.length || 0) + + (charts.ratio?.data.datasets?.[0]?.data?.length || 0) + + (charts.idSummary?.data.datasets?.reduce((a,d)=>a+(d.data?.length||0),0) || 0) + + (charts.idTime?.data.datasets?.reduce((a,d)=>a+(d.data?.length||0),0) || 0); + emptyState?.classList.toggle('d-none', anyData > 0); + } + + questionButtons.forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.getAttribute('data-question-key'); + if (!key) return; + showQuestion(key); + }); + }); + + // 초기에는 아무것도 선택 안 함(기획 의도) + // 필요하면 여기서 기본으로 'privacy' 또는 'idRequests'를 선택하도록 바꿔도 됨. +})(); diff --git a/src/main/resources/templates/fragments/_sidebar.html b/src/main/resources/templates/fragments/_sidebar.html index 763d2a1..edd85f7 100644 --- a/src/main/resources/templates/fragments/_sidebar.html +++ b/src/main/resources/templates/fragments/_sidebar.html @@ -18,6 +18,11 @@ 대시보드 + + + 서비스 인사이트 + @@ -62,6 +67,11 @@
새록 어드민
대시보드 + + + 서비스 인사이트 + diff --git a/src/main/resources/templates/service-insight/index.html b/src/main/resources/templates/service-insight/index.html new file mode 100644 index 0000000..ecffc52 --- /dev/null +++ b/src/main/resources/templates/service-insight/index.html @@ -0,0 +1,123 @@ + + + +
+
+ + + + +
+
+
+
+
+
+ + +
+
+
+
+

누적 새록 수

+ 단위: 건 +
+
+
+ +
+
+
+ +
+
+

비공개 새록 비율

+ 단위: % +
+
+
+ +
+
+
+
+
+ + +
+
+
+
+

동정 요청·해결 추이

+ 단위: 건 +
+
+
+ +
+
+
+ +
+
+

동정 해결 시간

+ 단위: 시간 +
+
+
+ +
+
+
+
+
+ +
+ +

표시할 데이터가 없습니다.

+

다른 질문을 선택하거나 나중에 다시 확인해 주세요.

+
+
+
+
+
+
+ + + + + + + + + + + +
+ diff --git a/src/test/java/apu/saerok_admin/web/AuthControllerTest.java b/src/test/java/apu/saerok_admin/web/AuthControllerTest.java index 6569100..15bd152 100644 --- a/src/test/java/apu/saerok_admin/web/AuthControllerTest.java +++ b/src/test/java/apu/saerok_admin/web/AuthControllerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import apu.saerok_admin.config.SocialLoginProperties; +import apu.saerok_admin.config.UnsplashProperties; import apu.saerok_admin.infra.CurrentAdminClient; import apu.saerok_admin.infra.auth.BackendAuthClient; import apu.saerok_admin.security.LoginSession; @@ -190,5 +191,13 @@ SocialLoginProperties socialLoginProperties() { new SocialLoginProperties.Provider("apple-client", URI.create("http://localhost/auth/callback/apple")) ); } + + @Bean + UnsplashProperties unsplashProperties() { + UnsplashProperties properties = new UnsplashProperties(); + properties.setAccessKey("test-access-key"); + properties.setAppName("saerok-admin-test"); + return properties; + } } } From f64c0e98bc752244e32bc6e1b95ece1299b3e0ed Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Fri, 31 Oct 2025 19:11:37 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20admin-theme.css=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=84=EC=86=8C=ED=99=94=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../saerok_admin/infra/stat/StatMetric.java | 18 +- src/main/resources/static/css/admin-theme.css | 866 ++++--------- .../resources/static/css/service-insight.css | 105 ++ .../resources/static/js/service-insight.js | 1110 ++++++++++++----- src/main/resources/templates/layout/base.html | 3 - .../templates/service-insight/index.html | 124 +- 6 files changed, 1211 insertions(+), 1015 deletions(-) create mode 100644 src/main/resources/static/css/service-insight.css 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 995ebec..9bd9864 100644 --- a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java +++ b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java @@ -6,8 +6,8 @@ public enum StatMetric { COLLECTION_TOTAL_COUNT( - "누적 새록 수", - "지금까지 등록된 새록의 총 개수입니다.", + "새록 총 개수", + "등록된 새록의 총 개수", MetricUnit.COUNT, false, Map.of(), @@ -15,31 +15,31 @@ public enum StatMetric { ), COLLECTION_PRIVATE_RATIO( "비공개 새록 비율", - "전체 새록 중 비공개로 설정된 비율입니다.", + "전체 새록 중 비공개로 설정된 비율", MetricUnit.RATIO, false, Map.of(), false ), BIRD_ID_PENDING_COUNT( - "진행 중인 동정 요청", - "아직 해결되지 않은 동정 요청 수입니다.", + "진행 중인 동정 요청 개수", + "이 날 진행 중인 동정 요청의 개수\n(= \"이름 모를 새 새록\"의 개수)", MetricUnit.COUNT, false, Map.of(), false ), BIRD_ID_RESOLVED_COUNT( - "누적 동정 해결 수", - "동정 제안이 채택되어 해결된 요청의 누적 개수입니다.", + "동정 의견 채택 횟수", + "이 날까지 동정 의견이 몇 번 채택됐는지 횟수", MetricUnit.COUNT, false, Map.of(), true ), BIRD_ID_RESOLUTION_STATS( - "동정 해결 시간", - "동정 요청이 해결되기까지 걸린 시간 통계입니다.", + "동정 의견 채택 시간", + "동정 요청 후 채택되기까지 평균적으로 걸린 시간", MetricUnit.HOURS, true, orderedComponentLabels(), diff --git a/src/main/resources/static/css/admin-theme.css b/src/main/resources/static/css/admin-theme.css index 9715ce2..ddde8d9 100644 --- a/src/main/resources/static/css/admin-theme.css +++ b/src/main/resources/static/css/admin-theme.css @@ -53,14 +53,8 @@ button { font-family: inherit; } -a { - color: var(--accent-primary); -} - -a:hover, -a:focus { - color: var(--accent-primary-strong); -} +a { color: var(--accent-primary); } +a:hover, a:focus { color: var(--accent-primary-strong); } .app-shell { min-height: 100vh; @@ -82,333 +76,127 @@ a:focus { gap: 0.75rem; margin-bottom: var(--spacing-lg); } +.app-sidebar__title { font-size: 1.25rem; font-weight: 700; } -.app-sidebar__title { - font-size: 1.25rem; - font-weight: 700; -} - -.app-sidebar__nav { - display: flex; - flex-direction: column; - gap: 0.5rem; -} +.app-sidebar__nav { display: flex; flex-direction: column; gap: 0.5rem; } .app-sidebar__link { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - border-radius: var(--radius-md); + display: flex; align-items: center; gap: 0.75rem; + padding: 0.75rem 1rem; border-radius: var(--radius-md); color: rgba(248, 250, 252, 0.74); - font-weight: 500; - text-decoration: none; + font-weight: 500; text-decoration: none; transition: background 0.2s ease, color 0.2s ease; } - -.app-sidebar__link:hover, -.app-sidebar__link:focus { - background: var(--surface-sidebar-hover); - color: var(--text-inverse); -} - +.app-sidebar__link:hover, .app-sidebar__link:focus { background: var(--surface-sidebar-hover); color: var(--text-inverse); } .app-sidebar__link.is-active { - background: var(--surface-sidebar-active); - color: var(--text-inverse); + background: var(--surface-sidebar-active); color: var(--text-inverse); border-left: 4px solid var(--accent-primary); padding-left: calc(1rem - 4px + 0.25rem); } - -.app-sidebar__link.is-disabled { - color: rgba(248, 250, 252, 0.35); - cursor: not-allowed; - opacity: 0.65; -} +.app-sidebar__link.is-disabled { color: rgba(248, 250, 252, 0.35); cursor: not-allowed; opacity: 0.65; } .app-sidebar__footer { - margin-top: auto; - padding-top: var(--spacing-lg); - font-size: 0.85rem; - color: rgba(248, 250, 252, 0.5); + margin-top: auto; padding-top: var(--spacing-lg); + font-size: 0.85rem; color: rgba(248, 250, 252, 0.5); } .app-content { - flex-grow: 1; - min-height: 100vh; - display: flex; - flex-direction: column; - background: transparent; -} - -@media (min-width: 992px) { - .app-content { - margin-left: 260px; - } + flex-grow: 1; min-height: 100vh; + display: flex; flex-direction: column; background: transparent; } +@media (min-width: 992px) { .app-content { margin-left: 260px; } } -.app-main { - padding: 1.5rem 1.5rem 2.5rem; -} - -@media (min-width: 992px) { - .app-main { - padding: 2rem 2.5rem 3.5rem; - } -} +.app-main { padding: 1.5rem 1.5rem 2.5rem; } +@media (min-width: 992px) { .app-main { padding: 2rem 2.5rem 3.5rem; } } .card-elevated { - border: none !important; - background: var(--surface-primary); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-soft); + border: none !important; background: var(--surface-primary); + border-radius: var(--radius-lg); box-shadow: var(--shadow-soft); } +.card-elevated .card-header, .card-elevated .card-body { border: none; background: transparent; } -.card-elevated .card-header, -.card-elevated .card-body { - border: none; - background: transparent; -} +.section-heading { font-weight: 600; font-size: 2rem; } -.section-heading { - font-weight: 600; - font-size: 2rem; -} +.badge-soft { background: rgba(59,130,246,0.15) !important; color: var(--accent-primary-strong) !important; } -.badge-soft { - background: rgba(59, 130, 246, 0.15) !important; - color: var(--accent-primary-strong) !important; -} - -/* ===== Environment badge (NEW, dark-friendly) ===== */ +/* ===== Environment badge ===== */ .environment-badge { - display: inline-flex; - align-items: center; - gap: .4ch; - padding: 0.22rem 0.66rem; - border-radius: 999px; - font-size: .78rem; - font-weight: 700; - letter-spacing: -.01em; - line-height: 1.15; - white-space: nowrap; - /* 공통 가독성 보강 */ + display: inline-flex; align-items: center; gap: .4ch; + padding: 0.22rem 0.66rem; border-radius: 999px; + font-size: .78rem; font-weight: 700; letter-spacing: -.01em; + line-height: 1.15; white-space: nowrap; text-shadow: 0 1px 0 rgba(0,0,0,.35); } - -/* 로컬: 사이언 → 블루 그라데이션 (상태 실험/자유로운 느낌) */ -.environment-badge--local { - --badge-start: #06b6d4; /* cyan-500 */ - --badge-end: #3b82f6; /* blue-500 */ - --badge-ring: rgba(56,189,248,.55); - --badge-glow: rgba(56,189,248,.32); - color: #ffffff; - background: linear-gradient(90deg, var(--badge-start) 0%, var(--badge-end) 100%); - box-shadow: - 0 0 0 1px var(--badge-ring) inset, - 0 6px 18px var(--badge-glow); -} - -/* 개발/QA: 푸시아 → 바이올렛 (변화/검증의 에너지) */ -.environment-badge--dev { - --badge-start: #e879f9; /* fuchsia-400 */ - --badge-end: #8b5cf6; /* violet-500 */ - --badge-ring: rgba(232,121,249,.55); - --badge-glow: rgba(139,92,246,.32); - color: #ffffff; - background: linear-gradient(90deg, var(--badge-start) 0%, var(--badge-end) 100%); - box-shadow: - 0 0 0 1px var(--badge-ring) inset, - 0 6px 18px var(--badge-glow); -} - -/* 운영: 라임 → 에메랄드 (안정/신뢰 + 경고성 존재감) */ -.environment-badge--prod { - --badge-start: #a3e635; /* lime-400 */ - --badge-end: #22c55e; /* green-500 */ - --badge-ring: rgba(163,230,53,.55); - --badge-glow: rgba(34,197,94,.30); - color: #0b1b13; /* 완전 흰색 대신 살짝 어두운 텍스트로 눈부심 줄이기 */ - background: linear-gradient(90deg, var(--badge-start) 0%, var(--badge-end) 100%); - box-shadow: - 0 0 0 1px var(--badge-ring) inset, - 0 6px 18px var(--badge-glow); -} - -/* 라이트 배경 위에서 너무 튀면 살짝 완화 (카드/화이트 영역) */ -@media (prefers-color-scheme: light) { - .app-content .environment-badge--local, - .card .environment-badge--local { box-shadow: 0 0 0 1px rgba(56,189,248,.35) inset; } - .app-content .environment-badge--dev, - .card .environment-badge--dev { box-shadow: 0 0 0 1px rgba(232,121,249,.35) inset; } - .app-content .environment-badge--prod, - .card .environment-badge--prod { box-shadow: 0 0 0 1px rgba(163,230,53,.35) inset; } +.environment-badge--local{--badge-start:#06b6d4;--badge-end:#3b82f6;--badge-ring:rgba(56,189,248,.55);--badge-glow:rgba(56,189,248,.32);color:#fff;background:linear-gradient(90deg,var(--badge-start)0%,var(--badge-end)100%);box-shadow:0 0 0 1px var(--badge-ring) inset,0 6px 18px var(--badge-glow);} +.environment-badge--dev {--badge-start:#e879f9;--badge-end:#8b5cf6;--badge-ring:rgba(232,121,249,.55);--badge-glow:rgba(139,92,246,.32);color:#fff;background:linear-gradient(90deg,var(--badge-start)0%,var(--badge-end)100%);box-shadow:0 0 0 1px var(--badge-ring) inset,0 6px 18px var(--badge-glow);} +.environment-badge--prod {--badge-start:#a3e635;--badge-end:#22c55e;--badge-ring:rgba(163,230,53,.55);--badge-glow:rgba(34,197,94,.30);color:#0b1b13;background:linear-gradient(90deg,var(--badge-start)0%,var(--badge-end)100%);box-shadow:0 0 0 1px var(--badge-ring) inset,0 6px 18px var(--badge-glow);} +@media (prefers-color-scheme: light){ + .app-content .environment-badge--local,.card .environment-badge--local{box-shadow:0 0 0 1px rgba(56,189,248,.35) inset;} + .app-content .environment-badge--dev,.card .environment-badge--dev{box-shadow:0 0 0 1px rgba(232,121,249,.35) inset;} + .app-content .environment-badge--prod,.card .environment-badge--prod{box-shadow:0 0 0 1px rgba(163,230,53,.35) inset;} } .table-modern thead th { - background: var(--surface-muted); - font-size: 0.875rem; - font-weight: 600; - color: var(--text-muted); - border-bottom: 1px solid var(--border-subtle); -} - -.table-modern tbody tr:hover { - background: rgba(59, 130, 246, 0.06); -} - -.table-modern tbody td { - vertical-align: middle; -} - -.table-responsive { - border-radius: var(--radius-lg); - overflow: hidden; -} - -.table-responsive .table { - margin-bottom: 0; -} - -.table-shell { - border-radius: var(--radius-lg); - overflow: hidden; - border: 1px solid var(--border-subtle); + background: var(--surface-muted); font-size: 0.875rem; font-weight: 600; + color: var(--text-muted); border-bottom: 1px solid var(--border-subtle); } +.table-modern tbody tr:hover { background: rgba(59,130,246,0.06); } +.table-modern tbody td { vertical-align: middle; } +.table-responsive { border-radius: var(--radius-lg); overflow: hidden; } +.table-responsive .table { margin-bottom: 0; } +.table-shell { border-radius: var(--radius-lg); overflow: hidden; border: 1px solid var(--border-subtle); } +.table-shell .table { margin-bottom: 0; } .btn-icon { - width: 2.5rem; - height: 2.5rem; - display: inline-flex; - align-items: center; - justify-content: center; + width: 2.5rem; height: 2.5rem; + display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-md); } -.status-dot { - width: 10px; - height: 10px; - border-radius: 999px; - display: inline-block; - margin-right: 0.5rem; -} - +.status-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; margin-right: 0.5rem; } .status-active { background: var(--accent-success); } -.status-pending { background: var(--accent-warning); } +.status-pending{ background: var(--accent-warning); } .status-danger { background: var(--accent-danger); } -.status-muted { background: rgba(148, 163, 184, 1); } +.status-muted { background: rgba(148, 163, 184, 1); } -.form-control::placeholder { - color: rgba(148, 163, 184, 0.9); -} +.form-control::placeholder { color: rgba(148,163,184,0.9); } .breadcrumb-item + .breadcrumb-item::before { - content: "\f285"; - font-family: "Bootstrap-icons"; - font-size: 0.75rem; -} - -.table-shell .table { - margin-bottom: 0; + content: "\f285"; font-family: "Bootstrap-icons"; font-size: 0.75rem; } .tooltip-bubble .tooltip-inner { - background-color: #1f2937; - color: #ffffff; - border-radius: var(--radius-md); - box-shadow: var(--shadow-soft); + background-color: #1f2937; color: #ffffff; + border-radius: var(--radius-md); box-shadow: var(--shadow-soft); padding: 0.5rem 0.75rem; } - .tooltip-bubble .tooltip-arrow::before { - border-width: 0.5rem 0.5rem 0; - border-top-color: #1f2937; + border-width: 0.5rem 0.5rem 0; border-top-color: #1f2937; filter: drop-shadow(0 6px 6px rgba(15, 23, 42, 0.2)); } -.upcoming-feature { - opacity: 0.55; - cursor: not-allowed !important; -} - -.upcoming-feature:hover, -.upcoming-feature:focus { - opacity: 0.55; -} - -.tooltip-wrapper { - display: block; - width: 100%; - cursor: not-allowed; -} +.upcoming-feature { opacity: 0.55; cursor: not-allowed !important; } +.upcoming-feature:hover, .upcoming-feature:focus { opacity: 0.55; } -.tooltip-wrapper .btn { - pointer-events: none; -} +.tooltip-wrapper { display: block; width: 100%; cursor: not-allowed; } +.tooltip-wrapper .btn { pointer-events: none; } -.media-thumb { - border-radius: var(--radius-lg); - object-fit: cover; - display: block; -} +.media-thumb { border-radius: var(--radius-lg); object-fit: cover; display: block; } +.media-thumb--square-lg { width: 72px; height: 72px; } -.media-thumb--square-lg { - width: 72px; - height: 72px; -} - -.text-truncate-md { - max-width: 260px; -} +.text-truncate-md { max-width: 260px; } .collection-image-thumbnail { - max-height: 320px; - object-fit: cover; - width: 100%; - border-radius: var(--radius-xl); + max-height: 320px; object-fit: cover; width: 100%; border-radius: var(--radius-xl); } - -.collection-image-modal { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - z-index: 1080; -} - -.collection-image-modal.d-none { - display: none !important; -} - -.collection-image-modal .collection-image-backdrop { - position: absolute; - inset: 0; - background: var(--surface-overlay); -} - -.collection-image-modal .collection-image-dialog { - position: relative; - max-width: min(90vw, 1024px); - max-height: 90vh; - box-shadow: var(--shadow-overlay); -} - -.collection-image-modal .collection-image-dialog img { - width: 100%; - height: auto; - max-height: 90vh; - object-fit: contain; - border-radius: var(--radius-xl); -} - +.collection-image-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 1080; } +.collection-image-modal.d-none { display: none !important; } +.collection-image-modal .collection-image-backdrop { position: absolute; inset: 0; background: var(--surface-overlay); } +.collection-image-modal .collection-image-dialog { position: relative; max-width: min(90vw, 1024px); max-height: 90vh; box-shadow: var(--shadow-overlay); } +.collection-image-modal .collection-image-dialog img { width: 100%; height: auto; max-height: 90vh; object-fit: contain; border-radius: var(--radius-xl); } .collection-image-modal .btn-close { - position: absolute; - top: -16px; - right: -16px; - background-color: var(--surface-overlay-strong); - border-radius: 999px; - padding: 0.75rem; - opacity: 1; + position: absolute; top: -16px; right: -16px; background-color: var(--surface-overlay-strong); + border-radius: 999px; padding: 0.75rem; opacity: 1; } .collection-note-highlight { @@ -417,398 +205,244 @@ a:focus { border-radius: var(--radius-xl); padding: var(--spacing-md); } - .note-label { - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--accent-primary-strong); - font-weight: 600; + font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--accent-primary-strong); font-weight: 600; } +.collection-one-line-note { white-space: pre-line; font-weight: 600; font-size: 1rem; } +.collection-detail-meta dl { font-size: 0.9rem; } -.collection-one-line-note { - white-space: pre-line; - font-weight: 600; - font-size: 1rem; -} - -.collection-detail-meta dl { - font-size: 0.9rem; -} - -.surface-muted { - background: var(--surface-muted) !important; - color: var(--text-primary); -} - -.rounded-lg { - border-radius: var(--radius-lg) !important; -} +.surface-muted { background: var(--surface-muted) !important; color: var(--text-primary); } +.rounded-lg { border-radius: var(--radius-lg) !important; } .profile-avatar { - width: 64px; - height: 64px; - border-radius: 50%; + width: 64px; height: 64px; border-radius: 50%; background: rgba(148, 163, 184, 0.35); - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); - font-size: 1.5rem; - /* 수정: 변수 폴백을 명시해 IDE 경고 제거 */ + display: flex; align-items: center; justify-content: center; + color: var(--text-muted); font-size: 1.5rem; background-image: var(--avatar-image, none); - background-size: cover; - background-position: center; - background-repeat: no-repeat; -} - -.table-empty-state { - padding: 3rem 0; - color: var(--text-muted); -} - -.metric-card { - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.metric-card--interactive { - cursor: pointer; -} - -.metric-card--interactive:hover, -.metric-card--interactive:focus-visible { - transform: translateY(-4px); - box-shadow: var(--shadow-soft); -} - -.metric-card--interactive:focus-visible { - outline: 2px solid var(--accent-primary); - outline-offset: 4px; + background-size: cover; background-position: center; background-repeat: no-repeat; } -.report-card { - border: none; - background: transparent; -} +.table-empty-state { padding: 3rem 0; color: var(--text-muted); } -.report-card + .report-card { - border-top: 1px solid var(--border-subtle); +.metric-card { transition: transform 0.2s ease, box-shadow 0.2s ease; } +.metric-card--interactive { cursor: pointer; } +.metric-card--interactive:hover, .metric-card--interactive:focus-visible { + transform: translateY(-4px); box-shadow: var(--shadow-soft); } +.metric-card--interactive:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 4px; } +.report-card { border: none; background: transparent; } +.report-card + .report-card { border-top: 1px solid var(--border-subtle); } .report-card__link { - display: block; - padding: var(--spacing-md); - border-radius: var(--radius-lg); + display: block; padding: var(--spacing-md); border-radius: var(--radius-lg); transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; } - -.report-card__link:hover, -.report-card__link:focus-visible { - background: var(--surface-muted); - box-shadow: var(--shadow-soft); - transform: translateY(-2px); -} - -.report-card__link:focus-visible { - outline: 2px solid var(--accent-primary); - outline-offset: 2px; +.report-card__link:hover, .report-card__link:focus-visible { + background: var(--surface-muted); box-shadow: var(--shadow-soft); transform: translateY(-2px); } - -.report-card:hover .report-card__link, -.report-card:focus-within .report-card__link { - background: var(--surface-muted); - box-shadow: var(--shadow-soft); -} - -.report-card__footer { - padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md); - border-color: var(--border-subtle) !important; -} - -.audit-log-card { - border: none; - background: transparent; -} - -.audit-log-card + .audit-log-card { - border-top: 1px solid var(--border-subtle); -} - -.audit-log-card__content { - padding: var(--spacing-md); - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.audit-log-details { - display: block; +.report-card__link:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; } +.report-card:hover .report-card__link, .report-card:focus-within .report-card__link { + background: var(--surface-muted); box-shadow: var(--shadow-soft); } +.report-card__footer { padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md); border-color: var(--border-subtle) !important; } +.audit-log-card { border: none; background: transparent; } +.audit-log-card + .audit-log-card { border-top: 1px solid var(--border-subtle); } +.audit-log-card__content { padding: var(--spacing-md); display: flex; flex-direction: column; gap: var(--spacing-md); } +.audit-log-details { display: block; } .audit-log-details__summary { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - cursor: pointer; - font-weight: 600; - color: var(--accent-primary); - text-decoration: none; -} - -.audit-log-details__summary:hover, -.audit-log-details__summary:focus-visible { - color: var(--accent-primary-strong); - outline: none; -} - -.audit-log-details__summary::-webkit-details-marker { - display: none; -} - -.audit-log-details__summary::after { - content: '\25BC'; - font-size: 0.75rem; - transition: transform 0.2s ease; -} - -.audit-log-details[open] .audit-log-details__summary::after { - transform: rotate(-180deg); -} - -.audit-log-details__content { - margin-top: var(--spacing-sm); -} - -.offcanvas .app-sidebar__link { - color: var(--text-primary); -} - -.offcanvas .app-sidebar__link.is-disabled { - color: rgba(100, 116, 139, 0.6); + display: inline-flex; align-items: center; gap: var(--spacing-xs); + cursor: pointer; font-weight: 600; color: var(--accent-primary); text-decoration: none; } +.audit-log-details__summary:hover, .audit-log-details__summary:focus-visible { color: var(--accent-primary-strong); outline: none; } +.audit-log-details__summary::-webkit-details-marker { display: none; } +.audit-log-details__summary::after { content: '\25BC'; font-size: 0.75rem; transition: transform 0.2s ease; } +.audit-log-details[open] .audit-log-details__summary::after { transform: rotate(-180deg); } +.audit-log-details__content { margin-top: var(--spacing-sm); } +.offcanvas .app-sidebar__link { color: var(--text-primary); } +.offcanvas .app-sidebar__link.is-disabled { color: rgba(100, 116, 139, 0.6); } .offcanvas .app-sidebar__link.is-active { - color: var(--accent-primary); - border-left: none; - background: rgba(59, 130, 246, 0.12); -} - -.text-muted-soft { - color: rgba(100, 116, 139, 0.85) !important; -} - -.alert-elevated { - border-radius: var(--radius-md); - border: none; - box-shadow: var(--shadow-soft); -} - -.card-header-tight { - padding: var(--spacing-sm) var(--spacing-md); - border-bottom: 1px solid rgba(15, 23, 42, 0.06); -} - -.card-body-comfy { - padding: var(--spacing-md); -} - -.flex-gap-sm { - gap: var(--spacing-sm); -} - -.flex-gap-md { - gap: var(--spacing-md); -} - -.btn-ghost { - background: transparent; - border: 1px solid var(--border-subtle); - color: var(--text-primary); -} - -.btn-ghost:hover, -.btn-ghost:focus { - background: var(--surface-muted); -} - -.shadow-soft { - box-shadow: var(--shadow-soft) !important; -} - -.border-subtle { - border: 1px solid var(--border-subtle) !important; -} - -.bg-overlay-muted { - background: rgba(15, 23, 42, 0.04) !important; -} - -.th-text-muted { - color: var(--text-muted) !important; -} - -.form-legend { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.app-section-title { - font-size: 1.125rem; - font-weight: 600; -} + color: var(--accent-primary); border-left: none; background: rgba(59, 130, 246, 0.12); +} + +.text-muted-soft { color: rgba(100, 116, 139, 0.85) !important; } +.alert-elevated { border-radius: var(--radius-md); border: none; box-shadow: var(--shadow-soft); } +.card-header-tight { padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid rgba(15, 23, 42, 0.06); } +.card-body-comfy { padding: var(--spacing-md); } +.flex-gap-sm { gap: var(--spacing-sm); } +.flex-gap-md { gap: var(--spacing-md); } +.btn-ghost { background: transparent; border: 1px solid var(--border-subtle); color: var(--text-primary); } +.btn-ghost:hover, .btn-ghost:focus { background: var(--surface-muted); } +.shadow-soft { box-shadow: var(--shadow-soft) !important; } +.border-subtle { border: 1px solid var(--border-subtle) !important; } +.bg-overlay-muted { background: rgba(15, 23, 42, 0.04) !important; } +.th-text-muted { color: var(--text-muted) !important; } +.form-legend { font-size: 0.875rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; } +.app-section-title { font-size: 1.125rem; font-weight: 600; } /* ===== Service Insight ===== */ .service-insight { - display: flex; - flex-direction: column; - gap: var(--spacing-lg); + display: flex; flex-direction: column; gap: var(--spacing-lg); } - .service-insight__container { - display: flex; - flex-direction: column; + /* ← 여기서 좌우 스크롤 분리용 컨테이너 고정 높이 + 내부 스크롤 */ + display: grid; + grid-template-columns: 280px 1fr; gap: var(--spacing-lg); + overflow: hidden; /* 내부 컬럼만 스크롤 */ + min-height: 0; /* 그리드 자식 overflow 동작 보장 */ + /* 높이는 JS가 런타임에 직접 설정 (뷰포트-오프셋 계산) */ } +@media (min-width: 992px) { .service-insight__container { grid-template-columns: 280px 1fr; } } +@media (max-width: 991.98px) { .service-insight__container { grid-template-columns: 1fr; } } -@media (min-width: 992px) { - .service-insight__container { - flex-direction: row; - align-items: stretch; - } -} - -.service-insight__metrics { - flex: 0 0 280px; - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.service-insight__metrics-header { - display: flex; - flex-direction: column; - gap: 0.25rem; +/* 좌우 컬럼을 각각 스크롤 */ +.service-insight__metrics, +.service-insight__chart { + height: 100%; /* 컨테이너 높이를 채움 (JS가 container 높이 세팅) */ + overflow-y: auto; /* 각자 스크롤 */ + min-height: 0; } -.service-insight__metric-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} +.service-insight__metrics { display: flex; flex-direction: column; gap: var(--spacing-md); } +.service-insight__metrics-header { display: flex; flex-direction: column; gap: 0.25rem; } +.service-insight__metric-list { display: flex; flex-direction: column; gap: 0.75rem; } .service-insight__metric-button { - width: 100%; - text-align: left; - background: transparent; - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); + width: 100%; text-align: left; background: transparent; + border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--spacing-sm) var(--spacing-md); - display: flex; - flex-direction: column; - gap: 0.35rem; - color: inherit; - transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + display: flex; flex-direction: column; gap: 0.35rem; + color: inherit; transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s; } - .service-insight__metric-button:hover, .service-insight__metric-button:focus-visible { border-color: var(--accent-primary); - box-shadow: 0 12px 32px rgba(59, 130, 246, 0.12); - transform: translateY(-1px); - outline: none; + box-shadow: 0 12px 32px rgba(59,130,246,0.12); + transform: translateY(-1px); outline: none; } - .service-insight__metric-button.is-active { border-color: var(--accent-primary); - background: rgba(59, 130, 246, 0.08); - box-shadow: 0 16px 32px rgba(59, 130, 246, 0.18); -} - -.service-insight__metric-label { - font-weight: 600; -} - -.service-insight__metric-badge { - align-self: flex-start; + background: rgba(59,130,246,0.08); + box-shadow: 0 16px 32px rgba(59,130,246,0.18); } +.service-insight__metric-label { font-weight: 600; } +.service-insight__metric-badge { align-self: flex-start; } -.service-insight__chart { - flex: 1 1 auto; - min-width: 0; -} - -.service-insight__chart-surface { - position: relative; - height: 360px; /* 명시적 높이 */ -} - -@media (min-width: 1200px) { - .service-insight__chart-surface { - height: 420px; - } -} - -.service-insight__chart-surface canvas { - width: 100% !important; - height: 100%; - display: block; -} +.service-insight__chart { min-width: 0; } -.service-insight__unit { - font-weight: 600; -} +.service-insight__chart-surface { position: relative; height: 360px; } +@media (min-width: 1200px) { .service-insight__chart-surface { height: 420px; } } +.service-insight__chart-surface canvas { width: 100% !important; height: 100%; display: block; } +.service-insight__unit { font-weight: 600; } .service-insight__empty-state { - border: 1px dashed var(--border-subtle); - border-radius: var(--radius-lg); + border: 1px dashed var(--border-subtle); border-radius: var(--radius-lg); background: rgba(15, 23, 42, 0.02); } -/* 질문형 인사이트 레이아웃 */ +/* 질문형 인사이트(호환) */ .siq-grid { display: grid; gap: var(--spacing-lg); } @media (min-width: 992px) { .siq-grid--two { grid-template-columns: 1fr 1fr; } } - -.siq-card__surface { - position: relative; - height: 360px; -} -@media (min-width: 1200px) { - .siq-card__surface { height: 420px; } -} -.siq-card__surface canvas { - width: 100% !important; - height: 100%; - display: block; -} - -/* ===== 질문 버튼 가독성 & 사이드 폭 확장 ===== */ -:root { --insight-sidebar-width: 360px; } /* 기본 폭 */ -@media (min-width: 1400px) { - :root { --insight-sidebar-width: 420px; } /* 넓은 화면에선 더 여유 */ -} - -.service-insight__metrics { - flex: 0 0 var(--insight-sidebar-width); - min-width: var(--insight-sidebar-width); +.siq-card__surface { position: relative; height: 360px; } +@media (min-width: 1200px) { .siq-card__surface { height: 420px; } } +.siq-card__surface canvas { width: 100% !important; height: 100%; display: block; } + +/* ===== DnD 플롯/그룹 UI ===== */ +.si-sidebar { display: flex; flex-direction: column; gap: var(--spacing-md); } +.si-sidebar__header { display: flex; align-items: baseline; justify-content: space-between; padding: 0 var(--spacing-sm); } +.si-sidebar__title { font-weight: 700; } +.si-sidebar__hint { color: var(--text-muted); font-size: 0.85rem; } +.si-groups { display: flex; flex-direction: column; gap: var(--spacing-sm); } + +.si-group { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface-primary); overflow: hidden; } +.si-group[open] .si-group__summary { border-bottom: 1px solid var(--border-subtle); } +.si-group__summary { + display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); cursor: pointer; +} +.si-group__title { font-weight: 600; } +.si-group__tools { display: inline-flex; gap: 0.25rem; } +.si-group__btn { + border: 1px solid var(--border-subtle); background: transparent; border-radius: var(--radius-sm); + padding: 0.25rem 0.5rem; font-size: 0.8rem; color: var(--text-muted); +} +.si-group__btn:hover { color: var(--accent-primary); border-color: var(--accent-primary); } +.si-group__body { padding: var(--spacing-sm) var(--spacing-md); } +.si-chip-list { display: flex; flex-direction: column; gap: 0.5rem; } + +.si-chip { + display: flex; align-items: center; gap: 0.6rem; + border: 1px dashed var(--border-subtle); + background: rgba(15,23,42,0.02); + border-radius: var(--radius-md); + padding: 0.45rem 0.6rem; + cursor: grab; user-select: none; +} +.si-chip:active { cursor: grabbing; } +.si-chip:hover { border-color: var(--accent-primary); background: rgba(59,130,246,0.06); } +.si-chip__dot { width: 10px; height: 10px; border-radius: 999px; flex: 0 0 10px; } +.si-chip__label { flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } +.si-chip__meta { color: var(--text-muted); font-size: 0.8rem; } +.si-grip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; opacity: .6; } + +.si-plots { display: flex; flex-direction: column; gap: var(--spacing-md); } +.si-plots__toolbar { display: flex; justify-content: space-between; align-items: center; padding: 0 0.25rem; } +.si-size { display: inline-flex; gap: 0.4rem; } +.si-size__btn.is-active { border-color: var(--accent-primary); color: var(--accent-primary); } + +.si-plot-grid { display: grid; gap: var(--spacing-lg); grid-template-columns: 1fr; } +@media (min-width: 1200px) { .si-plot-grid { grid-template-columns: 1fr 1fr; } } + +.si-plot-card { + border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); + background: var(--surface-primary); box-shadow: var(--shadow-soft); overflow: hidden; +} +.si-plot__header { + display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--border-subtle); +} +.si-plot__title { font-weight: 600; } +.si-plot__tools { display: inline-flex; gap: 0.35rem; } + +.si-plot__datasets { + display: flex; flex-wrap: wrap; gap: 0.35rem; + padding: 0.5rem var(--spacing-md); + border-bottom: 1px dashed var(--border-subtle); + background: rgba(15,23,42,0.02); +} +.si-ds-chip { + display: inline-flex; align-items: center; gap: 0.4rem; + border: 1px solid var(--border-subtle); + border-radius: 999px; padding: 0.2rem 0.5rem; + font-size: 0.85rem; background: #fff; cursor: grab; } +.si-ds-chip:active { cursor: grabbing; } +.si-ds-chip__dot { width: 8px; height: 8px; border-radius: 999px; } +.si-ds-chip__rm { border: none; background: transparent; color: var(--text-muted); padding: 0; line-height: 1; font-size: 1rem; cursor: pointer; } +.si-ds-chip__rm:hover { color: var(--accent-danger); } -@media (max-width: 991.98px) { - .service-insight__metrics { flex: 1 1 auto; min-width: 0; } /* 모바일에선 유동 */ +.si-dropzone { + display: flex; align-items: center; justify-content: center; + padding: var(--spacing-md); + border: 2px dashed var(--border-subtle); + border-radius: var(--radius-lg); + color: var(--text-muted); font-weight: 600; + transition: border-color .15s ease, background-color .15s ease; } +.si-dropzone.is-dragover { border-color: var(--accent-primary); background: rgba(59,130,246,0.06); } -.service-insight__metric-button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: var(--spacing-sm) var(--spacing-md); -} +.si-plot__surface { position: relative; } +.si-plots[data-size="compact"] .si-plot__surface { height: 240px; } +.si-plots[data-size="cozy"] .si-plot__surface { height: 300px; } +.si-plots[data-size="roomy"] .si-plot__surface { height: 420px; } +.si-plot__surface canvas { width: 100% !important; height: 100%; display: block; } -.service-insight__metric-label { - white-space: nowrap; /* 줄바꿈 금지 */ - overflow: hidden; /* 넘치면 숨김 */ - text-overflow: ellipsis; /* 말줄임표 */ - width: 100%; +.si-empty { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + color: var(--text-muted); pointer-events: none; } diff --git a/src/main/resources/static/css/service-insight.css b/src/main/resources/static/css/service-insight.css new file mode 100644 index 0000000..56134dc --- /dev/null +++ b/src/main/resources/static/css/service-insight.css @@ -0,0 +1,105 @@ +/* ===== Service Insight 전용 ===== */ +.service-insight { --x: 0px; --y: 0px; } + +.service-insight .si-group__summary { + position: relative; + cursor: pointer; + user-select: none; +} +.service-insight .si-group__summary::-webkit-details-marker { display: none; } +.service-insight .si-group__summary::before { + content: ""; + width: .55rem; height: .55rem; + border-right: 2px solid #94a3b8; + border-bottom: 2px solid #94a3b8; + transform: rotate(45deg); + margin-right: .4rem; + display: inline-block; + transition: transform .18s ease, border-color .18s ease; +} +.service-insight .si-group[open] .si-group__summary::before { transform: rotate(225deg); } +.service-insight .si-group__summary:hover::before { border-color: #334155; } + +/* 플롯 영역 여백 + 포커스 그림자 */ +.service-insight .service-insight__chart { margin-left: 16px; } +.service-insight .si-plot-card { transition: box-shadow .22s ease, border-color .22s ease, transform .12s ease; will-change: box-shadow, transform; } +.service-insight .si-plot-card.si-plot--active{ + box-shadow: + 0 2px 8px rgba(0,0,0,.06), + 0 10px 24px rgba(15,23,42,.08), + 0 0 0 2px rgba(99,102,241,.18), + 0 0 0 10px rgba(99,102,241,.06); + border-color: rgba(99,102,241,.25); +} + +/* 데이터셋 스트립 */ +.service-insight .si-plot__datasets{ + display:flex;flex-wrap:wrap;gap:.35rem; + padding:.35rem .5rem .5rem .5rem;min-height:40px; + background:#f8fafc;border-radius:.5rem;border:1px dashed rgba(148,163,184,.45); +} +.service-insight .si-plot__datasets[data-empty="true"]{position:relative;} +.service-insight .si-plot__datasets[data-empty="true"]::before{ + content:"여기에 데이터를 드래그해서 놓으세요";color:#64748b;font-size:.9rem; +} + +/* aside 칩 */ +.service-insight__metrics .si-chip.si-chip--toggle{ + appearance:none;border:1px solid rgba(15,23,42,.08); + background:linear-gradient(180deg,#fff,#fbfdff);color:#0b0f1a; + padding:.38rem .6rem;border-radius:999px;display:inline-flex;align-items:center;gap:.45rem; + line-height:1;font-size:.92rem;cursor:pointer!important; + box-shadow:0 1px 1px rgba(0,0,0,.03), inset 0 1px 0 rgba(255,255,255,.9); + transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .02s ease; +} +.service-insight__metrics .si-chip.si-chip--toggle:hover{ + background:#f9fbff;border-color:rgba(59,130,246,.18);box-shadow:0 2px 6px rgba(30,64,175,.06); +} +.service-insight__metrics .si-chip.si-chip--toggle:active{transform:translateY(1px);} +.service-insight__metrics .si-chip.si-chip--toggle .si-chip__dot{ + width:.55rem;height:.55rem;border-radius:50%;box-shadow:0 0 0 2px #fff,0 0 0 3px rgba(15,23,42,.05); +} +.service-insight__metrics .si-chip.si-chip--toggle .si-chip__check{ + width:1rem;height:1rem;display:inline-flex;align-items:center;justify-content:center;border-radius:50%; + border:1px solid rgba(15,23,42,.12);background:#fff;position:relative;flex:0 0 1rem;box-sizing:border-box; +} +.service-insight__metrics .si-chip.si-chip--toggle.is-active .si-chip__check{ + border-color:rgba(34,197,94,.35); + background:linear-gradient(180deg,rgba(34,197,94,.18),rgba(34,197,94,.28)); +} +.service-insight__metrics .si-chip.si-chip--toggle.is-active .si-chip__check::after{ + content:"";width:.7rem;height:.7rem;display:block; + background:conic-gradient(from 0deg at 50% 50%,#22c55e,#16a34a); + -webkit-mask:url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.5 11.6 4 9.1l-1.4 1.4 3.9 3.9L17.4 3.5 16 2.1 6.5 11.6z' fill='black'/%3E%3C/svg%3E") center/100% 100% no-repeat; + mask:url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.5 11.6 4 9.1l-1.4 1.4 3.9 3.9L17.4 3.5 16 2.1 6.5 11.6z' fill='black'/%3E%3C/svg%3E") center/100% 100% no-repeat; +} + +/* 플롯 상단 칩 */ +.service-insight .si-ds-chip{ + display:inline-flex;align-items:center;gap:.45rem;padding:.28rem .55rem;border-radius:.55rem; + border:1px solid rgba(15,23,42,.1);background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.04);cursor:grab;user-select:none; +} +.service-insight .si-ds-chip:active{cursor:grabbing;} +.service-insight .si-ds-chip .si-grip{display:inline-block;font-weight:700;color:#94a3b8;margin-right:.1rem;} +.service-insight .si-ds-chip__dot{width:.55rem;height:.55rem;border-radius:50%;box-shadow:0 0 0 2px #fff,0 0 0 3px rgba(15,23,42,.05);} +.service-insight .si-ds-chip__rm{margin-left:.25rem;width:1.15rem;height:1.15rem;border-radius:.5rem;border:1px solid rgba(15,23,42,.1);background:#fff;color:#0b0f1a;line-height:1;font-size:.95rem;padding:0;} +.service-insight .si-ds-chip__rm:hover{background:#fee2e2;border-color:#fecaca;} +.service-insight .si-ds-chip__label{white-space:nowrap;} + +/* 드래그 고스트 & 드랍 강조 */ +.service-insight .si-chip--ghost{ + position:fixed;left:0;top:0; + transform:translate3d(var(--x,0px),var(--y,0px),0); + pointer-events:none;z-index:999999;border:1px solid rgba(148,163,184,.45); + background:linear-gradient(180deg,rgba(255,255,255,.98),rgba(255,255,255,.92)); + color:#0b0c0f;border-radius:.6rem;box-shadow:0 12px 36px rgba(0,0,0,.35); + padding:.3rem .55rem;will-change:transform;transition:transform 160ms cubic-bezier(.2,.8,.2,1); +} +.service-insight .si-drop-target{outline:2px dashed rgba(91,140,255,.7);outline-offset:4px;} +body.is-dragging{cursor:grabbing;} body.is-dragging *{user-select:none!important;} + +/* 이미지/링크 네이티브 드래그 억제 */ +.service-insight img, .service-insight a{-webkit-user-drag:none;} + +/* 기타 */ +.service-insight .si-chip__meta, .service-insight .si-chip .si-chip__unit{display:none!important;} diff --git a/src/main/resources/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js index ddd0ddd..21f79da 100644 --- a/src/main/resources/static/js/service-insight.js +++ b/src/main/resources/static/js/service-insight.js @@ -1,101 +1,571 @@ +// ===== resources/static/js/service-insight.js ===== (function () { - // ====== 0) 데이터 파싱 ====== + // ---------- 0) 데이터 파싱 ---------- const dataElement = document.getElementById('serviceInsightData'); if (!dataElement) return; let payload = {}; - try { - payload = JSON.parse(dataElement.textContent || '{}'); - } catch (err) { - console.warn('Failed to parse service insight payload.', err); - } + try { payload = JSON.parse(dataElement.textContent || '{}'); } + catch (err) { console.warn('Failed to parse service insight payload.', err); } - const metricOptions = Array.isArray(payload.metricOptions) ? payload.metricOptions : []; - const seriesList = Array.isArray(payload.series) ? payload.series : []; - const componentLabels = (payload.componentLabels && typeof payload.componentLabels === 'object') - ? payload.componentLabels : {}; + const metricOptions = Array.isArray(payload.metricOptions) ? payload.metricOptions : []; + const seriesList = Array.isArray(payload.series) ? payload.series : []; + const componentLabels = (payload.componentLabels && typeof payload.componentLabels === 'object') ? payload.componentLabels : {}; - // metric → meta/series 매핑 const optionMap = new Map(metricOptions.map(o => [o.metric, o])); const seriesMap = new Map(seriesList.map(s => [s.metric, s])); - // 서버 enum 키 (백엔드와 1:1) : - // COLLECTION_TOTAL_COUNT, COLLECTION_PRIVATE_RATIO, - // BIRD_ID_PENDING_COUNT, BIRD_ID_RESOLVED_COUNT, BIRD_ID_RESOLUTION_STATS + // 서버 enum 키 const METRICS = { - TOTAL_COUNT: 'COLLECTION_TOTAL_COUNT', - PRIVATE_RATIO: 'COLLECTION_PRIVATE_RATIO', - PENDING_COUNT: 'BIRD_ID_PENDING_COUNT', - RESOLVED_COUNT: 'BIRD_ID_RESOLVED_COUNT', - RESOLUTION_STATS: 'BIRD_ID_RESOLUTION_STATS' + TOTAL_COUNT: 'COLLECTION_TOTAL_COUNT', + 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 + }; + + // 컬러 팔레트 + const PALETTE = ['#2563eb','#16a34a','#dc2626','#f97316','#9333ea','#0ea5e9','#059669','#ea580c','#3b82f6','#14b8a6']; + const colorCache = new Map(); + const colorForMetric = key => { + if (colorCache.has(key)) return colorCache.get(key); + const idx = colorCache.size % PALETTE.length; + const c = PALETTE[idx]; colorCache.set(key, c); return c; }; - // ====== 1) DOM 핸들 ====== - const questionButtons = Array.from(document.querySelectorAll('[data-role="si-question"]')); - const viewPrivacy = document.getElementById('siq-privacy'); - const viewIdRequests = document.getElementById('siq-idRequests'); - const emptyState = document.getElementById('serviceInsightEmptyState'); + // ---------- 레이아웃: 좌·우 스크롤 분리 ---------- + const container = document.querySelector('.service-insight__container'); + const leftCol = document.querySelector('.service-insight__metrics'); + const rightCol = document.querySelector('.service-insight__chart'); + + function applyIndependentScroll() { + if (!container || !leftCol || !rightCol) return; + const rect = container.getBoundingClientRect(); + const avail = Math.max(240, window.innerHeight - rect.top - 16); + container.style.height = avail + 'px'; + container.style.overflow = 'hidden'; + [leftCol, rightCol].forEach(el => { + el.style.height = '100%'; + el.style.minHeight = '0'; + el.style.overflowY = 'auto'; + }); + } + applyIndependentScroll(); + + // 리사이즈 보정 + function resizeAllCharts() { + for (const [, p] of plots) { try { p.chart.resize(); } catch(_){} } + } + window.addEventListener('resize', () => { applyIndependentScroll(); resizeAllCharts(); }); + + // ---------- 유틸 ---------- + function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // KST 기준 날짜 정규화 + 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 + + 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 고정 + 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()); + return t; + } + + // aside 툴팁 초기화 + function initTooltipsIn(scope) { + const root = scope || document; + if (!window.bootstrap || !window.bootstrap.Tooltip) return; + 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, + sanitize: true, + boundary: 'window', + container: 'body', + 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'); - // ====== 2) 차트 생성 유틸 ====== - const PALETTE = [ - '#2563eb', '#16a34a', '#dc2626', '#f97316', '#9333ea', - '#0ea5e9', '#059669', '#ea580c', '#3b82f6', '#14b8a6' + const GROUPS = [ + { key: 'privacy', name: '새록', metrics: [METRICS.TOTAL_COUNT, METRICS.PRIVATE_RATIO] }, + { key: 'id', name: '동정 요청', metrics: [METRICS.PENDING_COUNT, METRICS.RESOLVED_COUNT, METRICS.RESOLUTION_STATS] }, ]; - let colorCursor = 0; - const nextColor = () => PALETTE[(colorCursor++) % PALETTE.length]; + 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 }); + + function renderGroups(){ + if (!groupsWrap) return; + groupsWrap.innerHTML = ''; + + GROUPS.forEach(group => { + const details = document.createElement('details'); + details.className = 'si-group'; + details.open = true; + + const summary = document.createElement('summary'); + summary.className = 'si-group__summary'; + summary.innerHTML = ` + ${escapeHtml(group.name)} + + + + + `; + details.appendChild(summary); + + const body = document.createElement('div'); body.className = 'si-group__body'; + const list = document.createElement('div'); list.className = 'si-chip-list'; + + (group.metrics || []).forEach(metricKey => { + const opt = optionMap.get(metricKey); if (!opt) return; + const color = colorForMetric(metricKey); + + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'si-chip si-chip--toggle'; + chip.dataset.metric = metricKey; + + chip.setAttribute('draggable','false'); + chip.addEventListener('dragstart', e => e.preventDefault()); + + // aside 툴팁: 제목+설명 + const titleText = opt.label || metricKey; + const descText = opt.description || ''; + const tooltipHtml = `${escapeHtml(titleText)}` + (descText ? `
${escapeHtml(descText)}` : ''); + chip.setAttribute('data-bs-toggle', 'tooltip'); + chip.setAttribute('data-bs-placement', 'right'); + chip.setAttribute('data-bs-title', tooltipHtml); + chip.removeAttribute('title'); + + chip.innerHTML = ` + + ${escapeHtml(opt.label || metricKey)} + + `; + + chip.addEventListener('click', ()=>{ + const activePlot = getActivePlotId() ?? ensureAtLeastOnePlot(); + toggleMetricGroupOnPlot(activePlot, metricKey); + if (window.bootstrap?.Tooltip) { + const inst = window.bootstrap.Tooltip.getInstance(chip); + try { inst?.hide(); } catch(_) {} + } + syncAsideActiveStates(); + }); + + chip.addEventListener('mouseleave', ()=>{ + if (window.bootstrap?.Tooltip) { + const inst = window.bootstrap.Tooltip.getInstance(chip); + try { inst?.hide(); } catch(_) {} + } + }); + + list.appendChild(chip); + }); + + body.appendChild(list); + details.appendChild(body); + + summary.querySelector('[data-act="addAll"]').addEventListener('click', (ev)=>{ + ev.preventDefault(); ev.stopPropagation(); + const activePlot = getActivePlotId() ?? ensureAtLeastOnePlot(); + (group.metrics||[]).forEach(m => addMetricGroupToPlot(activePlot, m)); + syncAsideActiveStates(); + }); + summary.querySelector('[data-act="removeAll"]').addEventListener('click', (ev)=>{ + ev.preventDefault(); ev.stopPropagation(); + (group.metrics||[]).forEach(m => removeMetricGroupFromAllPlots(m)); + syncAsideActiveStates(); + }); + + groupsWrap.appendChild(details); + }); + + initTooltipsIn(groupsWrap); + syncAsideActiveStates(); + } + + // ---------- 플롯 ---------- + const plotGrid = document.getElementById('si-plot-grid'); + const plotsHost = document.getElementById('si-plots'); + const addPlotBtn = document.getElementById('si-add-plot'); + const clearAllBtn= document.getElementById('si-clear-all'); + + if (plotsHost) plotsHost.setAttribute('data-size', 'roomy'); + + let plotSeq = 0; + // plots: id -> { id, index, el, chart, datasets:Set, groups: Map>, active:boolean } + const plots = new Map(); + + function ensureAtLeastOnePlot() { if (plots.size===0) return createPlot(); return [...plots.keys()][0]; } + function getActivePlotId(){ for (const [id,p] of plots){ if (p.active) return id; } return null; } + function setActivePlot(id){ + for (const [pid,p] of plots){ + p.active = (pid===id); + const title = p.el.querySelector('.si-plot__title'); + if (title) title.textContent = `플롯 ${p.index}`; + p.el.classList.toggle('si-plot--active', p.active); + } + syncAsideActiveStates(); + } + + // 박스플롯 플러그인(평균 중앙선, ±표준편차 상자, 최소~최대 수염) + const boxPlotPlugin = { + id: 'siBoxPlot', + afterDatasetsDraw(chart) { + const ctx = chart.ctx; + (chart.data.datasets || []).forEach((ds, i) => { + if (!ds._boxplot || !chart.isDatasetVisible(i)) return; + const meta = chart.getDatasetMeta(i); + const xScale = meta.xScale, yScale = meta.yScale; + const boxHalf = 6; + const capHalf = 10; + + ds._boxplot.forEach(entry => { + const x = xScale.getPixelForValue(entry._time); + const yMin = yScale.getPixelForValue(entry.min); + const yMax = yScale.getPixelForValue(entry.max); + const yMean = yScale.getPixelForValue(entry.mean); + const yLo = yScale.getPixelForValue(entry.mean - entry.std); + const yHi = yScale.getPixelForValue(entry.mean + entry.std); + + ctx.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(); + + // 박스(±표준편차) + ctx.fillStyle = 'rgba(91,140,255,.18)'; + ctx.strokeStyle = 'rgba(91,140,255,.9)'; + ctx.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(); + + // 평균선 + 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(); + }); + }); + } + }; + if (window.Chart && Chart.register) Chart.register(boxPlotPlugin); + + function createPlot(){ + const id = `plot-${++plotSeq}`; const index = plots.size + 1; + const card = document.createElement('div'); + card.className='si-plot-card'; card.dataset.plotId = id; + card.innerHTML = ` +
+
플롯 ${index}
+
+ +
+
+
+
+ +
표시할 데이터가 없습니다
+
+ `; + plotGrid.appendChild(card); + + card.addEventListener('mousedown', ()=> setActivePlot(id)); + card.addEventListener('focusin', ()=> setActivePlot(id)); + + const canvas = card.querySelector('canvas'); + const chart = new Chart(canvas.getContext('2d'), { type:'line', data:{datasets:[]}, options: makeBaseChartOptions() }); + + card.querySelector('[data-act="removePlot"]').addEventListener('click', ()=> removePlot(id)); + + const dz = card.querySelector('[data-dropzone="datasets"]'); + dz.addEventListener('dragover', e=>{ e.preventDefault(); dz.classList.add('is-dragover'); }); + dz.addEventListener('dragleave', ()=> dz.classList.remove('is-dragover')); + dz.addEventListener('drop', e=>{ + e.preventDefault(); dz.classList.remove('is-dragover'); + try{ + const payload = JSON.parse(e.dataTransfer.getData('application/json') || '{}'); + if (payload.type === 'datasetGroup' && payload.groupId && payload.fromPlotId){ + moveGroupToPlot(payload.groupId, payload.fromPlotId, id); + } + }catch(_){} + }); + + plots.set(id, { + id, index, el: card, chart, + datasets: new Set(), + groups: new Map(), + active: false + }); + + if (plots.size===1) setActivePlot(id); + updateEmptyState(id); + return id; + } + + function markDatasetsStripState(strip) { + const hasChip = !!strip.querySelector('.si-ds-chip'); + strip.setAttribute('data-empty', hasChip ? 'false' : 'true'); + } + + function removePlot(id){ + const p = plots.get(id); if (!p) return; + p.chart.destroy(); + p.el.remove(); + plots.delete(id); - const axisByUnit = { COUNT: 'count', RATIO: 'ratio', HOURS: 'hours' }; + let i = 0; + for (const [pid, plot] of plots){ + plot.index = ++i; + const title = plot.el.querySelector('.si-plot__title'); + if (title) title.textContent = `플롯 ${plot.index}`; + } + if (plots.size === 0) createPlot(); + syncAsideActiveStates(); + } + + function updateEmptyState(plotId){ + const p = plots.get(plotId); if (!p) return; + const emptyEl = p.el.querySelector('.si-empty'); + const hasAny = (p.chart.data.datasets || []).some(ds => (ds.data||[]).length>0); + emptyEl.classList.toggle('d-none', hasAny); - function createBaseOptions(yAxisKey) { + const strip = p.el.querySelector('.si-plot__datasets'); + strip.setAttribute('data-empty', (p.groups.size === 0) ? 'true' : 'false'); + } + + // ---------- 데이터 변환/축 ---------- + function toPoints(series, unit) { + const points = Array.isArray(series?.points) ? series.points : []; + return points + .map(pt => { + const x = toDateKST(pt.date); // ✅ KST 00:00 기준 + const y = normalizeValue(pt.value, unit); + return (x && y != null) ? ({ x, y }) : null; + }) + .filter(Boolean); + } + function formatValue(v, unit){ + if (!Number.isFinite(v)) return '-'; + if (unit==='RATIO') return `${v.toFixed(1)}%`; + if (unit==='HOURS') return `${v.toFixed(2)}시간`; + return Math.round(v).toLocaleString(); + } + function normalizeValue(num, unit){ + const v = typeof num === 'string' ? Number(num) : Number(num); + if (!Number.isFinite(v)) return null; + if (unit==='RATIO') return v <= 1.000001 ? v*100 : v; + return v; + } + function axisByUnit(unit){ if (unit==='RATIO') return 'ratio'; if (unit==='HOURS') return 'hours'; return 'count'; } + + function makeBaseChartOptions(){ return { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: 'nearest', intersect: false }, - elements: { line: { borderWidth: 2 }, point: { radius: 0, hoverRadius: 4, hitRadius: 8 } }, + responsive: true, maintainAspectRatio: false, + interaction: { mode:'nearest', intersect:false }, + elements: { line:{borderWidth:2}, point:{radius:0, hoverRadius:4, hitRadius:8} }, scales: { x: { - type: 'time', - time: { unit: 'day', tooltipFormat: 'yyyy-LL-dd' }, - ticks: { autoSkip: true, maxRotation: 0 }, - grid: { color: 'rgba(148, 163, 184, 0.2)' } - }, - count: { - display: yAxisKey === 'count', - position: 'left', - beginAtZero: true, - ticks: { - precision: 0, - maxTicksLimit: 8, - callback: (v) => (Number.isInteger(v) ? v.toLocaleString() : '') - } + type:'time', + time:{ + unit:'day', + round:'day', // ✅ 눈금도 일자 기준으로 스냅 + tooltipFormat:'yyyy-LL-dd', + zone: TZ // ✅ 축 파싱 타임존 고정 + }, + adapters:{ date:{ zone: TZ } }, // ✅ 어댑터에도 동일 적용 + ticks:{ autoSkip:true, maxRotation:0 }, + grid:{ color:'rgba(148, 163, 184, 0.2)' } }, - ratio: { - display: yAxisKey === 'ratio', - position: 'right', - beginAtZero: true, - min: 0, max: 100, - grid: { drawOnChartArea: false }, - ticks: { stepSize: 10, maxTicksLimit: 6, callback: (v) => `${v}%` } - }, - hours: { - display: yAxisKey === 'hours', - position: 'right', - beginAtZero: true, - grid: { drawOnChartArea: false }, - ticks: { maxTicksLimit: 6, callback: (v) => (typeof v === 'number' ? v.toFixed(1) : v) } - } + count: { display:false, position:'left', beginAtZero:true, ticks:{ precision:0, maxTicksLimit:8, callback:v => Number.isInteger(v)? v.toLocaleString():'' } }, + ratio: { display:false, position:'right', beginAtZero:true, min:0, max:100, grid:{ drawOnChartArea:false }, ticks:{ stepSize:10, maxTicksLimit:6, callback:v=>`${v}%` } }, + hours: { display:false, position:'right', beginAtZero:true, grid:{ drawOnChartArea:false }, ticks:{ maxTicksLimit:6, callback:v=>(typeof v==='number'? v.toFixed(1):v) } } }, plugins: { - legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, boxHeight: 8 } }, + legend: { position:'bottom', labels:{ usePointStyle:true, boxWidth:8, boxHeight:8 } }, tooltip: { - mode: 'index', intersect: false, + mode:'index', intersect:false, callbacks: { - label(ctx) { - const unit = ctx.dataset?._saUnit; - const label = ctx.dataset?.label || ''; - const value = ctx.parsed.y; - const formatted = formatValue(value, unit); - return label ? `${label}: ${formatted}` : formatted; + // 박스플롯은 한 줄 요약만 (상세설명 제외) + label(ctx){ + const ds = ctx.dataset || {}; + const unit = ds._saUnit; + const base = ds.label || ''; + const v = ctx.parsed.y; + + if (ds._boxplot && ds._boxIndex) { + const x = ctx.raw?.x instanceof Date ? ctx.raw.x.getTime() : new Date(ctx.raw?.x).getTime(); + const st = ds._boxIndex.get(String(x)); + if (st) { + 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'); + if (mean!=null && lo!=null && hi!=null) { + return `${base}: 평균적으로 ${fmt(lo)} ~ ${fmt(hi)} 정도 (최소: ${fmt(st.min)}, 최대: ${fmt(st.max)})`; + } + } + return `${base}: 평균 ${formatValue(v,'HOURS')}`; + } + + return base ? `${base}: ${formatValue(v,unit)}` : formatValue(v,unit); } } } @@ -103,253 +573,311 @@ }; } - function newLineChart(canvasId, yAxisKey) { - const canvas = document.getElementById(canvasId); - if (!canvas || typeof Chart === 'undefined') return null; - return new Chart(canvas.getContext('2d'), { - type: 'line', - data: { datasets: [] }, - options: createBaseOptions(yAxisKey) + function niceNum(x, round){ + const exp = Math.floor(Math.log10(x)); + const f = x / Math.pow(10,exp); + let nf; + if (round){ nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10; } + else { nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; } + return nf * Math.pow(10,exp); + } + function niceScale(min,max,maxTicks=6,integerOnly=false){ + const range = Math.max(1e-9, max-min); + let step = niceNum(range / Math.max(2,(maxTicks-1)), true); + if (integerOnly) step = Math.max(1, Math.round(step)); + const niceMin = Math.floor(min/step)*step; + const niceMax = Math.ceil(max/step)*step; + return {min:niceMin,max:niceMax,step}; + } + function bounds(chart, axis){ + let min=Infinity, max=-Infinity, used=false; + (chart.data.datasets||[]).forEach(ds=>{ + if (ds.yAxisID!==axis || !Array.isArray(ds.data)) return; + ds.data.forEach(pt=>{ + if (pt && Number.isFinite(pt.y)){ used=true; if (pt.ymax) max=pt.y; } + }); }); + if (!used) return {used:false,min:0,max:1}; + if (min===max){ min-=1; max+=1; } + return {used:true,min,max}; } - function toChartPoints(points, unit) { - if (!Array.isArray(points)) return []; - return points - .filter(p => p && p.date != null && p.value != null) - .map(p => ({ x: p.date, y: normalizeValue(p.value, unit) })); + // 축 자동 스케일 + 우측축 겹침 보정(offset) + function applyAutoYAxisScaling(chart){ + const b = { count: bounds(chart,'count'), ratio: bounds(chart,'ratio'), hours: bounds(chart,'hours') }; + + // count (좌) + if (b.count.used){ + const nice = niceScale(b.count.min, b.count.max, 7, true); + const s = chart.options.scales.count; s.display=true; s.min=nice.min; s.max=nice.max; s.ticks.stepSize=nice.step; s.ticks.precision=0; + } else chart.options.scales.count.display=false; + + // 오른쪽 축 2개(ratio, hours) 동시 사용 시 시각 분리 + const bothRight = b.ratio.used && b.hours.used; + + if (b.ratio.used){ + const s = chart.options.scales.ratio; + const range=Math.max(5,b.ratio.max-b.ratio.min); + const step = range<=30?5:10; + s.display=true; s.position='right'; s.offset = bothRight ? false : false; + s.min=Math.max(0,Math.floor(b.ratio.min/step)*step); + s.max=Math.min(100,Math.ceil(b.ratio.max/step)*step); + s.ticks.stepSize=step; + } else chart.options.scales.ratio.display=false; + + if (b.hours.used){ + const nice = niceScale(b.hours.min, b.hours.max, 6, false); + const s = chart.options.scales.hours; + s.display=true; s.position='right'; s.offset = bothRight ? true : false; + s.min=nice.min; s.max=nice.max; s.ticks.stepSize=nice.step; + } else chart.options.scales.hours.display=false; } - function normalizeValue(num, unit) { - const v = typeof num === 'number' ? num : Number(num); - if (!Number.isFinite(v)) return null; - if (unit === 'RATIO') return v <= 1.000001 ? v * 100 : v; - return v; + function datasetIdFor(metric, compKey){ + return compKey ? `${metric}::${compKey}` : metric; } - function formatValue(v, unit) { - if (!Number.isFinite(v)) return '-'; - switch (unit) { - case 'RATIO': return `${v.toFixed(1)}%`; - case 'HOURS': return `${v.toFixed(2)}시간`; - default: return Math.round(v).toLocaleString(); + // ---------- 핵심: 추가/제거/이동 ---------- + function addMetricGroupToPlot(plotId, metric){ + const p = plots.get(plotId); if (!p) return; + const opt = optionMap.get(metric); if (!opt) return; + + const color = colorForMetric(metric); + const groupSet = p.groups.get(metric) || new Set(); + let changed = false; + + if (metric === METRICS.RESOLUTION_STATS) { + // 박스플롯 데이터 구성 (KST 기준 병합) + const series = seriesMap.get(metric); + const comps = Array.isArray(series?.components) ? series.components : []; + const mapByKey = new Map(comps.map(c => [c.key, c.points || []])); + const get = (k) => mapByKey.get(k) || mapByKey.get(k?.toLowerCase()) || []; + const minPts = get('min_hours'); + const maxPts = get('max_hours'); + const avgPts = get('avg_hours'); + const stdPts = get('stddev_hours'); + + const byTime = new Map(); // key: ms timestamp + const ensure = (d) => { + const t = toDateKST(d); + if (!t) return null; + const k = t.getTime(); + if (!byTime.has(k)) byTime.set(k, { _time: t }); + return byTime.get(k); + }; + + avgPts.forEach(pt => { const e=ensure(pt.date); if (e) e.mean = Number(pt.value); }); + minPts.forEach(pt => { const e=ensure(pt.date); if (e) e.min = Number(pt.value); }); + maxPts.forEach(pt => { const e=ensure(pt.date); if (e) e.max = Number(pt.value); }); + stdPts.forEach(pt => { const e=ensure(pt.date); if (e) e.std = Number(pt.value); }); + + 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); + + const meanPoints = merged.map(v => ({ x: v._time, y: v.mean })); + + const id = datasetIdFor(metric, 'boxplot'); + if (!p.datasets.has(id)) { + const indexMap = new Map(merged.map(v => [String(v._time.getTime()), { min:v.min, max:v.max, std:v.std }])); + const ds = { + _id: id, + label: (opt.label || '동정 해결 시간'), + data: meanPoints, + parsing: { xAxisKey: 'x', yAxisKey: 'y' }, // ✅ 명시 파싱 + borderColor: color, + backgroundColor: color, + tension: 0.25, + pointRadius: 0, + pointHoverRadius: 4, + fill: false, + spanGaps: true, + yAxisID: 'hours', + _saUnit: 'HOURS', + _boxplot: merged, // { _time: Date(KST 00:00), min,max,mean,std } + _boxIndex: indexMap + }; + p.chart.data.datasets.push(ds); + p.datasets.add(id); + groupSet.add(id); + changed = true; + } + } else { + const unit = String(optionMap.get(metric)?.unit || '').toUpperCase(); + const s = seriesMap.get(metric); + const points = toPoints(s, unit); + const id = datasetIdFor(metric); + if (!p.datasets.has(id)) { + const yAxis = axisByUnit(unit); + const ds = { + _id: id, + label: optionMap.get(metric)?.label || metric, + data: points, + parsing: { xAxisKey: 'x', yAxisKey: 'y' }, // ✅ 명시 파싱 + borderColor: color, + backgroundColor: color, + tension: 0.25, + pointRadius: 0, + pointHoverRadius: 4, + fill: false, + spanGaps: true, + yAxisID: yAxis, + _saUnit: unit + }; + p.chart.data.datasets.push(ds); + p.datasets.add(id); + groupSet.add(id); + changed = true; + } } - } - // nice scale per chart - function applyNiceYAxis(chart, axisKey) { - if (!chart) return; - const ds = chart.data.datasets || []; - let min = Infinity, max = -Infinity, used = false; - ds.forEach(d => { - if (d.yAxisID !== axisKey) return; - (d.data || []).forEach(pt => { - if (pt && Number.isFinite(pt.y)) { used = true; min = Math.min(min, pt.y); max = Math.max(max, pt.y); } - }); - }); - if (!used) return; - - if (min === max) { min -= 1; max += 1; } - const nice = niceScale(min, max, axisKey === 'count' ? 7 : 6, axisKey === 'count'); - const s = chart.options.scales[axisKey]; - s.min = nice.min; - s.max = nice.max; - s.ticks.stepSize = nice.step; - if (axisKey === 'count') s.ticks.precision = 0; + if (changed) { + p.groups.set(metric, groupSet); + renderPlotGroupChips(plotId); + applyAutoYAxisScaling(p.chart); + p.chart.update(); + updateEmptyState(plotId); + resizeAllCharts(); + syncAsideActiveStates(); + } } - function niceScale(min, max, maxTicks = 6, integerOnly = false) { - const range = Math.max(1e-9, max - min); - let step = niceNum(range / Math.max(2, (maxTicks - 1)), true); - if (integerOnly) step = Math.max(1, Math.round(step)); - const niceMin = Math.floor(min / step) * step; - const niceMax = Math.ceil(max / step) * step; - return { min: niceMin, max: niceMax, step }; + function removeMetricGroupFromPlot(plotId, metric){ + const p = plots.get(plotId); if (!p) return; + const ids = p.groups.get(metric) || new Set(); + p.chart.data.datasets = (p.chart.data.datasets || []).filter(ds => !ids.has(ds._id)); + ids.forEach(id => p.datasets.delete(id)); + p.groups.delete(metric); + renderPlotGroupChips(plotId); + applyAutoYAxisScaling(p.chart); + p.chart.update(); + updateEmptyState(plotId); + resizeAllCharts(); + syncAsideActiveStates(); } - function niceNum(x, round) { - const exp = Math.floor(Math.log10(x)); - const f = x / Math.pow(10, exp); - let nf; - if (round) { nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10; } - else { nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; } - return nf * Math.pow(10, exp); + + function toggleMetricGroupOnPlot(plotId, metric){ + const p = plots.get(plotId); if (!p) return; + if (p.groups.has(metric)) removeMetricGroupFromPlot(plotId, metric); + else addMetricGroupToPlot(plotId, metric); } - // ====== 3) 시리즈 접근 ====== - function getPoints(metricKey) { - const s = seriesMap.get(metricKey); - if (!s) return []; - const unit = optionMap.get(metricKey)?.unit || 'COUNT'; - return toChartPoints(s.points || [], unit); + function removeMetricGroupFromAllPlots(metric){ + for (const [plotId] of plots) removeMetricGroupFromPlot(plotId, metric); + syncAsideActiveStates(); } - function getResolutionComponents() { - const s = seriesMap.get(METRICS.RESOLUTION_STATS); - const comps = Array.isArray(s?.components) ? s.components : []; - // key → label 매핑 - const labels = componentLabels[METRICS.RESOLUTION_STATS] || {}; - // 필요한 키만 추출 - const byKey = new Map(); - comps.forEach(c => byKey.set(c.key, c.points || [])); - return { - min: { label: labels['min_hours'] || '최소', points: byKey.get('min_hours') || [] }, - avg: { label: labels['avg_hours'] || '평균', points: byKey.get('avg_hours') || [] }, - max: { label: labels['max_hours'] || '최대', points: byKey.get('max_hours') || [] } - // stddev는 시각적 혼잡도 때문에 일단 제외 - }; + function moveGroupToPlot(metric, fromPlotId, toPlotId){ + if (fromPlotId === toPlotId) return; + removeMetricGroupFromPlot(fromPlotId, metric); + addMetricGroupToPlot(toPlotId, metric); + const fromStrip = document.querySelector(`.si-plot-card[data-plot-id="${fromPlotId}"] .si-plot__datasets`); + const toStrip = document.querySelector(`.si-plot-card[data-plot-id="${toPlotId}"] .si-plot__datasets`); + if (fromStrip) markDatasetsStripState(fromStrip); + if (toStrip) markDatasetsStripState(toStrip); } - // ====== 4) 차트 인스턴스 (필요할 때 생성) ====== - const charts = { - total: null, - ratio: null, - idSummary: null, - idTime: null - }; + // 플롯 상단 칩(그룹 단위) 렌더 — 이 칩들만 드래그 이동 허용 + function renderPlotGroupChips(plotId){ + const p = plots.get(plotId); if (!p) return; + const wrap = p.el.querySelector('.si-plot__datasets'); + const hasAny = p.chart.data.datasets.length > 0; - function ensurePrivacyCharts() { - if (!charts.total) charts.total = newLineChart('chart-total-count', 'count'); - if (!charts.ratio) charts.ratio = newLineChart('chart-private-ratio', 'ratio'); - - // 데이터 바인딩 - if (charts.total) { - const unit = optionMap.get(METRICS.TOTAL_COUNT)?.unit || 'COUNT'; - charts.total.data.datasets = [{ - label: optionMap.get(METRICS.TOTAL_COUNT)?.label || '누적 새록 수', - data: getPoints(METRICS.TOTAL_COUNT), - borderColor: '#2563eb', - backgroundColor: '#2563eb', - tension: 0.25, - yAxisID: axisByUnit[unit] || 'count', - _saUnit: unit - }]; - applyNiceYAxis(charts.total, 'count'); - charts.total.update(); - } + wrap.innerHTML = ''; + wrap.classList.add('si-dropzone'); + wrap.setAttribute('data-empty', hasAny ? 'false' : 'true'); - if (charts.ratio) { - const unit = optionMap.get(METRICS.PRIVATE_RATIO)?.unit || 'RATIO'; - charts.ratio.data.datasets = [{ - label: optionMap.get(METRICS.PRIVATE_RATIO)?.label || '비공개 새록 비율', - data: getPoints(METRICS.PRIVATE_RATIO), - borderColor: '#16a34a', - backgroundColor: '#16a34a', - tension: 0.25, - yAxisID: axisByUnit[unit] || 'ratio', - _saUnit: unit - }]; - applyNiceYAxis(charts.ratio, 'ratio'); // 0~100 내에서 step 조정 - charts.ratio.update(); - } - } + if (!hasAny) return; - function ensureIdCharts() { - if (!charts.idSummary) charts.idSummary = newLineChart('chart-id-summary', 'count'); - if (!charts.idTime) charts.idTime = newLineChart('chart-id-resolution-time', 'hours'); - - // 요약(동정 요청/해결) : 두 라인 - if (charts.idSummary) { - const pUnit = optionMap.get(METRICS.PENDING_COUNT)?.unit || 'COUNT'; - const rUnit = optionMap.get(METRICS.RESOLVED_COUNT)?.unit || 'COUNT'; - charts.idSummary.data.datasets = [ - { - label: optionMap.get(METRICS.PENDING_COUNT)?.label || '진행 중인 동정 요청', - data: getPoints(METRICS.PENDING_COUNT), - borderColor: '#dc2626', - backgroundColor: '#dc2626', - tension: 0.25, - yAxisID: axisByUnit[pUnit] || 'count', - _saUnit: pUnit - }, - { - label: optionMap.get(METRICS.RESOLVED_COUNT)?.label || '누적 동정 해결 수', - data: getPoints(METRICS.RESOLVED_COUNT), - borderColor: '#9333ea', - backgroundColor: '#9333ea', - tension: 0.25, - borderDash: [6, 4], - yAxisID: axisByUnit[rUnit] || 'count', - _saUnit: rUnit - } - ]; - applyNiceYAxis(charts.idSummary, 'count'); - charts.idSummary.update(); - } + for (const [groupId, children] of p.groups){ + const opt = optionMap.get(groupId); + const color = colorForMetric(groupId); + const label = opt?.label || groupId; + const empty = [...children].every(id => { + const ds = p.chart.data.datasets.find(d => d._id === id); + return !ds || !Array.isArray(ds.data) || ds.data.length === 0; + }); - // 해결 시간 : 평균(굵게) + 최소/최대(점선) - if (charts.idTime) { - const unit = optionMap.get(METRICS.RESOLUTION_STATS)?.unit || 'HOURS'; - const comps = getResolutionComponents(); - charts.idTime.data.datasets = [ - { - label: `평균`, - data: toChartPoints(comps.avg.points, unit), - borderColor: '#0ea5e9', - backgroundColor: '#0ea5e9', - borderWidth: 3, - tension: 0.25, - yAxisID: axisByUnit[unit] || 'hours', - _saUnit: unit - }, - { - label: `최소`, - data: toChartPoints(comps.min.points, unit), - borderColor: '#14b8a6', - backgroundColor: '#14b8a6', - tension: 0.25, - borderDash: [4, 4], - yAxisID: axisByUnit[unit] || 'hours', - _saUnit: unit - }, - { - label: `최대`, - data: toChartPoints(comps.max.points, unit), - borderColor: '#f97316', - backgroundColor: '#f97316', - tension: 0.25, - borderDash: [4, 4], - yAxisID: axisByUnit[unit] || 'hours', - _saUnit: unit + const chip = document.createElement('div'); + chip.className='si-ds-chip'; + chip.dataset.groupId = groupId; + chip.style.touchAction = 'none'; + chip.setAttribute('draggable','false'); + chip.addEventListener('dragstart', e => e.preventDefault()); + chip.innerHTML = ` + + + ${escapeHtml(label)} + + + `; + + const rm = chip.querySelector('.si-ds-chip__rm'); + rm.addEventListener('click', (ev)=> { + ev.preventDefault(); + ev.stopPropagation(); + removeMetricGroupFromPlot(plotId, groupId); + const strip = chip.closest('.si-plot__datasets'); + if (strip) markDatasetsStripState(strip); + }); + + enablePointerDnD( + chip, + () => ({ type:'datasetGroup', groupId, fromPlotId: plotId }), + (targetStrip, payload, done) => { + const toPlotId = targetStrip.closest('.si-plot-card')?.dataset.plotId || plotId; + moveGroupToPlot(payload.groupId, payload.fromPlotId, toPlotId); + markDatasetsStripState(targetStrip); + done(); } - ]; - applyNiceYAxis(charts.idTime, 'hours'); - charts.idTime.update(); + ); + + wrap.appendChild(chip); } } - // ====== 5) 질문 토글 (동시에 하나만 on) ====== - function showQuestion(key) { - // 버튼 상태 - questionButtons.forEach(btn => { - const isActive = btn.getAttribute('data-question-key') === key; - btn.classList.toggle('is-active', isActive); + // aside 칩 상태 동기화 (활성 플롯 기준) + function syncAsideActiveStates(){ + const active = getActivePlotId(); + const activeGroups = active ? (plots.get(active)?.groups || new Map()) : new Map(); + document.querySelectorAll('.service-insight__metrics .si-chip.si-chip--toggle').forEach(chip=>{ + const metric = chip.dataset.metric; + const on = activeGroups.has(metric); + chip.classList.toggle('is-active', !!on); + chip.setAttribute('aria-pressed', on ? 'true' : 'false'); }); - - // 뷰 전환 - viewPrivacy.classList.toggle('d-none', key !== 'privacy'); - viewIdRequests.classList.toggle('d-none', key !== 'idRequests'); - - // 해당 그룹 차트 lazy 생성/업데이트 - if (key === 'privacy') ensurePrivacyCharts(); - if (key === 'idRequests') ensureIdCharts(); - - // 빈 상태 처리 - const anyData = - (charts.total?.data.datasets?.[0]?.data?.length || 0) + - (charts.ratio?.data.datasets?.[0]?.data?.length || 0) + - (charts.idSummary?.data.datasets?.reduce((a,d)=>a+(d.data?.length||0),0) || 0) + - (charts.idTime?.data.datasets?.reduce((a,d)=>a+(d.data?.length||0),0) || 0); - emptyState?.classList.toggle('d-none', anyData > 0); } - questionButtons.forEach(btn => { - btn.addEventListener('click', () => { - const key = btn.getAttribute('data-question-key'); - if (!key) return; - showQuestion(key); - }); + // 컨트롤 + if (addPlotBtn) addPlotBtn.addEventListener('click', ()=> createPlot()); + if (clearAllBtn) clearAllBtn.addEventListener('click', ()=>{ + for (const [plotId, p] of plots){ + p.chart.data.datasets = []; + p.datasets.clear(); + p.groups.clear(); + p.chart.update(); + renderPlotGroupChips(plotId); + updateEmptyState(plotId); + } + resizeAllCharts(); + syncAsideActiveStates(); }); - // 초기에는 아무것도 선택 안 함(기획 의도) - // 필요하면 여기서 기본으로 'privacy' 또는 'idRequests'를 선택하도록 바꿔도 됨. + // 초기 렌더 + renderGroups(); + createPlot(); + + // 툴팁 초기화 + window.addEventListener('load', () => initTooltipsIn(document)); + + // 전역: 드래그 중 기본 drag/select/스크롤 억제 + document.addEventListener('dragstart', (e) => { + if (document.body.classList.contains('is-dragging')) e.preventDefault(); + }, true); + document.addEventListener('selectstart', (e) => { + if (document.body.classList.contains('is-dragging')) e.preventDefault(); + }, true); + window.addEventListener('touchmove', (e) => { + if (document.body.classList.contains('is-dragging')) e.preventDefault(); + }, { passive: false }); })(); diff --git a/src/main/resources/templates/layout/base.html b/src/main/resources/templates/layout/base.html index 65fa7af..dd296dc 100644 --- a/src/main/resources/templates/layout/base.html +++ b/src/main/resources/templates/layout/base.html @@ -9,9 +9,6 @@
-
-

페이지 제목

-
diff --git a/src/main/resources/templates/service-insight/index.html b/src/main/resources/templates/service-insight/index.html index ecffc52..cf4dd9f 100644 --- a/src/main/resources/templates/service-insight/index.html +++ b/src/main/resources/templates/service-insight/index.html @@ -2,117 +2,49 @@ + + + +
- -
- + - + From 13fa876f60084ce5f24a12f8619c798201ce2609 Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Thu, 6 Nov 2025 17:10:12 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20StatMetric=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(=EB=8F=99=EC=A0=95=20=EC=9D=98=EA=B2=AC?= =?UTF-8?q?=20=EC=B1=84=ED=83=9D=20=ED=9A=9F=EC=88=98=20=EC=84=A4=EB=AA=85?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/apu/saerok_admin/infra/stat/StatMetric.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9bd9864..d866a08 100644 --- a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java +++ b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java @@ -31,7 +31,7 @@ public enum StatMetric { ), BIRD_ID_RESOLVED_COUNT( "동정 의견 채택 횟수", - "이 날까지 동정 의견이 몇 번 채택됐는지 횟수", + "이 날 동정 의견이 몇 번 채택됐는지 횟수", MetricUnit.COUNT, false, Map.of(), From 77300ca9bce3a884a1e4baa77f66adb0abc5b0c7 Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Thu, 6 Nov 2025 17:38:32 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20=EC=8B=9C=EA=B0=84=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EA=B0=80=EB=B3=80=EC=A0=81=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/static/js/service-insight.js | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/main/resources/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js index 21f79da..be3502c 100644 --- a/src/main/resources/static/js/service-insight.js +++ b/src/main/resources/static/js/service-insight.js @@ -502,12 +502,32 @@ }) .filter(Boolean); } + function formatHoursAdaptive(hours) { + if (!Number.isFinite(hours)) return '-'; + const H_PER_DAY = 24; + const D_PER_MONTH = 30; + + 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 sig = Math.abs(months) >= 10 ? 0 : 1; + return `${months.toFixed(sig)}개월`; + } + if (abs >= H_PER_DAY) { + const days = hours / H_PER_DAY; + const sig = Math.abs(days) >= 10 ? 0 : 1; + return `${days.toFixed(sig)}일`; + } + return `${hours.toFixed(2)}시간`; + } + function formatValue(v, unit){ if (!Number.isFinite(v)) return '-'; if (unit==='RATIO') return `${v.toFixed(1)}%`; - if (unit==='HOURS') return `${v.toFixed(2)}시간`; + if (unit==='HOURS') return formatHoursAdaptive(v); return Math.round(v).toLocaleString(); } + function normalizeValue(num, unit){ const v = typeof num === 'string' ? Number(num) : Number(num); if (!Number.isFinite(v)) return null; @@ -550,15 +570,16 @@ const base = ds.label || ''; const v = ctx.parsed.y; - if (ds._boxplot && ds._boxIndex) { + if (ds._boxIndex) { const x = ctx.raw?.x instanceof Date ? ctx.raw.x.getTime() : new Date(ctx.raw?.x).getTime(); const st = ds._boxIndex.get(String(x)); if (st) { 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)})`; } } @@ -684,7 +705,7 @@ _id: id, label: (opt.label || '동정 해결 시간'), data: meanPoints, - parsing: { xAxisKey: 'x', yAxisKey: 'y' }, // ✅ 명시 파싱 + parsing: { xAxisKey: 'x', yAxisKey: 'y' }, borderColor: color, backgroundColor: color, tension: 0.25, @@ -694,9 +715,9 @@ spanGaps: true, yAxisID: 'hours', _saUnit: 'HOURS', - _boxplot: merged, // { _time: Date(KST 00:00), min,max,mean,std } - _boxIndex: indexMap + _boxIndex: indexMap // ← 툴팁 계산에만 사용 }; + p.chart.data.datasets.push(ds); p.datasets.add(id); groupSet.add(id); From 30f5c23733aa761b575b13d7dc7e56a4fa9e7367 Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Thu, 6 Nov 2025 19:19:57 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EC=9C=A0=EC=A0=80=20=EC=A7=80?= =?UTF-8?q?=ED=91=9C(=EA=B0=80=EC=9E=85=EC=9E=90,=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=93=B1)=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../saerok_admin/infra/stat/StatMetric.java | 79 +++++++++++++------ .../resources/static/js/service-insight.js | 17 ++++ 2 files changed, 73 insertions(+), 23 deletions(-) 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 d866a08..40d3e2d 100644 --- a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java +++ b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java @@ -44,6 +44,56 @@ public enum StatMetric { true, orderedComponentLabels(), false + ), + + // ===== 유저 지표 ===== + USER_COMPLETED_TOTAL( + "누적 가입자 수", + "현재 가입된 총 사용자 수", + MetricUnit.COUNT, + false, + Map.of(), + true + ), + USER_SIGNUP_DAILY( + "일일 가입자 수", + "이 날 신규 가입한 사용자 수", + MetricUnit.COUNT, + false, + Map.of(), + false + ), + USER_WITHDRAWAL_DAILY( + "일일 탈퇴자 수", + "이 날 탈퇴한 사용자 수", + MetricUnit.COUNT, + false, + Map.of(), + false + ), + USER_DAU( + "DAU", + "일일 활성 사용자 수(그 날 서비스에 몇 명이 접속했는지 기준)", + MetricUnit.COUNT, + false, + Map.of(), + true + ), + USER_WAU( + "WAU", + "주간 활성 사용자 수(최근 7일)", + MetricUnit.COUNT, + false, + Map.of(), + false + ), + USER_MAU( + "MAU", + "월간 활성 사용자 수(최근 30일)", + MetricUnit.COUNT, + false, + Map.of(), + false ); private final String label; @@ -69,29 +119,12 @@ public enum StatMetric { this.defaultActive = defaultActive; } - public String label() { - return label; - } - - public String description() { - return description; - } - - public MetricUnit unit() { - return unit; - } - - public boolean multiSeries() { - return multiSeries; - } - - public Map componentLabels() { - return componentLabels; - } - - public boolean defaultActive() { - return defaultActive; - } + public String label() { return label; } + public String description() { return description; } + public MetricUnit unit() { return unit; } + public boolean multiSeries() { return multiSeries; } + public Map componentLabels() { return componentLabels; } + public boolean defaultActive() { return defaultActive; } private static Map orderedComponentLabels() { Map labels = new LinkedHashMap<>(); diff --git a/src/main/resources/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js index be3502c..11eb44b 100644 --- a/src/main/resources/static/js/service-insight.js +++ b/src/main/resources/static/js/service-insight.js @@ -22,8 +22,17 @@ 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 + + // ===== 유저 지표 ===== + USER_COMPLETED_TOTAL: 'USER_COMPLETED_TOTAL', + USER_SIGNUP_DAILY: 'USER_SIGNUP_DAILY', + USER_WITHDRAWAL_DAILY:'USER_WITHDRAWAL_DAILY', + USER_DAU: 'USER_DAU', + USER_WAU: 'USER_WAU', + USER_MAU: 'USER_MAU', }; + // 컬러 팔레트 const PALETTE = ['#2563eb','#16a34a','#dc2626','#f97316','#9333ea','#0ea5e9','#059669','#ea580c','#3b82f6','#14b8a6']; const colorCache = new Map(); @@ -231,6 +240,14 @@ 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] }, ]; const known = new Set(GROUPS.flatMap(g => g.metrics));