feat(B05/ui): 유토곡선 스크롤바·구분선·선택 토글·패널 시인성(21~25)

- 21: 유토곡선 높이를 스크롤러 clientHeight 기준으로 — 스크롤바가 Y축 라벨을 덮던
  문제 해소, overflow-x scroll 고정으로 높이 안정화
- 22: 절토·성토 요약 줄을 종단 열 안으로 — 배수유역도 좌측 구분선 25px 끊김 해소
- 23: 선택 토글 해제 — 종단·유토곡선 측점 재클릭/빈 곳 클릭, 사이드바 구조물 재클릭
  시 해제(null 전파로 3D·그래프·목록·유역 강조 동기 해제)
- 24: 좌측 제목 패널(B04~B07 공용) 우측 가장자리 강조색 라인
- 25: 접힌 손잡이 강조색 배경 틴트 — 닫힌 패널이 있음을 상시 표시

Playwright: 재클릭 1→0, 빈곳 클릭 0, 스크롤러 226=곡선 226, 구분선 간극 1px,
접힘 틴트 확인, 콘솔 오류 0. typecheck 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:34:38 +09:00
co-authored by Claude Opus 5
parent 344d1fbbb9
commit 05887b6a7e
5 changed files with 71 additions and 12 deletions
@@ -165,6 +165,8 @@ export function createIrregularStationsSection(
remainderField.value = target ? String(target.remainder) : "";
structureField.value = target?.structure ?? "";
syncButtons();
// 선택 강조(is-selected)가 클릭 즉시 따라오도록 목록을 다시 그린다(토글 해제 포함).
renderList();
callbacks.onSelect(target ?? null);
}
@@ -188,7 +190,8 @@ export function createIrregularStationsSection(
const info = document.createElement("span");
info.textContent = station.structure || "(구조물 미입력)";
item.append(name, info);
item.addEventListener("click", () => loadForm(station));
// 같은 항목 재클릭 = 선택 해제(2026-08-04 사용자 지시) — 그래프·3D 강조도 함께 풀린다.
item.addEventListener("click", () => loadForm(station.id === editingId ? null : station));
list.append(item);
// 목록이 5개를 넘으면 내부 스크롤이 생긴다 — 선택(=수정 중) 항목이 스크롤 밖에
// 있으면 안 보이므로, 렌더 직후 그 항목만 보이는 위치로 데려온다.
@@ -118,6 +118,8 @@ export interface RouteMassHaulDrawParams {
widthPx: number;
selectedStationId: string | null;
onSelectStation: (stationId: string) => void;
/** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */
onClearSelection?: () => void;
}
export interface RouteMassHaulPanel {
@@ -192,6 +194,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
});
overlay.append(resizer.root, bar, scroll, legendLayer);
// 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시).
let clearSelection: (() => void) | undefined;
scroll.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
clearSelection?.();
});
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
scroll.addEventListener(
@@ -274,6 +283,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
}
function draw(params: RouteMassHaulDrawParams): void {
clearSelection = params.onClearSelection;
overlay.hidden = !open;
syncHandlePosition();
if (!open) return;
@@ -304,10 +314,14 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
const summarySeries = banded ?? series[0];
bar.append(createMassHaulSummary(summarySeries, haulPlan));
// 곡선 몫 = 오버레이에서 요약 막대를 뺀 나머지. 첫 렌더(레이아웃 전)에는 0이 나올 수
// 있어 기본 높이로 시작한다 — Panel이 다음 프레임에 한 번 더 그려 실측으로 수렴한다.
const overlayHeight = overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT;
const chartHeight = Math.max(overlayHeight - bar.offsetHeight, MASS_HAUL_MIN_HEIGHT);
// 곡선 몫 = 스크롤러의 **안쪽 높이**(clientHeight = 가로 스크롤바 제외). 오버레이 전체
// 높이로 잡으면 스크롤바가 곡선 바닥(Y축 -200 라벨)을 덮는다(2026-08-04 사용자 보고).
// 첫 렌더(레이아웃 전)에는 0이 나올 수 있어 기본 높이로 시작한다 — Panel이 다음
// 프레임에 한 번 더 그려 실측으로 수렴한다.
const scrollHeight =
scroll.clientHeight ||
(overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12;
const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT);
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const chart = createMassHaulChart(
@@ -218,7 +218,8 @@ export interface RouteProfilePanelCallbacks {
export function createRouteProfilePanel(
projectId: string,
onSelectStation: (stationId: string) => void,
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void,
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */
onResetAll?: () => void,
callbacks?: RouteProfilePanelCallbacks,
@@ -243,7 +244,9 @@ export function createRouteProfilePanel(
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
bodyWrap.append(body, progress.root);
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
const massHaul = createRouteMassHaulPanel(() => draw());
@@ -269,7 +272,7 @@ export function createRouteProfilePanel(
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
storageKey: HEIGHT_KEY,
});
root.append(panelHandle.root, heightResizer.root, balanceBar, content);
root.append(panelHandle.root, heightResizer.root, content);
drainagePanel.load(projectId);
let detail: SectionDetailResponse | null = null;
@@ -292,6 +295,17 @@ export function createRouteProfilePanel(
/** 처음 그릴 때 잡힌 도면 테이블 높이(px). 패널을 끌어도 이 값을 지킨다. */
let fixedTableHeight = Number(sessionStorage.getItem(TABLE_HEIGHT_KEY)) || 0;
/**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다.
*/
function selectStation(stationId: string | null): void {
const next = stationId !== null && stationId === selectedStationId ? null : stationId;
selectedStationId = next;
draw();
onSelectStation(next);
}
function renderBalance(): void {
balanceBar.replaceChildren();
if (!alignment) {
@@ -501,6 +515,12 @@ export function createRouteProfilePanel(
const chartWrap = document.createElement("div");
chartWrap.className = "b05-profile__chart";
chartWrap.style.height = `${chartHeight}px`;
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
chartWrap.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
if (selectedStationId !== null) selectStation(null);
});
const designProfiles = alignment
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
: (longitudinal.design_profiles ?? []);
@@ -522,7 +542,7 @@ export function createRouteProfilePanel(
selectedStationId,
1,
undefined,
onSelectStation,
selectStation,
stationInterval,
width,
chartHeight,
@@ -608,7 +628,10 @@ export function createRouteProfilePanel(
stationInterval: stationIntervalM ?? 1,
widthPx: width,
selectedStationId,
onSelectStation,
onSelectStation: selectStation,
onClearSelection: () => {
if (selectedStationId !== null) selectStation(null);
},
});
}
}
@@ -44,12 +44,14 @@
background: var(--color-surface);
}
/* 곡선 가로 스크롤러 — 종단 스크롤러와 scrollLeft를 동기화한다(JS). 세로는 잘라낸다. */
/* 곡선 가로 스크롤러 — 종단 스크롤러와 scrollLeft를 동기화한다(JS). 세로는 잘라낸다.
스크롤바는 항상 표시(scroll) — auto면 생겼다 없어질 때 곡선 높이가 널뛰고,
clientHeight 기반 높이 계산도 함께 흔들린다. */
.b05-profile__masshaul-scroll {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow-x: auto;
overflow-x: scroll;
overflow-y: hidden;
}
+17
View File
@@ -28,6 +28,13 @@
border-top: 0;
border-bottom: 0;
border-left: 0;
/* 크기 조절은 안 되지만 조절 패널들과 같은 강조색 가장자리(2026-08-04 사용자 지시)
— 리사이저 상시 표시선과 같은 색·기조로 경계가 구분되게 한다. */
border-right-color: color-mix(
in srgb,
var(--panel-handle-border, var(--color-border)) 75%,
transparent
);
border-radius: 0;
transition: transform var(--transition-base);
}
@@ -105,6 +112,16 @@
transition: transform var(--transition-fast);
}
/* 닫힌 패널의 손잡이 — 접힌 내용이 있음을 강조색 배경 틴트로 알린다(2026-08-04 제안 적용).
열려 있으면 평상 배경이라, 틴트가 있는 손잡이 = "펼칠 것이 있다"로 읽힌다. */
.ui-workflow-overlay__handle[aria-expanded="false"] {
background: color-mix(
in srgb,
var(--panel-handle-border, var(--color-primary)) 20%,
var(--color-surface-raised)
);
}
/* 이름표 달린 손잡이 — 화살표+텍스트를 나란히 담도록 고정 폭을 푼다.
화살표(삼각형)는 flex 압축에서 제외해 이름표 유무와 무관하게 같은 크기를 유지한다. */
.ui-workflow-overlay__handle.is-labeled {