diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index e64fd2c7..ffbf58b1 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -220,6 +220,10 @@ export function createCrossSectionCard( designElevation?: number, onDesignChange?: DesignChangeHandler, rockBoundary?: RockBoundaryControl, + /** 이 카드가 선택 상태일 때 되살릴 면적 강조(없으면 강조 없음). */ + initialAreaKey?: CrossAreaKey | null, + /** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */ + onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void, ): HTMLElement { const card = document.createElement("article"); card.id = `cross-${section.station_id}`; @@ -375,14 +379,21 @@ export function createCrossSectionCard( svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), ); - // 면적 강조는 선택된 카드에서만 붙인다 — 다른 측점을 고르면 카드가 다시 그려지며 해제된다. - let activeArea: CrossAreaKey | null = null; + // 면적 밴드는 선택된 카드에만 깐다 — 다른 측점을 고르면 카드가 다시 그려지며 해제된다. + // 다만 **값 칸은 어느 카드에서든 눌린다**: 선택 안 된 카드에서 누르면 부모가 그 카드를 + // 고르면서 누른 면적까지 한 번에 켠다(2026-08-02 사용자 지시). + let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null; let setBandActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined; const toggleArea = (key: CrossAreaKey): void => { + if (!selected) { + onAreaSelect?.(section.station_id, key); + return; + } activeArea = activeArea === key ? null : key; setBandActive(activeArea); setChipActive(activeArea); + onAreaSelect?.(section.station_id, activeArea); }; if (section.design) { @@ -476,13 +487,15 @@ export function createCrossSectionCard( const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; attachZoomPan(svg, widthPx, heightPx); - // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 선택된 카드의 칩은 강조 토글이 된다. - const readout = buildAreaReadout( - section.design, - selected && section.design ? toggleArea : undefined, - ); + // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. + const readout = buildAreaReadout(section.design, toggleArea); setChipActive = readout.setActive; chartWrap.append(svg, readout.root); + // 다시 그려지기 전에 켜져 있던 강조를 되살린다(부모가 들고 있던 값). + if (activeArea) { + setBandActive(activeArea); + setChipActive(activeArea); + } // 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만. if (rockBoundary && section.design?.geometry_preset === "rock") { const rockControl = buildRockBoundaryControl(section, rockBoundary); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index bc2b2318..11fa359d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -15,12 +15,14 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import type { CrossSection, EarthworkConversion, SectionDetailResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design"; +import type { CrossAreaKey } from "./B06_wf3_ProfileCross_UI_Cross_Areas"; import { createCrossSectionCard, crossCardNaturalHeight, @@ -59,11 +61,11 @@ import { export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; /** - * 헤더(캐럿 행)·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다. - * (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가 잘린다. - * 범례는 그래프 위에 겹치는 오버레이라 흐름 높이를 먹지 않는다.) + * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 + * 그래프 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가 + * 잘린다. 범례는 그래프 위에 겹치는 오버레이라 흐름 높이를 먹지 않는다.) */ -const PANEL_CHROME_PX = 100; +const PANEL_CHROME_PX = 116; /** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */ const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX; const MIN_LONG_HEIGHT = 110; @@ -144,6 +146,8 @@ export function createSectionView( root.className = "b06-section"; let currentDetail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; + /** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */ + let activeAreaKey: CrossAreaKey | null = null; let currentExaggeration = 1; let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; @@ -165,13 +169,13 @@ export function createSectionView( * 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */ const panel = document.createElement("section"); panel.className = "b06-section__panel ui-collapsible"; - // 최소 높이는 TS 상수 하나로만 정의한다(세션에 저장된 옛 높이가 더 작아도 여기서 걸린다). - panel.style.minHeight = `${MIN_PANEL_HEIGHT}px`; - // 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 접기/펼치기 손잡이 겸 상태 표시줄이며, - // 캐럿은 공용 `ui-collapsible` 규칙이 그린다(방향만 B06 CSS에서 상하로 바꾼다). + // 최소 높이는 TS 상수 하나로만 정의하되 **접힘 상태에는 걸리지 않아야** 한다 — 인라인 + // min-height를 두면 접어도 그 높이만큼 빈 배경이 남는다. CSS 변수로 넘겨 펼친 상태에만 건다. + panel.style.setProperty("--b06-profile-min-height", `${MIN_PANEL_HEIGHT}px`); + // 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 상태 문구만 담고, 접기/펼치기는 + // 패널 하단 가운데 손잡이가 맡는다. const panelHeader = document.createElement("header"); - panelHeader.className = "ui-collapsible__title"; - panelHeader.setAttribute("aria-label", L("B06_Profile_View_Longitudinal")); + panelHeader.className = "b06-section__panel-status"; const panelCount = document.createElement("span"); panelHeader.append(panelCount); const chartWrap = document.createElement("div"); @@ -179,14 +183,23 @@ export function createSectionView( const panelBody = document.createElement("div"); panelBody.className = "b06-section__panel-body"; panelBody.append(chartWrap); - panel.append(panelHeader, panelBody); + // 접기 손잡이는 패널 **하단 가운데**에 둔다(2026-08-02 사용자 지시). 모양은 좌측 사이드 + // 패널과 같은 삼각형 손잡이를 90° 돌린 것이며, 위로 접히는 패널이라 `collapseToward: "up"`이다. + // `ui-collapsible__title` 클래스를 달아 공용 토글 동작을 그대로 쓰고(접혀도 이 손잡이만 남는다), + // 그 클래스가 붙이는 캐럿은 B06 CSS에서 지운다(삼각형 아이콘과 겹치므로). + const panelToggle = createWorkflowPanelHandle("bottom", "up"); + panelToggle.root.classList.add("ui-collapsible__title", "b06-section__panel-toggle"); + panel.append(panelHeader, panelBody, panelToggle.root); attachCollapsible(panel); // 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙). if (sessionStorage.getItem(PANEL_COLLAPSED_KEY) === "true") panel.classList.add("is-collapsed"); - // attachCollapsible보다 **뒤에** 붙여야 토글이 끝난 상태를 읽는다(헤더에 붙이면 토글 전 값이 저장된다). + panelToggle.setOpen(!panel.classList.contains("is-collapsed")); + // attachCollapsible보다 **뒤에** 붙여야 토글이 끝난 상태를 읽는다(먼저 붙이면 토글 전 값이 저장된다). panel.addEventListener("click", (event) => { if (!(event.target as HTMLElement).closest(".ui-collapsible__title")) return; - sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(panel.classList.contains("is-collapsed"))); + const collapsed = panel.classList.contains("is-collapsed"); + sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(collapsed)); + panelToggle.setOpen(!collapsed); }); const panelResizer = createPanelResizer({ @@ -264,10 +277,37 @@ export function createSectionView( const selectStation = (stationId: string, scroll: boolean): void => { const deselect = selectedStationId === stationId; selectedStationId = deselect ? null : stationId; + // 측점이 바뀌면 면적 강조는 따라가지 않는다(다른 측점의 강조를 물려받으면 오해를 부른다). + activeAreaKey = null; draw(); if (scroll && !deselect) revealCard(stationId, "smooth"); }; + /** + * 횡단 카드의 면적 값을 눌렀을 때. **선택되지 않은 카드에서 눌러도** 그 카드를 고르면서 + * 누른 면적까지 한 번에 강조한다(2026-08-02 사용자 지시). + * 이미 선택된 카드라면 카드가 자기 DOM에서 강조를 켜고 끄므로 여기서는 상태만 받아 둔다 + * (다시 그릴 때 그대로 되살리기 위해). + */ + const selectArea = (stationId: string, key: CrossAreaKey | null): void => { + if (selectedStationId === stationId) { + activeAreaKey = key; + return; + } + selectedStationId = stationId; + activeAreaKey = key; + draw(); + revealCard(stationId, "smooth"); + }; + + /** 카드 바깥(그리드 빈 자리)을 누르면 선택을 푼다(2026-08-02 사용자 지시). */ + const clearSelection = (): void => { + if (!selectedStationId && !activeAreaKey) return; + selectedStationId = null; + activeAreaKey = null; + draw(); + }; + const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement => createCrossSectionCard( section, @@ -283,6 +323,8 @@ export function createSectionView( : undefined, onDesignChange, rockBoundary, + section.station_id === selectedStationId ? activeAreaKey : null, + selectArea, ); /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ @@ -381,6 +423,10 @@ export function createSectionView( // 공간만 먹고, 카드마다 측점이 적혀 있어 무엇을 보는지는 이미 분명하다. const grid = document.createElement("div"); grid.className = "b06-section__grid"; + // 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다. + grid.addEventListener("click", (event) => { + if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); + }); const columnCount = Math.max( 1, Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index 64acfe8b..3a12d9c5 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -43,7 +43,13 @@ height: var(--b06-profile-height, auto); } -/* 접었을 때는 저장된 높이를 무시하고 제목 행만 남긴다(클래스 2개라 위 규칙을 이긴다). */ +/* 최소 높이는 **펼친 상태에만** 건다. 접힘에도 걸리면(인라인 min-height가 그랬다) 접어도 + 그 높이만큼 빈 배경이 남는다. 값은 JS가 `--b06-profile-min-height`로 실어 준다. */ +.b06-section__panel:not(.is-collapsed) { + min-height: var(--b06-profile-min-height, 360px); +} + +/* 접었을 때는 저장된 높이를 무시하고 손잡이 행만 남긴다(클래스 2개라 위 규칙을 이긴다). */ .b06-section__panel.is-collapsed { height: auto; } @@ -54,7 +60,8 @@ 잘려 나가 잡히지 않는다 — 안쪽에 붙여 전체가 클릭 영역으로 남게 한다. */ .b06-section__panel > .ui-resizer--vertical { top: auto; - bottom: 0; + /* 하단 가운데 접기 손잡이(14px) 위에 얹는다 — 겹치면 어느 쪽이 잡히는지 헷갈린다. */ + bottom: 14px; height: 8px; } @@ -83,21 +90,31 @@ font-size: var(--text-caption); } -/* 제목 문구 없이 접기/펼치기 손잡이만 남은 행 — 캐럿을 가운데 두고 높이를 낮춘다. */ +/* 상태 문구만 담는 행 — 비어 있을 때가 많아 높이를 최소로 둔다. */ .b06-section__panel > header { justify-content: center; padding-block: 2px; border-bottom: 1px solid var(--color-border); } -/* 좌측 사이드 패널과 같은 캐럿을 쓰되 **방향만 상하로** 바꾼다(이 패널은 위로 접힌다). - 펼침 = 위로 접는다(▴) / 접힘 = 아래로 편다(▾). */ -.b06-section__panel > .ui-collapsible__title::after { - content: "▴"; +/* 접기 손잡이 — 패널 하단 가운데. 모양은 좌측 사이드 패널과 같은 삼각형 손잡이를 90° 돌린 + 것이라 공용 컴포넌트를 그대로 쓰고, 위치만 여기서 흐름 배치로 되돌린다(원본은 absolute). + `ui-collapsible__title`이 붙여 주는 캐럿은 삼각형 아이콘과 겹치므로 지운다. */ +.b06-section__panel-toggle { + position: relative; + inset: auto; + z-index: 3; + /* `ui-collapsible__title`이 flex + space-between을 걸어 두므로 아이콘을 가운데로 되돌린다. */ + justify-content: center; + align-self: center; + width: var(--spacing-64); + height: 14px; + margin-top: auto; + border-radius: var(--radius-buttons) var(--radius-buttons) 0 0; } -.b06-section__panel.is-collapsed > .ui-collapsible__title::after { - content: "▾"; +.b06-section__panel-toggle::after { + content: none; } /* 종단면도와 유토곡선이 이 컨테이너 하나를 공유한다 — 가로 스크롤을 함께 써야 @@ -530,7 +547,7 @@ .b06-cross-card__areas th, .b06-cross-card__areas td { padding: 0 5px; - text-align: right; + text-align: center; font-weight: var(--font-weight-regular); } diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index da50d46e..6369ec7b 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -32,8 +32,12 @@ export function createWorkflowPanelHandle( * 오른쪽에 도킹해 오른쪽으로 접히는 패널(B05 배수유역도)은 `"right"`를 넘겨야 * 화살표가 뒤집히지 않는다 — 도킹 방향이 반대인데 같은 회전을 쓰면 열기/닫기 * 표시가 정확히 거꾸로 보인다. + * + * `placement: "bottom"`은 위아래로 접히는 패널이다. 기본은 아래로 접히는 하단 도킹 + * 패널(`"down"`)이고, **위로 접히는 상단 도킹 패널**(B06 종단·유토곡선 패널)은 `"up"`을 + * 넘겨야 한다. 좌우와 같은 이유로, 도킹 방향이 반대인데 같은 회전을 쓰면 표시가 거꾸로 된다. */ - collapseToward: "left" | "right" = "left", + collapseToward: "left" | "right" | "up" | "down" = "left", ): WorkflowPanelHandle { const root = document.createElement("button"); root.type = "button"; @@ -44,9 +48,10 @@ export function createWorkflowPanelHandle( function setOpen(isOpen: boolean): void { // 삼각형은 회전 0°에서 오른쪽(▶)을 가리킨다. 펼친 상태면 접히는 쪽을, - // 접힌 상태면 펼쳐질 쪽을 가리키도록 180°를 붙였다 뗀다. + // 접힌 상태면 펼쳐질 쪽을 가리키도록 90°/180°를 붙였다 뗀다. const pointsRight = isOpen === (collapseToward === "right"); - const rotation = placement === "side" ? (pointsRight ? 0 : 180) : isOpen ? 90 : -90; + const pointsUp = isOpen === (collapseToward === "up"); + const rotation = placement === "side" ? (pointsRight ? 0 : 180) : pointsUp ? -90 : 90; icon.style.transform = `rotate(${rotation}deg)`; root.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); root.setAttribute("aria-label", root.title);