From 4fedfe6b55df85044f900ec6b276b7fc1a466e0e Mon Sep 17 00:00:00 2001 From: soonduck-dreams Date: Fri, 7 Nov 2025 14:17:03 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20Service=20Insight=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=ED=98=B8=ED=99=98=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/ServiceInsightController.java | 23 +- .../web/view/ServiceInsightViewModel.java | 35 +- .../static/css/service-insight.mobile.css | 41 ++ .../static/css/service-insight.target.css | 111 ++++++ .../static/js/service-insight.mobile.js | 358 ++++++++++++++++++ .../static/js/service-insight.target.js | 244 ++++++++++++ .../templates/fragments/_navbar.html | 4 +- .../templates/service-insight/index.html | 57 ++- 8 files changed, 845 insertions(+), 28 deletions(-) create mode 100644 src/main/resources/static/css/service-insight.mobile.css create mode 100644 src/main/resources/static/css/service-insight.target.css create mode 100644 src/main/resources/static/js/service-insight.mobile.js create mode 100644 src/main/resources/static/js/service-insight.target.js diff --git a/src/main/java/apu/saerok_admin/web/ServiceInsightController.java b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java index 7614aac..5e7ba39 100644 --- a/src/main/java/apu/saerok_admin/web/ServiceInsightController.java +++ b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java @@ -6,6 +6,8 @@ import apu.saerok_admin.web.view.ToastMessage; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.util.ArrayList; import java.util.List; import org.slf4j.Logger; @@ -27,7 +29,10 @@ public class ServiceInsightController { public ServiceInsightController(ServiceInsightService serviceInsightService, ObjectMapper objectMapper) { this.serviceInsightService = serviceInsightService; - this.objectMapper = objectMapper; + // ObjectMapper 설정 강화 + this.objectMapper = objectMapper.copy() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); } @GetMapping("/service-insight") @@ -43,6 +48,8 @@ public String serviceInsight(Model model) { ServiceInsightViewModel viewModel; try { viewModel = serviceInsightService.loadViewModel(); + log.info("Successfully loaded service insight view model with {} metrics", + viewModel.metricOptions().size()); } catch (RestClientResponseException exception) { log.warn( "Failed to load service insight stats. status={}, body={}", @@ -59,7 +66,11 @@ public String serviceInsight(Model model) { } model.addAttribute("serviceInsight", viewModel); - model.addAttribute("chartDataJson", toJson(viewModel)); + String chartDataJson = toJson(viewModel); + model.addAttribute("chartDataJson", chartDataJson); + + log.debug("Chart data JSON length: {}", chartDataJson.length()); + return "service-insight/index"; } @@ -92,10 +103,12 @@ private void attachErrorToast(Model model) { private String toJson(ServiceInsightViewModel viewModel) { try { - return objectMapper.writeValueAsString(viewModel); + String json = objectMapper.writeValueAsString(viewModel); + log.debug("Serialized view model to JSON successfully"); + return json; } catch (JsonProcessingException exception) { - log.warn("Failed to serialize service insight payload.", exception); + log.error("Failed to serialize service insight payload.", exception); return "{\"metricOptions\":[],\"series\":[],\"componentLabels\":{}}"; } } -} +} \ No newline at end of file diff --git a/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java b/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java index 324d87c..c2d82c2 100644 --- a/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java +++ b/src/main/java/apu/saerok_admin/web/view/ServiceInsightViewModel.java @@ -1,42 +1,43 @@ package apu.saerok_admin.web.view; import apu.saerok_admin.infra.stat.StatMetric.MetricUnit; +import com.fasterxml.jackson.annotation.JsonProperty; import java.time.LocalDate; import java.util.List; import java.util.Map; public record ServiceInsightViewModel( - List metricOptions, - List series, - Map> componentLabels + @JsonProperty("metricOptions") List metricOptions, + @JsonProperty("series") List series, + @JsonProperty("componentLabels") Map> componentLabels ) { public record MetricOption( - String metric, - String label, - String description, - MetricUnit unit, - boolean multiSeries, - boolean defaultActive + @JsonProperty("metric") String metric, + @JsonProperty("label") String label, + @JsonProperty("description") String description, + @JsonProperty("unit") MetricUnit unit, + @JsonProperty("multiSeries") boolean multiSeries, + @JsonProperty("defaultActive") boolean defaultActive ) { } public record Series( - String metric, - List points, - List components + @JsonProperty("metric") String metric, + @JsonProperty("points") List points, + @JsonProperty("components") List components ) { } public record Point( - LocalDate date, - double value + @JsonProperty("date") LocalDate date, + @JsonProperty("value") double value ) { } public record ComponentSeries( - String key, - List points + @JsonProperty("key") String key, + @JsonProperty("points") List points ) { } -} +} \ No newline at end of file diff --git a/src/main/resources/static/css/service-insight.mobile.css b/src/main/resources/static/css/service-insight.mobile.css new file mode 100644 index 0000000..0db8571 --- /dev/null +++ b/src/main/resources/static/css/service-insight.mobile.css @@ -0,0 +1,41 @@ +/* ===== Service Insight: 모바일 전용 오버라이드 ===== + - 사이드바 숨김, DnD 힌트 제거, 차트 컴팩트, FAB(모바일만) 등 +*/ + +.service-insight__container { height: auto !important; overflow: visible !important; } +.service-insight__metrics, +.service-insight__chart { height: auto !important; min-height: 0 !important; overflow: visible !important; } + +/* 사이드바는 모바일에서 숨김(바텀시트 이용) */ +.service-insight__metrics { display: none !important; } + +/* DnD affordance 제거 */ +.service-insight .si-plot__datasets, +.service-insight .si-dropzone { display: none !important; } + +/* 차트 높이 컴팩트 */ +.si-plots .si-plot__surface { height: 280px !important; } + +/* FAB: 모바일에서만 표출 */ +#si-fab { + display: inline-flex !important; + position: fixed; right: max(16px, env(safe-area-inset-right)); + bottom: calc(max(16px, env(safe-area-inset-bottom)) + 8px); + width: 56px; height: 56px; z-index: 1055; + align-items: center; justify-content: center; + border-radius: 50%; padding: 0; line-height: 1; +} + +/* 바텀시트 */ +.offcanvas.offcanvas-bottom#siDataPicker { + height: 75vh; border-top-left-radius: 1rem; border-top-right-radius: 1rem; + box-shadow: var(--shadow-overlay); +} +.offcanvas.offcanvas-bottom#siDataPicker .offcanvas-header { padding: 0.75rem 1rem; } +.offcanvas.offcanvas-bottom#siDataPicker .offcanvas-body { padding: 0.5rem 1rem 1rem; overflow: auto; } + +/* 툴바 줄바꿈 */ +.si-plots__toolbar .si-actions { display: flex; gap: .5rem; flex-wrap: wrap; } + +/* 안전 여백 */ +.service-insight { padding-bottom: max(72px, env(safe-area-inset-bottom)); } diff --git a/src/main/resources/static/css/service-insight.target.css b/src/main/resources/static/css/service-insight.target.css new file mode 100644 index 0000000..718abe0 --- /dev/null +++ b/src/main/resources/static/css/service-insight.target.css @@ -0,0 +1,111 @@ +/* ===== resources/static/css/service-insight.target.css ===== */ +/* ===== 전역(PC/모바일 공통) ===== */ + +/* FAB는 PC 기본 숨김, 모바일 전용 CSS에서만 표출 */ +#si-fab { display: none !important; } + +/* "플롯 n 데이터 선택" – 인라인 타이틀 */ +.si-targettitle { + display: inline-flex; + align-items: center; + gap: .55rem; + font-size: 1rem; + font-weight: 800; + letter-spacing: -0.2px; +} +.si-targettitle__prefix, +.si-targettitle__suffix { font-weight: 800; } + +/* 숫자 Select: 클릭감 강화 */ +.si-targettitle__select { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + min-width: 4.5rem; + width: auto; + border-radius: 999px; + padding-inline: 1.25rem 2rem; /* 오른쪽 캐럿 공간 */ + padding-block: .35rem; + line-height: 1.2; + border: 1px solid var(--border-strong, #c4cbd3); + background: + linear-gradient(to bottom, rgba(0,0,0,0.02), rgba(0,0,0,0)) padding-box, + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16'%3E%3Cpath fill='%23666' d='M3.2 6l4.8 5L12.8 6z'/%3E%3C/svg%3E") + no-repeat right .6rem center/12px 12px; + box-shadow: 0 1px 0 rgba(0,0,0,.02); + transition: box-shadow .15s ease, transform .04s ease, border-color .15s ease; + font-weight: 700; +} +.si-targettitle__select:hover { + border-color: var(--accent, #0d6efd); + box-shadow: 0 0 0 .2rem rgba(13,110,253,.12); +} +.si-targettitle__select:active { transform: translateY(1px); } +.si-targettitle__select:focus { + outline: none; + border-color: var(--accent, #0d6efd); + box-shadow: 0 0 0 .24rem rgba(13,110,253,.16); +} + +/* 데이터 패널 레이아웃 */ +.si-data-panel { display: flex; flex-direction: column; gap: .75rem; } + +/* ========== ✅ 체크 인디케이터: "오른쪽 단일"로 통일 ========== */ +/* (1) 좌측 체크를 만들던 예전 커스텀 규칙들 강제 무력화 */ +.si-data-panel .si-chip::before, +.si-data-panel [data-si-chip]::before, +.si-data-panel .si-chip--toggle::before { content: none !important; } + +/* (2) 우측 ::after 하나만 사용 (PC/모바일 공통) */ +.si-data-panel .si-chip, +.si-data-panel [data-si-chip], +.si-data-panel .si-chip--toggle { + position: relative; + padding-right: 28px; /* 오른쪽 체크 공간 확보 */ +} + +.si-data-panel .si-chip::after, +.si-data-panel [data-si-chip]::after, +.si-data-panel .si-chip--toggle::after { + content: ""; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 16px; height: 16px; + border-radius: 999px; + border: 1.5px solid var(--border-subtle, #cfd4d9); + background: var(--surface-primary, #fff); + pointer-events: none; /* 클릭 간섭 방지 */ + transition: background-color .12s ease, border-color .12s ease, box-shadow .12s ease; +} + +/* (3) 활성 조건: .is-active 또는 [data-checked="true"] 중 하나라도 true면 체크 표시 */ +.si-data-panel .si-chip.is-active::after, +.si-data-panel [data-si-chip].is-active::after, +.si-data-panel .si-chip--toggle.is-active::after, +.si-data-panel .si-chip[data-checked="true"]::after, +.si-data-panel [data-si-chip][data-checked="true"]::after, +.si-data-panel .si-chip--toggle[data-checked="true"]::after { + content: "✓"; + font-weight: 700; + font-size: 12px; + text-align: center; + line-height: 16px; + color: var(--accent-contrast, #fff); + background: var(--accent, #0d6efd); + border-color: var(--accent, #0d6efd); + box-shadow: 0 0 0 2px rgba(13,110,253,.15); +} + +/* 키보드 포커스 접근성 */ +.si-chip.si-chip--toggle:focus-visible { + outline: none; + box-shadow: 0 0 0 .2rem rgba(59,130,246,.20); + border-color: rgba(59,130,246,.35); +} + +/* 모바일 폰트 톤 조정 */ +@media (max-width: 991.98px) { + .si-targettitle { font-size: 1.05rem; } +} \ No newline at end of file diff --git a/src/main/resources/static/js/service-insight.mobile.js b/src/main/resources/static/js/service-insight.mobile.js new file mode 100644 index 0000000..93a8710 --- /dev/null +++ b/src/main/resources/static/js/service-insight.mobile.js @@ -0,0 +1,358 @@ +// ===== resources/static/js/service-insight.mobile.js ===== +// 모바일 전용 보조 스크립트: 데이터 패널(#si-data-panel) + 타이틀(#si-target-title) 이동 +(function () { + const MOBILE_MAX = 991.98; + + function q(id){ return document.getElementById(id); } + + function relocate() { + const panel = q('si-data-panel'); + const desktopPanelAnchor = q('si-groups-desktop-anchor'); + const mobilePanelHost = q('si-data-panel-mobile-host'); + + const title = q('si-target-title'); + const desktopTitleAnchor = q('si-target-title-desktop-anchor'); + const mobileTitleHost = q('si-target-title-mobile-host'); + + if (!panel || !desktopPanelAnchor || !mobilePanelHost) return; + if (!title || !desktopTitleAnchor || !mobileTitleHost) return; + + const isMobile = window.innerWidth <= MOBILE_MAX; + + // 패널 이동 + const panelParent = panel.parentElement; + if (isMobile && panelParent !== mobilePanelHost) { + mobilePanelHost.appendChild(panel); + } else if (!isMobile && panelParent !== desktopPanelAnchor) { + desktopPanelAnchor.appendChild(panel); + } + + // 타이틀 이동 (단일 표시 지점 보장) + const titleParent = title.parentElement; + if (isMobile && titleParent !== mobileTitleHost) { + mobileTitleHost.prepend(title); + } else if (!isMobile && titleParent !== desktopTitleAnchor) { + desktopTitleAnchor.prepend(title); + } + } + + // FAB 가시성 제어: 바텀시트 열릴 때 숨기고, 닫히면 복원 + function setupFabVisibility(){ + const fab = q('si-fab'); // FAB 버튼 + const sheet = q('siDataPicker'); // 데이터 선택 바텀시트(Offcanvas) + if (!fab || !sheet) return; + + const hideFab = () => { + // 모바일 CSS가 #si-fab { display: inline-flex !important; }이므로 + // inline + !important로 안전하게 가린다. + try { + fab.style.setProperty('display', 'none', 'important'); + } catch { + fab.style.display = 'none'; + } + }; + const showFab = () => { + fab.style.removeProperty('display'); // CSS 원래 규칙으로 복귀 + }; + + sheet.addEventListener('show.bs.offcanvas', hideFab); + sheet.addEventListener('hidden.bs.offcanvas', showFab); + + // 새로고침 등으로 시트가 이미 열려있는 경우 대비 + if (sheet.classList.contains('show')) hideFab(); + } + + // helper: 실제 화면에 보이는지 판단 (fixed 요소 대응) + function isElementShown(el){ + if (!el) return false; + const cs = window.getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false; + // fixed 요소는 offsetParent가 null일 수 있으므로 rect 기반으로 판단 + return el.getClientRects().length > 0; + } + + // 최초 진입 툴팁: "보고 싶은 데이터를 선택하세요" + function setupFabFirstRunTooltip(){ + const fab = q('si-fab'); + const sheet = q('siDataPicker'); + // Bootstrap 전역 객체가 없으면 조용히 패스 (필수는 아님) + if (!fab || !window.bootstrap || !window.bootstrap.Tooltip) return; + + const KEY = 'si.fab.tooltip.dismissed'; + const isMobile = window.innerWidth <= MOBILE_MAX; + + // 세션 저장소로만 체크: 이 세션에서 한 번 닫으면 다시 안 뜨지만, 새 세션에서는 다시 뜸 + let dismissed = false; + try { + dismissed = (sessionStorage.getItem(KEY) === '1'); + } catch (_) { + // sessionStorage 접근 불가 환경에서는 안내를 계속 띄우도록 false 유지 + } + + if (!isMobile || dismissed) return; + + // 혹시 기존 인스턴스가 있다면 정리 + const prev = window.bootstrap.Tooltip.getInstance(fab); + if (prev) { try { prev.dispose(); } catch(_){} } + + const tip = new window.bootstrap.Tooltip(fab, { + trigger: 'manual', + placement: 'left', // FAB 위치 특성상 좌측이 자연스러움 + container: 'body', + customClass: 'tooltip-bubble', + title: '보고 싶은 데이터를 선택하세요' + }); + + const maybeShow = () => { + // 시트가 열려 있으면 미표시 + if (sheet && sheet.classList.contains('show')) return; + if (!isElementShown(fab)) return; + try { tip.show(); } catch(_) {} + }; + + // 사용자 상호작용 시: 이 세션에서만 영구 비표시 + const onClick = () => { + try { tip.hide(); } catch(_) {} + try { sessionStorage.setItem(KEY, '1'); } catch(_) {} + fab.removeEventListener('click', onClick); + window.removeEventListener('resize', onResizeCheck); + }; + + // 레이아웃 안정 후 노출 + setTimeout(maybeShow, 500); + + // 바텀시트가 열릴 때는 숨기고, 닫히면(아직 미해제라면) 다시 한 번 보여줌 + if (sheet) { + sheet.addEventListener('show.bs.offcanvas', () => { try { tip.hide(); } catch(_) {} }); + sheet.addEventListener('hidden.bs.offcanvas', () => { + // 세션에서만 체크 + let stillDismissed = false; + try { stillDismissed = (sessionStorage.getItem(KEY) === '1'); } catch(_) {} + if (!stillDismissed) setTimeout(maybeShow, 250); + }); + } + + // 리사이즈 시 모바일 유지되는 동안만 안내 (데스크탑 전환 시 숨김) + const onResizeCheck = () => { + if (window.innerWidth > MOBILE_MAX) { try { tip.hide(); } catch(_) {} } + }; + + fab.addEventListener('click', onClick, { once: true }); + window.addEventListener('resize', onResizeCheck); + } + + // === 그룹 헤더 테마 색 + 기본 접힘 + 드롭다운 인디케이터(왼쪽) === + function injectGroupThemeCss(){ + if (document.getElementById('si-group-theme-style')) return; + const style = document.createElement('style'); + style.id = 'si-group-theme-style'; + style.textContent = ` +/* 공통 스코프: 데스크탑(.service-insight) + 모바일 바텀시트(#siDataPicker) */ +:is(.service-insight,#siDataPicker) details.si-group { + border-radius: 10px; + overflow: hidden; +} +:is(.service-insight,#siDataPicker) .si-group__summary{ + position: relative; + display: flex; + align-items: center; + gap: .5rem; + padding: .75rem .875rem .75rem 2rem; /* 왼쪽 chevron 공간 확보 */ + background: linear-gradient(90deg, rgba(var(--group-accent-rgb), .10), rgba(var(--group-accent-rgb), 0) 70%); + border-left: 6px solid rgba(var(--group-accent-rgb), .90); + cursor: pointer; + user-select: none; + transition: background-color .18s ease, box-shadow .18s ease; +} +:is(.service-insight,#siDataPicker) .si-group[open] .si-group__summary{ + background: linear-gradient(90deg, rgba(var(--group-accent-rgb), .14), rgba(var(--group-accent-rgb), .03) 70%); +} +:is(.service-insight,#siDataPicker) .si-group__summary:hover{ + box-shadow: inset 0 0 0 999px rgba(var(--group-accent-rgb), .03); +} +/* 드롭다운 꺾쇠(왼쪽) */ +:is(.service-insight,#siDataPicker) .si-group__summary::before{ + content: ""; + position: absolute; + left: .625rem; + top: 50%; + width: .6rem; + height: .6rem; + margin-top: -.3rem; + border-right: 2px solid rgba(var(--group-accent-rgb), .80); + border-bottom: 2px solid rgba(var(--group-accent-rgb), .80); + transform: translateY(-50%) rotate(-45deg); /* 닫힘: ∨ 느낌 */ + transition: transform .16s ease, border-color .16s ease, opacity .16s ease; + opacity: .95; +} +:is(.service-insight,#siDataPicker) .si-group[open] .si-group__summary::before{ + transform: translateY(-50%) rotate(45deg); /* 펼침: ∧ 느낌 */ + opacity: 1; +} +/* 액션 버튼 hover에 포커스 색상 */ +:is(.service-insight,#siDataPicker) .si-group__btn:hover{ + color: rgb(var(--group-accent-rgb)) !important; + border-color: rgba(var(--group-accent-rgb), .6) !important; +} +/* 그룹별 팔레트: hue 대비를 키워 확실히 구분 (collection=블루, user=그린, id=레드, others=퍼플) */ +:is(.service-insight,#siDataPicker) .si-group { --group-accent-rgb: 59,130,246; } /* 기본 파랑 */ +:is(.service-insight,#siDataPicker) .si-group--collection { --group-accent-rgb: 37,99,235; } /* 새록: blue-600 */ +:is(.service-insight,#siDataPicker) .si-group--user { --group-accent-rgb: 5,150,105; } /* 유저: emerald-600 */ +:is(.service-insight,#siDataPicker) .si-group--id { --group-accent-rgb: 220,38,38; } /* 동정 요청: red-600 */ +:is(.service-insight,#siDataPicker) .si-group--others { --group-accent-rgb: 139,92,246; } /* 기타: violet-500 */ + +/* [+ 전체] [- 전체] 버튼 미관 개선 */ +:is(.service-insight,#siDataPicker) .si-bulk-btn { + --btn-bg: rgba(0,0,0,.02); + --btn-bd: rgba(0,0,0,.12); + --btn-bg-h: rgba(0,0,0,.04); + --btn-bd-h: rgba(0,0,0,.28); + display: inline-flex; + align-items: center; + gap: .35rem; + padding: .25rem .6rem; + font-size: .85rem; + line-height: 1.1; + border-radius: 999px; + border: 1px solid var(--btn-bd); + background: var(--btn-bg); + text-decoration: none !important; +} +:is(.service-insight,#siDataPicker) .si-bulk-btn:hover{ + background: var(--btn-bg-h); + border-color: var(--btn-bd-h); +} +:is(.service-insight,#siDataPicker) .si-bulk-toolbar{ + display: flex; + gap: .5rem; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + `.trim(); + document.head.appendChild(style); + } + + function setupGroupThemingAndCollapse(){ + const containers = [ + document.getElementById('si-groups'), + document.getElementById('siDataPicker') + ].filter(Boolean); + + const applyTo = (root) => { + const groups = Array.from(root.querySelectorAll('details.si-group')); + if (!groups.length) return false; + + groups.forEach((details) => { + // 1) 기본 접힘 + details.open = false; + + // 2) 이름 -> 키 매핑 후 테마 클래스 부여 (새록 => collection) + const name = details.querySelector('.si-group__title')?.textContent?.trim() || ''; + let key = 'others'; + if (name.includes('새록')) key = 'collection'; + else if (name.includes('유저')) key = 'user'; + else if (name.includes('동정')) key = 'id'; + + details.classList.remove('si-group--privacy','si-group--collection','si-group--user','si-group--id','si-group--others'); + details.classList.add(`si-group--${key}`); + + // 3) summary 기본 클래스 보강 (없다면 붙여 UI 일관화) + const summary = details.querySelector('summary'); + if (summary && !summary.classList.contains('si-group__summary')) { + summary.classList.add('si-group__summary'); + } + }); + return true; + }; + + // 즉시 적용 시도 + let foundAny = false; + containers.forEach(root => { if (applyTo(root)) foundAny = true; }); + + // 렌더가 늦는 경우를 대비해 컨테이너별로 감시 + if (!foundAny) { + containers.forEach(root => { + const mo = new MutationObserver(() => { + if (applyTo(root)) mo.disconnect(); + }); + mo.observe(root, { childList: true, subtree: true }); + }); + } + } + + // [+ 전체] / [- 전체] 컨트롤을 세련된 pill 버튼으로 치장 (기존 클릭 동작 유지) + function setupBulkControlStyling(){ + const SCOPE_SEL = '#si-groups, #siDataPicker'; + const enhance = (root) => { + const nodes = Array.from(root.querySelectorAll('button, a')); + if (!nodes.length) return; + + const isPlus = (t) => /\[\+\s*전체\]/.test(t) || /모두\s*펼치기/.test(t); + const isMinus = (t) => /\[-\s*전체\]/.test(t) || /모두\s*접기/.test(t); + + let plusEl = null, minusEl = null; + + nodes.forEach(el => { + if (el.dataset.siEnhanced) return; + const text = (el.textContent || '').trim(); + + if (isPlus(text)) { + el.classList.add('si-bulk-btn'); + el.setAttribute('data-si-enhanced','1'); + el.innerHTML = '모두 펼치기'; + if (el.tagName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type','button'); + plusEl = el; + } else if (isMinus(text)) { + el.classList.add('si-bulk-btn'); + el.setAttribute('data-si-enhanced','1'); + el.innerHTML = '모두 접기'; + if (el.tagName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type','button'); + minusEl = el; + } + }); + + // 두 버튼의 부모에 툴바 정렬 보강 + const host = plusEl?.parentElement || minusEl?.parentElement; + if (host && !host.classList.contains('si-bulk-toolbar')) { + host.classList.add('si-bulk-toolbar'); + } + }; + + document.querySelectorAll(SCOPE_SEL).forEach(enhance); + + // 동적 렌더 대응 + const roots = document.querySelectorAll(SCOPE_SEL); + roots.forEach(root => { + const mo = new MutationObserver(() => enhance(root)); + mo.observe(root, { childList: true, subtree: true }); + }); + } + + // 초기/리사이즈 + let t = null; + function onResize(){ + clearTimeout(t); + t = setTimeout(relocate, 120); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + relocate(); + setupFabVisibility(); + setupFabFirstRunTooltip(); + injectGroupThemeCss(); + setupGroupThemingAndCollapse(); + setupBulkControlStyling(); + window.addEventListener('resize', onResize); + }); + } else { + relocate(); + setupFabVisibility(); + setupFabFirstRunTooltip(); + injectGroupThemeCss(); + setupGroupThemingAndCollapse(); + setupBulkControlStyling(); + window.addEventListener('resize', onResize); + } +})(); diff --git a/src/main/resources/static/js/service-insight.target.js b/src/main/resources/static/js/service-insight.target.js new file mode 100644 index 0000000..94a496f --- /dev/null +++ b/src/main/resources/static/js/service-insight.target.js @@ -0,0 +1,244 @@ +// ===== resources/static/js/service-insight.target.js ===== +(function () { + const grid = document.getElementById('si-plot-grid'); + const select = document.getElementById('si-target-select'); + const groups = document.getElementById('si-groups'); + const offcanvasEl = document.getElementById('siDataPicker'); + if (!grid || !select || !groups) return; + + /* ---------- Helpers ---------- */ + function getActivePlotCard() { + return grid.querySelector('.si-plot-card.si-plot--active'); + } + + function getActivePlotIdFromDOM() { + const active = getActivePlotCard(); + return active ? active.getAttribute('data-plot-id') : null; + } + + function listPlots() { + return Array.from(grid.querySelectorAll('.si-plot-card')).map((card, idx) => { + const id = card.getAttribute('data-plot-id') || String(idx + 1); + const titleEl = card.querySelector('.si-plot__title'); + const title = (titleEl?.textContent?.trim()) || `플롯 ${idx + 1}`; + return { id, title, index: idx + 1 }; + }); + } + + function ensureActivePlotViaDom(id) { + const card = id && grid.querySelector(`.si-plot-card[data-plot-id="${CSS.escape(id)}"]`); + if (!card) return; + const ev = new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }); + card.dispatchEvent(ev); + } + + // 🔧 FIX: aside 칩에서 metric 키 추출 + function getChipKey(chip) { + return chip.getAttribute('data-metric') + || chip.getAttribute('data-key') + || chip.getAttribute('data-dsid') + || chip.getAttribute('data-dataset-id') + || chip.getAttribute('data-id') + || null; + } + + // 🔧 FIX: 플롯 카드 안에서 해당 metric이 포함되어 있는지 확인 + // 플롯 내부에는 data-group-id로 저장되어 있음! + function activePlotContainsKey(key) { + if (!key) return false; + const card = getActivePlotCard(); + if (!card) return false; + + // 플롯 카드 안의 데이터셋 칩들을 검색 (data-group-id로 저장됨) + const selector = `[data-group-id="${CSS.escape(key)}"]`; + const found = !!card.querySelector(selector); + + return found; + } + + /* ---------- Select ↔ Focus 동기화 ---------- */ + function refreshSelectOptions() { + const active = getActivePlotIdFromDOM(); + const items = listPlots(); + const prev = select.value; + + select.innerHTML = ''; + items.forEach(({id, index}) => { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = String(index); + select.appendChild(opt); + }); + + if (items.length) { + const toSelect = (prev && items.some(i => i.id === prev)) ? prev : (active || items[0].id); + select.value = toSelect; + } + } + + function syncSelectToActive() { + const active = getActivePlotIdFromDOM(); + if (active && select.value !== active) select.value = active; + } + + select.addEventListener('change', () => { + const picked = select.value; + if (picked) ensureActivePlotViaDom(picked); + scheduleChecklist(); + }); + + /* ---------- 체크 인디케이터 ---------- */ + function computeChipChecked(chip) { + const key = getChipKey(chip); + + // 1순위: 활성 플롯에 실제로 포함되어 있는지 확인 + if (key) { + const isInPlot = activePlotContainsKey(key); + return isInPlot; + } + + // 2순위: 휴리스틱 (key가 없는 경우) + const ap = chip.getAttribute('aria-pressed'); + const ac = chip.getAttribute('aria-checked'); + const cls = chip.className || ''; + const truthyAttr = (v) => v === 'true' || v === '1'; + return truthyAttr(ap) || truthyAttr(ac) || /\b(is-)?(active|selected|on)\b/.test(cls); + } + + function updateChecklist() { + // PC와 모바일 모두의 칩을 찾아서 업데이트 + const chips = document.querySelectorAll('#si-data-panel .si-chip.si-chip--toggle'); + + chips.forEach(chip => { + const checked = computeChipChecked(chip); + chip.setAttribute('data-checked', checked ? 'true' : 'false'); + + // 디버깅용 로그 (필요시 주석 해제) + // const key = getChipKey(chip); + // console.log('[Checklist]', key, 'checked:', checked); + }); + } + + // 즉시 + 다음 프레임 조합으로 확실하게 갱신 + const scheduleChecklist = (() => { + let rafId = null; + let timeoutId = null; + + return function () { + // 즉시 실행 + updateChecklist(); + + // RAF로 한 번 더 + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + updateChecklist(); + rafId = null; + + // 그래도 안전하게 한 번 더 (비동기 처리 대비) + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + updateChecklist(); + timeoutId = null; + }, 50); + }); + }; + })(); + + // 칩 상호작용 이벤트 + groups.addEventListener('click', scheduleChecklist, false); + groups.addEventListener('pointerup', scheduleChecklist, false); + groups.addEventListener('keyup', (e) => { + if (e.key === 'Enter' || e.key === ' ') scheduleChecklist(); + }, false); + groups.addEventListener('change', scheduleChecklist, false); + + // DOM 변경 관찰 + const obsGroups = new MutationObserver(() => { + scheduleChecklist(); + }); + obsGroups.observe(groups, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['class', 'aria-pressed', 'aria-checked'] + }); + + // 플롯 구조 변경 관찰 + const obsGrid = new MutationObserver(() => { + refreshSelectOptions(); + syncSelectToActive(); + scheduleChecklist(); + }); + obsGrid.observe(grid, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'data-plot-id'] + }); + + // Offcanvas 이벤트 + if (offcanvasEl) { + offcanvasEl.addEventListener('show.bs.offcanvas', () => { + refreshSelectOptions(); + syncSelectToActive(); + scheduleChecklist(); + }); + + offcanvasEl.addEventListener('shown.bs.offcanvas', () => { + scheduleChecklist(); + }); + + // 닫힐 때도 갱신 (다음 열림을 대비) + offcanvasEl.addEventListener('hidden.bs.offcanvas', () => { + scheduleChecklist(); + }); + } + + // 초기 동기화 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + refreshSelectOptions(); + syncSelectToActive(); + scheduleChecklist(); + }); + } else { + refreshSelectOptions(); + syncSelectToActive(); + scheduleChecklist(); + } + + /* ---------- 코어 syncAsideActiveStates 오버라이드 ---------- */ + try { + const original = window.syncAsideActiveStates; + + window.syncAsideActiveStates = function patchedSyncAsideActiveStates() { + // 1) 코어 로직 실행 (데스크톱 칩 갱신) + if (typeof original === 'function') { + try { + original(); + } catch (e) { + console.warn('[SyncAsideActiveStates] Original failed:', e); + } + } + + // 2) 모바일 포함 모든 칩 갱신 + const allChips = document.querySelectorAll('#si-data-panel .si-chip.si-chip--toggle'); + + allChips.forEach(chip => { + const key = getChipKey(chip); + const isActive = key ? activePlotContainsKey(key) : false; + + chip.classList.toggle('is-active', isActive); + chip.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + + // 3) 체크 인디케이터도 갱신 + scheduleChecklist(); + }; + + console.log('[ServiceInsight] syncAsideActiveStates overridden successfully'); + + } catch (e) { + console.error('[ServiceInsight] Failed to override syncAsideActiveStates:', e); + } +})(); \ No newline at end of file diff --git a/src/main/resources/templates/fragments/_navbar.html b/src/main/resources/templates/fragments/_navbar.html index f11950c..fae1ebe 100644 --- a/src/main/resources/templates/fragments/_navbar.html +++ b/src/main/resources/templates/fragments/_navbar.html @@ -1,3 +1,4 @@ + @@ -14,7 +15,8 @@

페이지 제목

+ + +
+
+ +
+ +
+
+ +
+
+
+ @@ -51,5 +94,9 @@ + + + +