diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts index 5683ee1d..19d38990 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts @@ -190,6 +190,9 @@ export function createIrregularStationsSection( item.append(name, info); item.addEventListener("click", () => loadForm(station)); list.append(item); + // 목록이 5개를 넘으면 내부 스크롤이 생긴다 — 선택(=수정 중) 항목이 스크롤 밖에 + // 있으면 안 보이므로, 렌더 직후 그 항목만 보이는 위치로 데려온다. + if (station.id === editingId) item.scrollIntoView({ block: "nearest" }); }); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts index 621b7449..71fc1552 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts @@ -77,6 +77,17 @@ export interface ProfileEditStore { edited(): boolean; } +/** + * 노선의 미저장 편집 초안을 읽는다(B06 공용). + * + * B06이 진입 시 stale 횡단 설계를 일괄 재계산할 때, B05가 확정 없이 남긴 세션 초안까지 + * 반영해야 두 화면의 계획선이 같은 값으로 계산된다 — 저장분(profile_alignment.edits)만 + * 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다. + */ +export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null { + return readDraft(`${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`); +} + function readDraft(storageKey: string): AlignmentEdits | null { try { const raw = sessionStorage.getItem(storageKey); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts index 44981851..bccdfcb5 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B05_wf2_Route_UI_Profile_MassHaul.ts - * B05 유토곡선 — 종단면 패널 안에 다시 접히는 2차 하단 슬라이드 패널. + * B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**. * * ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ────────────── * 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세 @@ -14,10 +14,12 @@ * 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을 * 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다. * - * ── 배치 ───────────────────────────────────────────────────────── - * 펼치면 12행 도면 테이블 자리를 덮고, 종단 그래프와 세로 1:1로 나눈다(2026-08-03 지시). - * 곡선 SVG는 종단 그래프와 **같은 가로 스크롤러(canvas)** 안 형제로 넣어 X축이 저절로 - * 맞물리게 하고, 범례·요약처럼 폭에 관여하면 안 되는 것만 스크롤러 밖 막대에 둔다. + * ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ───────────────────── + * 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도· + * 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는 + * 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고 + * 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단 + * 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. * ========================================================================== */ import type { @@ -46,11 +48,19 @@ import { MASS_HAUL_MIN_HEIGHT, } from "@util/common_util_mass_haul_view"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_wf2_Route_UI_Style_MassHaul.css"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ const OPEN_KEY = "b05-route-profile-masshaul-open"; +/** 오버레이 높이(px) 세션 키 — 메인 하단 패널 높이와 같은 수명. */ +const OVERLAY_HEIGHT_KEY = "b05-route-profile-masshaul-height"; +/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */ +const OVERLAY_MIN_HEIGHT = 160; +const OVERLAY_DEFAULT_HEIGHT = 280; +/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */ +const OVERLAY_MAX_RATIO = 0.8; /** * 표시 곡선·레이어 키 — **B06 유토곡선과 같은 키**를 쓴다. 두 화면은 같은 그림의 두 창이라 * 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정). @@ -67,9 +77,34 @@ export interface RouteMassHaulContext { naturalSpoilMinSlope?: number; } +/** 가로 스크롤에도 좌측에 고정되는 Y축 오버레이(표고·누가토량 공용). + * + * SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가 + * 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 + * 절대배치한다. 종단 그래프(Panel)와 유토곡선(이 모듈)이 함께 쓰므로 여기가 정의처다. + */ +export function buildStickyYAxis( + axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }, + chartHeight: number, +): HTMLElement { + const anchor = document.createElement("div"); + anchor.className = "b05-profile__yaxis"; + const inner = document.createElement("div"); + inner.className = "b05-profile__yaxis-inner"; + inner.style.width = `${axis.padLeft}px`; + inner.style.height = `${chartHeight}px`; + axis.ticks.forEach(({ y, label }) => { + const tick = document.createElement("span"); + tick.className = "b05-profile__yaxis-tick"; + tick.style.top = `${y}px`; + tick.textContent = label; + inner.append(tick); + }); + anchor.append(inner); + return anchor; +} + export interface RouteMassHaulDrawParams { - /** 범례 오버레이의 세로 위치(px) — 종단 그래프 높이 + 여백. B06과 같은 문법. */ - legendTopPx: number; /** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */ stationSource: MassHaulStationSource; /** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */ @@ -79,31 +114,23 @@ export interface RouteMassHaulDrawParams { /** 종단 그래프와 같은 X 매핑(누가거리 최댓값·좌우 여백). */ axis: MassHaulAxis; stationInterval: number; + /** 종단 캔버스와 같은 콘텐츠 폭(px) — 같아야 측점 세로선이 맞물린다. */ widthPx: number; - heightPx: number; selectedStationId: string | null; onSelectStation: (stationId: string) => void; - /** 가로 스크롤에도 왼쪽에 고정되는 Y축(누가토량)을 그리도록 눈금을 되돌려 준다. */ - onAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void; } export interface RouteMassHaulPanel { - /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이. */ + /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */ handle: HTMLElement; - /** 요약 막대 — 가로 스크롤러 **밖**에 놓는다. */ - bar: HTMLElement; - /** - * 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 동일 문법, 2026-08-03 사용자 지시). - * 호출한 쪽이 `position: relative`인 패널 본문에 붙인다. - */ - legendLayer: HTMLElement; + /** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */ + overlay: HTMLElement; isOpen(): boolean; setContext(next: RouteMassHaulContext | null): void; - /** - * 곡선 SVG를 만든다. 펼침 상태가 아니거나 계산이 안 되면 null을 돌려주고, - * 이때 호출한 쪽은 원래대로 도면 테이블을 그린다. - */ - draw(params: RouteMassHaulDrawParams): SVGSVGElement | null; + /** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */ + attachScrollSync(main: HTMLElement): void; + /** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */ + draw(params: RouteMassHaulDrawParams): void; } function readVisible(): Set { @@ -121,7 +148,7 @@ function readVisible(): Set { } /** - * @param onChanged 펼침·범례 토글로 다시 그려야 할 때 호출된다(패널 전체 redraw). + * @param onChanged 펼침·범례 토글·높이 조절로 다시 그려야 할 때 호출된다(패널 전체 redraw). */ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPanel { const handleControl = createWorkflowPanelHandle("bottom", "down"); @@ -132,11 +159,36 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa caption.textContent = "유토곡선"; handle.append(handleControl.root, caption); + // 오버레이 뼈대 — 위 경계 리사이저 + 요약 막대 + 가로 스크롤러 + 범례. + const overlay = document.createElement("div"); + overlay.className = "b05-profile__masshaul-overlay"; const bar = document.createElement("div"); bar.className = "b05-profile__masshaul-bar"; + const scroll = document.createElement("div"); + scroll.className = "b05-profile__masshaul-scroll"; const legendLayer = document.createElement("div"); legendLayer.className = "b05-profile__masshaul-legend"; - legendLayer.hidden = true; + // 높이 조절 — 메인 하단 패널과 같은 공용 리사이저(위 경계, 위로 끌면 커짐, 세션 보존). + let resizeRedrawPending = false; + const resizer = createPanelResizer({ + axis: "vertical", + target: overlay, + cssVar: "--b05-masshaul-height", + direction: -1, + min: OVERLAY_MIN_HEIGHT, + max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, + storageKey: OVERLAY_HEIGHT_KEY, + onResize: () => { + // 끄는 동안 매 이동마다 전체 redraw를 돌리면 손을 못 따라간다 — 프레임당 한 번만. + if (resizeRedrawPending) return; + resizeRedrawPending = true; + requestAnimationFrame(() => { + resizeRedrawPending = false; + onChanged(); + }); + }, + }); + overlay.append(resizer.root, bar, scroll, legendLayer); let open = sessionStorage.getItem(OPEN_KEY) === "true"; let context: RouteMassHaulContext | null = null; @@ -146,14 +198,12 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa sessionStorage.setItem(OPEN_KEY, String(next)); handleControl.setOpen(next); handle.classList.toggle("is-open", next); - bar.hidden = !next; - legendLayer.hidden = !next; + overlay.hidden = !next; onChanged(); } handleControl.setOpen(open); handle.classList.toggle("is-open", open); - bar.hidden = !open; - legendLayer.hidden = !open; + overlay.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); caption.addEventListener("click", () => applyOpen(!open)); @@ -164,65 +214,113 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa onChanged(); } + // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다. + let syncing = false; + function mirror(from: HTMLElement, to: HTMLElement): void { + from.addEventListener("scroll", () => { + if (syncing) return; + syncing = true; + to.scrollLeft = from.scrollLeft; + syncing = false; + }); + } + let syncedMain: HTMLElement | null = null; + function attachScrollSync(main: HTMLElement): void { + if (syncedMain === main) return; + syncedMain = main; + mirror(main, scroll); + mirror(scroll, main); + } + + function note(message: string): void { + bar.replaceChildren(); + scroll.replaceChildren(); + legendLayer.replaceChildren(); + const empty = document.createElement("span"); + empty.className = "b05-profile__masshaul-empty"; + empty.textContent = message; + bar.append(empty); + } + + function draw(params: RouteMassHaulDrawParams): void { + overlay.hidden = !open; + if (!open) return; + if (!context) { + note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); + return; + } + // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. + const series: MassHaulSeries[] = computeMassHaulSeries( + params.longitudinal, + params.crossSections, + context.conversion, + context.naturalSpoilMinSlope, + ); + if (!series.length) { + note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); + return; + } + bar.replaceChildren(); + legendLayer.replaceChildren(); + const visible = readVisible(); + // 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙. + const banded = series.find((entry) => visible.has(entry.key)); + const haulPlan: HaulPlan | null = + banded && visible.has(MASS_HAUL_BALANCE_KEY) + ? computeHaulPlan(banded.result, context.haulLimits) + : null; + 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); + + let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; + const chart = createMassHaulChart( + series, + visible, + params.stationSource, + params.axis, + params.selectedStationId, + params.stationInterval, + params.widthPx, + chartHeight, + params.widthPx, + params.onSelectStation, + haulPlan, + (axis) => { + massAxis = axis; + }, + ); + // 종단 캔버스와 같은 폭의 래퍼 — sticky Y축 앵커의 기준(position: relative)이 된다. + const wrap = document.createElement("div"); + wrap.className = "b05-profile__chart b05-profile__masshaul-chart"; + wrap.style.width = `${params.widthPx}px`; + wrap.style.height = `${chartHeight}px`; + wrap.append(chart); + if (massAxis) wrap.append(buildStickyYAxis(massAxis, chartHeight)); + scroll.replaceChildren(wrap); + // 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다. + if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft; + + legendLayer.append( + createMassHaulLegend(series, visible, toggleSeries, () => { + resetBalloonOffsets(); + onChanged(); + }), + ); + } + return { handle, - bar, - legendLayer, + overlay, isOpen: () => open, setContext(next) { context = next; }, - draw(params) { - bar.replaceChildren(); - legendLayer.replaceChildren(); - if (!open) return null; - const note = (message: string): null => { - const empty = document.createElement("span"); - empty.className = "b05-profile__masshaul-empty"; - empty.textContent = message; - bar.append(empty); - return null; - }; - if (!context) return note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); - // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. - const series: MassHaulSeries[] = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ); - if (!series.length) return note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); - const visible = readVisible(); - // 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙. - const banded = series.find((entry) => visible.has(entry.key)); - const haulPlan: HaulPlan | null = - banded && visible.has(MASS_HAUL_BALANCE_KEY) - ? computeHaulPlan(banded.result, context.haulLimits) - : null; - const chart = createMassHaulChart( - series, - visible, - params.stationSource, - params.axis, - params.selectedStationId, - params.stationInterval, - params.widthPx, - Math.max(params.heightPx, MASS_HAUL_MIN_HEIGHT), - params.widthPx, - params.onSelectStation, - haulPlan, - params.onAxis, - ); - legendLayer.style.top = `${params.legendTopPx}px`; - legendLayer.append( - createMassHaulLegend(series, visible, toggleSeries, () => { - resetBalloonOffsets(); - onChanged(); - }), - ); - const summarySeries = banded ?? series[0]; - bar.append(createMassHaulSummary(summarySeries, haulPlan)); - return chart; - }, + attachScrollSync, + draw, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 90aa340b..dde9bf6e 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -50,6 +50,7 @@ import { import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { + buildStickyYAxis, createRouteMassHaulPanel, type RouteMassHaulContext, } from "./B05_wf2_Route_UI_Profile_MassHaul"; @@ -78,10 +79,6 @@ const HEIGHT_KEY = "b05-route-profile-height"; const TABLE_HEIGHT_KEY = "b05-route-profile-table-height"; /** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ const CHART_HEIGHT_RATIO = 0.4; -/** 유토곡선 펼침 시 종단:곡선 분할 비율(종단 몫). 경계 드래그로 조절, 세션 보존. */ -const MASS_SPLIT_KEY = "b05-route-profile-masshaul-split"; -const MASS_SPLIT_MIN = 0.25; -const MASS_SPLIT_MAX = 0.75; const MIN_CHART_HEIGHT = 100; /** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ const MIN_PANEL_HEIGHT = 180; @@ -146,33 +143,6 @@ function irregularGraphStations(list: IrregularStation[], maxChainage: number): })); } -/** - * 가로 스크롤에도 좌측에 고정되는 Y축(표고 눈금) 오버레이. - * - * SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가 - * 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 절대배치한다. - */ -function buildStickyYAxis( - axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }, - chartHeight: number, -): HTMLElement { - const anchor = document.createElement("div"); - anchor.className = "b05-profile__yaxis"; - const inner = document.createElement("div"); - inner.className = "b05-profile__yaxis-inner"; - inner.style.width = `${axis.padLeft}px`; - inner.style.height = `${chartHeight}px`; - axis.ticks.forEach(({ y, label }) => { - const tick = document.createElement("span"); - tick.className = "b05-profile__yaxis-tick"; - tick.style.top = `${y}px`; - tick.textContent = label; - inner.append(tick); - }); - anchor.append(inner); - return anchor; -} - /** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ function chainageMapper( data: LongitudinalSection, @@ -273,9 +243,12 @@ export function createRouteProfilePanel( const bodyWrap = document.createElement("div"); bodyWrap.className = "b05-route-profile__body-wrap"; bodyWrap.append(body, progress.root); - // 유토곡선 — 2차 하단 슬라이드. 펼치면 도면 테이블 자리를 덮는다. + // 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정). + // 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다. const massHaul = createRouteMassHaulPanel(() => draw()); - bodyWrap.append(massHaul.legendLayer, massHaul.bar, massHaul.handle); + bodyWrap.append(massHaul.overlay, massHaul.handle); + // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. + massHaul.attachScrollSync(body); const content = document.createElement("div"); content.className = "b05-route-profile__content"; // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). @@ -490,47 +463,39 @@ export function createRouteProfilePanel( fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO)); sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight)); } - // 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 나눈다. 기본 1:1(2026-08-03 사용자 지시), - // 두 그래프 사이 경계선을 끌어 비율을 바꿀 수 있고 세션 동안 유지된다. - const massSplit = Math.min( - MASS_SPLIT_MAX, - Math.max(MASS_SPLIT_MIN, Number(sessionStorage.getItem(MASS_SPLIT_KEY)) || 0.5), - ); - const chartHeight = massHaul.isOpen() - ? Math.max(MIN_CHART_HEIGHT, Math.floor(available * massSplit)) - : alignment - ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight) - : available; + // 종단도·테이블 배치는 유토곡선과 무관하게 항상 같다(2026-08-04 사용자 확정). + // 유토곡선은 별도 오버레이 서브패널이 그 위를 덮는다. + const chartHeight = alignment + ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight) + : available; const tableHeight = available - chartHeight; - // 계획 유토곡선을 펼치면 도면 테이블은 그리지 않는다 — 같은 자리를 나눠 쓰면 둘 다 뭉개진다. - const table = - alignment && !massHaul.isOpen() - ? createProfileTable({ - alignment, - stationInterval: stationInterval ?? alignment.policy.station_interval_m, - width, - height: tableHeight, - // 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다). - cellWidth: layout.cellWidth, - // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. - labelWidth: LONG_PAD.left, - rowCount: TABLE_ROW_COUNT, - x, - // 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다. - irregularStations: irregularStations.filter( - (entry) => - entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, - ), - selectedStationId, - stationDisplay, - onCurveRadiusChange: (curve, radius) => - applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), - // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. - onAdjustStation: (chainage, delta) => - base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), - }) - : null; + const table = alignment + ? createProfileTable({ + alignment, + stationInterval: stationInterval ?? alignment.policy.station_interval_m, + width, + height: tableHeight, + // 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다). + cellWidth: layout.cellWidth, + // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. + labelWidth: LONG_PAD.left, + rowCount: TABLE_ROW_COUNT, + x, + // 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다. + irregularStations: irregularStations.filter( + (entry) => + entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, + ), + selectedStationId, + stationDisplay, + onCurveRadiusChange: (curve, radius) => + applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), + // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. + onAdjustStation: (chainage, delta) => + base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), + }) + : null; const chartWrap = document.createElement("div"); chartWrap.className = "b05-profile__chart"; @@ -574,6 +539,9 @@ export function createRouteProfilePanel( ); if (target) callbacks?.onStructureMove?.(target.chainage_m, toChainage, target); }, + // 계획고 편집 ▼ 버튼(바닥 2px + 높이 15px)과 측점 라벨이 겹치지 않게 X축·라벨을 + // 버튼 높이만큼 올린다(2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지. + 17, ), ); // 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다. @@ -612,81 +580,36 @@ export function createRouteProfilePanel( }), ); } - // 유토곡선 SVG는 종단 그래프와 **같은 부모의 형제**로 넣는다. 감싸는 상자를 하나라도 - // 끼우면 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점선이 어긋난다. - // 유토곡선의 Y축(누가토량)도 종단 표고축과 같은 방식으로 왼쪽에 고정한다 — - // 안 그러면 가로로 훑을 때 유토곡선 눈금은 흘러가고 종단 표고축만 남아, - // 유토곡선 Y축이 "높이"로 읽힌다(2026-08-03 사용자 보고). - let massYAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; - const massHaulChart = - alignment && designProfiles[0] - ? massHaul.draw({ - stationSource: graphLongitudinal, - // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 - // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. - longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, - crossSections: detail.cross_sections, - axis: { - maxChainageM: maxChainageOf(longitudinal), - // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 - // 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다. - padLeft: LONG_PAD.left + originOffset, - padRight: LONG_PAD.right + originOffset, - // 축 선·눈금은 위 종단 그래프의 축과 같은 자리(62px)에 — 축이 두 개로 보이지 않게. - axisX: LONG_PAD.left, - }, - legendTopPx: chartHeight + 6, - onAxis: (axis) => { - massYAxis = axis; - }, - stationInterval: stationIntervalM ?? 1, - widthPx: width, - heightPx: tableHeight, - selectedStationId, - onSelectStation, - }) - : null; - canvas.style.height = `${available}px`; canvas.append(chartWrap); if (table) canvas.append(table); - if (massHaulChart) { - // 종단 그래프와 유토곡선 사이 경계 — 세로로 끌어 두 그래프의 높이 배분을 바꾼다. - const splitBar = document.createElement("div"); - splitBar.className = "b05-profile__masshaul-split"; - splitBar.title = "끌어서 종단 그래프와 유토곡선의 높이 비율을 조절합니다."; - // 절대배치 — 흐름에 끼우면 6px만큼 세로가 넘쳐 곡선 아래가 잘린다. - splitBar.style.top = `${chartHeight - 3}px`; - splitBar.addEventListener("pointerdown", (event) => { - event.preventDefault(); - splitBar.setPointerCapture(event.pointerId); - const startY = event.clientY; - const startRatio = chartHeight / Math.max(available, 1); - const onMove = (move: PointerEvent): void => { - const ratio = startRatio + (move.clientY - startY) / Math.max(available, 1); - sessionStorage.setItem( - MASS_SPLIT_KEY, - String(Math.min(MASS_SPLIT_MAX, Math.max(MASS_SPLIT_MIN, ratio))), - ); - rebuild(); - }; - const onUp = (): void => { - splitBar.removeEventListener("pointermove", onMove); - splitBar.removeEventListener("pointerup", onUp); - }; - splitBar.addEventListener("pointermove", onMove); - splitBar.addEventListener("pointerup", onUp); - }); - // 종단 그래프와 같은 문법으로 감싼다 — sticky Y축 앵커의 기준(position: relative)이 필요하다. - const massWrap = document.createElement("div"); - massWrap.className = "b05-profile__chart"; - massWrap.style.height = `${tableHeight}px`; - massWrap.append(massHaulChart); - if (massYAxis) massWrap.append(buildStickyYAxis(massYAxis, tableHeight)); - canvas.append(splitBar, massWrap); - } body.replaceChildren(canvas); body.scrollLeft = scrollLeft; + + // 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정). + // X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다. + if (alignment && designProfiles[0]) { + massHaul.draw({ + stationSource: graphLongitudinal, + // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 + // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. + longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, + crossSections: detail.cross_sections, + axis: { + maxChainageM: maxChainageOf(longitudinal), + // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 + // 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다. + padLeft: LONG_PAD.left + originOffset, + padRight: LONG_PAD.right + originOffset, + // 축 선·눈금은 위 종단 그래프의 축과 같은 자리(62px)에 — 축이 두 개로 보이지 않게. + axisX: LONG_PAD.left, + }, + stationInterval: stationIntervalM ?? 1, + widthPx: width, + selectedStationId, + onSelectStation, + }); + } } // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index 956864df..eccec14c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -292,7 +292,12 @@ function curveTitle(curve: AlignmentCurve): string { * 곡선이 없는 측점에도 **빈 칸을 만든다**. 변화점에만 셀을 두면 세로 구분선이 띄엄띄엄 * 끊겨 위쪽 측점값 행들과 격자가 맞지 않는다. */ -function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLElement[] { +function buildCurveRows( + options: ProfileTableOptions, + centers: number[], + /** 구조물 측점 chainage 키(소수 3자리) — 이 열의 곡선 입력은 기본 표기하지 않는다. */ + structureStationKeys: ReadonlySet, +): HTMLElement[] { const { alignment, onCurveRadiusChange } = options; const lengthRow = createRow( "b05-profile-table__row--curve is-group-start", @@ -311,7 +316,10 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl alignment.stations.forEach((station, index) => { const lengthCell = element("span", "b05-profile-table__curve"); const radiusCell = element("span", "b05-profile-table__curve"); - const curve = curveByChainage.get(station.chainage_m.toFixed(3)); + // 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지). + const curve = structureStationKeys.has(station.chainage_m.toFixed(3)) + ? undefined + : curveByChainage.get(station.chainage_m.toFixed(3)); if (curve) { // L·R은 L = R × |대수차| 로 연동된다. 둘 다 입력 가능하고, 어느 쪽을 고쳐도 R로 환산해 // 같은 파이프라인(onCurveRadiusChange)을 태운다. 재계산 후 마지막에 입력한 값이 반영된다. @@ -516,6 +524,12 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { .filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3))) .map((node) => node.chainage_m.toFixed(3)), ); + // 확정 시 종단 정본에 병합된 구조물 측점은 alignment.stations에 규칙 측점처럼 끼어 있다. + // 그 열의 기본값은 표기하지 않는다(2026-08-04 사용자 지시) — 사이드바 구조물 목록의 + // chainage와 일치하는 측점이 대상이고, 값은 선택 시 값 열 오버레이(하이라이트)가 보여준다. + const structureStationKeys = new Set( + (options.irregularStations ?? []).map((entry) => entry.chainage_m.toFixed(3)), + ); const selectedIrregularEntry = options.irregularStations?.find( (entry) => irregularStationId(entry.id) === options.selectedStationId, ); @@ -534,13 +548,19 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { spec.unit, ); // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. - alignment.stations.forEach((_station, stationIndex) => { - const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex)); + alignment.stations.forEach((station, stationIndex) => { + // 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다. + const structural = structureStationKeys.has(station.chainage_m.toFixed(3)); + const cell = element( + "span", + `b05-profile-table__cell${structural ? " is-structure" : ""}`, + structural ? "" : spec.cell(stationIndex), + ); placeCell(row, centers[stationIndex], cell); }); table.append(row); }); - table.append(...buildCurveRows(options, centers)); + table.append(...buildCurveRows(options, centers, structureStationKeys)); // 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다. // 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다. diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index d7be12f0..f05d824d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -359,6 +359,11 @@ margin: 0; padding: 0; list-style: none; + /* 목록이 5개를 넘으면 사이드 패널을 밀어내지 않고 내부 스크롤로 돈다(2026-08-04 사용자 + 지시). 상한 = 항목 5개 높이(항목 약 30px + gap 4px) — 5개까지는 현행과 동일하게 다 + 보이고 6개째부터 스크롤이 생긴다. */ + max-height: 170px; + overflow-y: auto; } .b05-route__irregular-item { @@ -867,10 +872,34 @@ } /* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 - 렌더러가 좌우로 비켜 배치한다. 구분을 위해 밝은 자수정색으로 표시한다. */ -.b05-profile-edit__btn.is-segment { - border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); - color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); + 렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */ + +/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이 + 배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b / + 다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다. + is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */ +.b05-profile-edit__btn.is-up { + border-color: color-mix(in srgb, var(--color-danger) 65%, transparent); + color: var(--color-danger); +} + +.b05-profile-edit__btn.is-down { + border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); + color: var(--color-chart-0, #5b8def); +} + +.b05-route-profile .b05-profile-edit__btn.is-up:hover, +.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised)); + color: var(--color-danger); +} + +.b05-route-profile .b05-profile-edit__btn.is-down:hover, +.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible { + border-color: var(--color-chart-0, #5b8def); + background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised)); + color: var(--color-chart-0, #5b8def); } .b05-profile-edit__btn.is-reset { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css index 3603a304..be8a7c44 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css @@ -6,26 +6,58 @@ * 여기서는 종단면 패널 안에서 그것들이 놓이는 **자리**만 잡는다. * ========================================================================== */ -/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 요약 막대·손잡이가 붙게 한다. */ +/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다. + 오버레이의 절대배치 기준이기도 하다. */ .b05-route-profile__body-wrap { + position: relative; flex-direction: column; } +/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다 + (2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */ +.b05-profile__masshaul-overlay { + position: absolute; + right: 0; + bottom: 20px; /* 유토곡선 접기 손잡이(.b05-profile__masshaul-handle height) 몫 */ + left: 0; + z-index: 5; + display: flex; + flex-direction: column; + height: var(--b05-masshaul-height, 280px); + border-top: 1px solid var(--color-border); + background: var(--color-surface-raised); + box-shadow: 0 -4px 12px rgb(0 0 0 / 18%); +} + +/* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */ +.b05-profile__masshaul-overlay > .ui-resizer--vertical { + top: -3px; +} + .b05-profile__masshaul-bar { display: flex; flex: none; flex-wrap: wrap; align-items: center; gap: var(--spacing-8) var(--spacing-16); - border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); background: var(--color-surface); } -/* 범례 오버레이 — B06과 같은 문법으로 유토곡선 우상단에 절대배치한다(2026-08-03 지시). +/* 곡선 가로 스크롤러 — 종단 스크롤러와 scrollLeft를 동기화한다(JS). 세로는 잘라낸다. */ +.b05-profile__masshaul-scroll { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; +} + +/* 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 같은 문법). 요약 막대 아래에 앉힌다. 공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다. */ .b05-profile__masshaul-legend { position: absolute; - top: 0; + top: 34px; right: 0; left: 0; z-index: 3; @@ -37,21 +69,6 @@ pointer-events: auto; } -/* 종단 그래프와 유토곡선 사이 경계 — 세로 드래그로 높이 배분을 바꾼다. */ -.b05-profile__masshaul-split { - position: absolute; - left: 0; - right: 0; - height: 6px; - z-index: 2; - cursor: row-resize; - touch-action: none; -} - -.b05-profile__masshaul-split:hover { - background: color-mix(in srgb, var(--color-primary) 25%, transparent); -} - .b05-profile__masshaul-bar .b06-masshaul__summary { border-top: none; padding-top: 0; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 63d63b29..d30f065b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -479,12 +479,23 @@ export async function previewCrossDesigns( routeId: number, edits: ProfileAlignmentEdits, standardCrossSection?: StandardCrossSection, + options?: { + /** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */ + fullDesigns?: boolean; + /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ + rockBoundaryOffsets?: Record; + }, ): Promise { return requestJson( `/projects/${projectId}/sections/${routeId}/cross-design/preview`, { method: "POST", - body: JSON.stringify({ ...edits, standard_cross_section: standardCrossSection ?? null }), + body: JSON.stringify({ + ...edits, + standard_cross_section: standardCrossSection ?? null, + full_designs: options?.fullDesigns ?? false, + rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, + }), }, ); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 006f43b7..d50186dd 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -507,12 +507,14 @@ def _recompute_designs_for_alignment( cross_sections: list[dict[str, Any]], stored_designs: list[dict[str, Any]], standard: dict[str, Any] | None, + rock_boundary_offsets: dict[str, float] | None = None, ) -> None: """새 계획선 기준으로 전 측점 횡단 설계를 다시 계산해 얹는다(메모리 프리뷰). 사용자가 이미 고른 지반유형·단면유형·측구·암 경계는 **그대로 유지**하고 계획고만 새 선형 값으로 바꿔 넣는다 — 계획선을 끌었다고 측점 선택이 초기화되면 안 된다. 지정이 없는 측점은 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토)으로 계산한다. + `rock_boundary_offsets`(B06 세션값)가 오면 DB 저장분보다 우선한다. """ default_modes = _default_section_modes(longitudinal) pavement = _pavement_suggestions(longitudinal) @@ -520,11 +522,21 @@ def _recompute_designs_for_alignment( round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs } + # 세션 오프셋 키는 프론트 표기(소수 자릿수)가 제각각일 수 있어 수치로 정규화한다. + session_offsets: dict[float, float] = {} + for raw_key, offset in (rock_boundary_offsets or {}).items(): + try: + session_offsets[round(float(raw_key), 3)] = float(offset) + except (TypeError, ValueError): + continue for section in cross_sections: chainage_m = float(section.get("chainage_m", 0.0)) key = round(chainage_m, 3) stored = stored_by_chainage.get(key) or {} suggested = pavement.get(key, False) + rock_offset = session_offsets.get( + key, stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M) + ) try: design = compute_cross_design( section.get("samples", []), @@ -535,9 +547,7 @@ def _recompute_designs_for_alignment( ditch_type=str(stored.get("ditch_type") or "standard"), paved=bool(stored.get("paved", suggested)), standard=standard, - rock_boundary_offset_m=stored.get( - "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M - ), + rock_boundary_offset_m=rock_offset, two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), ) @@ -587,17 +597,22 @@ async def preview_cross_designs( detail["cross_sections"], designs, request.standard_cross_section, + request.rock_boundary_offsets, ) await asyncio.to_thread(rebuild) - # 계획고를 끄는 동안 오가는 값이라 **유토곡선이 실제로 읽는 필드만** 싣는다. + # 계획고를 끄는 동안 오가는 값이라 기본은 **유토곡선이 실제로 읽는 필드만** 싣는다. # 지반선 샘플·설계선 좌표(design_line)·선형 구조를 다 담으면 1MB가 넘어 편집이 굼떠진다 # (2026-08-03 사용자 지적). 계획선 자체는 화면이 이미 같은 규칙으로 계산해 들고 있다. + # `full_designs`(B06 진입 시 stale 일괄 재계산)일 때만 설계 전체를 1회 싣는다 — + # 측점별 순차 호출 N번보다 왕복 1번이 훨씬 싸다(2026-08-04 사용자 확인). return CrossDesignPreviewResponse( designs=[ { "chainage_m": section.get("chainage_m"), - "design": { + "design": section["design"] + if request.full_designs + else { key: section["design"].get(key) for key in _PREVIEW_DESIGN_FIELDS if key in section["design"] diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 551b6496..8af0ac84 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -188,6 +188,12 @@ class CrossDesignPreviewRequest(BaseModel): curve_radii: dict[str, float] = Field(default_factory=dict) # B06 설정 패널 편집값(있으면 그 표준단면으로 계산). standard_cross_section: dict[str, Any] | None = None + # True면 설계 전체(설계선 좌표 포함)를 돌려준다 — B06 진입 시 stale 설계 일괄 재계산용. + # B05 편집 프리뷰(기본 False)는 유토곡선 필드만 받아 응답을 가볍게 유지한다. + full_designs: bool = False + # 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는 + # 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다. + rock_boundary_offsets: dict[str, float] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index 6a2d3e56..a5340884 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -159,6 +159,13 @@ export function createLongitudinalProfile( * B05가 구조물·배관 위치를 그래프에서 바로 조정하는 데 쓴다(2026-08-01 사용자 지시). */ onDragStation?: (stationId: string, toChainageM: number) => void, + /** + * X축·측점 라벨을 그래프 바닥에서 이만큼(px) 더 위로 올린다(기본 0 = 현행). + * B05는 계획고 편집 오버레이의 ▼ 버튼이 바닥(높이 15px + 여백 2px)에 붙어 있어 + * 라벨과 겹친다 — 버튼은 그대로 두고 라벨 띠만 밀어 올린다(2026-08-04 사용자 지시). + * LONG_PAD.bottom을 직접 키우면 B06 종단면도 여백까지 변하므로 호출부 옵션으로 뒀다. + */ + bottomInsetPx = 0, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -190,7 +197,9 @@ export function createLongitudinalProfile( // 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아, // 0측점·종점이 좌우 끝에 딱 붙지 않고 반 칸씩 안으로 들어오게 한다. const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right - 2 * originOffsetPx; - const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom; + // X축·라벨을 바닥에서 밀어 올리는 몫까지 합친 실효 bottom 여백 — 아래 모든 y 계산의 기준. + const padBottom = LONG_PAD.bottom + bottomInsetPx; + const plotHeight = heightPx - LONG_PAD.top - padBottom; const elevationSpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max(rawMax - rawMin, 1); @@ -244,7 +253,7 @@ export function createLongitudinalProfile( x1: x(segment.start_chainage_m), y1: LONG_PAD.top, x2: x(segment.start_chainage_m), - y2: heightPx - LONG_PAD.bottom, + y2: heightPx - padBottom, class: "b06-chart__balance-boundary", }), ); @@ -277,21 +286,21 @@ export function createLongitudinalProfile( x1: stationX, y1: LONG_PAD.top, x2: stationX, - y2: heightPx - LONG_PAD.bottom, + y2: heightPx - padBottom, class: "b06-chart__station-hit", }), svgElement("line", { x1: stationX, y1: LONG_PAD.top, x2: stationX, - y2: heightPx - LONG_PAD.bottom, + y2: heightPx - padBottom, class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, }), // 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점 // 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시). svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), { x: stationX, - y: heightPx - 5, + y: heightPx - bottomInsetPx - 5, "text-anchor": "middle", class: "b06-chart__station-label", }), @@ -303,7 +312,7 @@ export function createLongitudinalProfile( structureLabels.push( svgText(station.structure, { x: stationX, - y: heightPx - LONG_PAD.bottom - 5, + y: heightPx - padBottom - 5, "text-anchor": "middle", class: "b06-chart__structure-label", }), @@ -333,16 +342,16 @@ export function createLongitudinalProfile( svgElement("polyline", { points, class: "b06-chart__profile" }), svgElement("line", { x1: LONG_PAD.left, - y1: heightPx - LONG_PAD.bottom, + y1: heightPx - padBottom, x2: widthPx - LONG_PAD.right, - y2: heightPx - LONG_PAD.bottom, + y2: heightPx - padBottom, class: "b06-chart__axis", }), svgElement("line", { x1: LONG_PAD.left, y1: LONG_PAD.top, x2: LONG_PAD.left, - y2: heightPx - LONG_PAD.bottom, + y2: heightPx - padBottom, class: "b06-chart__axis", }), // 그래프 이름표는 플롯 **안쪽 상단에 겹쳐** 놓는다(2026-08-02 사용자 지시) — 위쪽 여백을 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 30429e29..3df7c464 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -23,11 +23,13 @@ import { type CrossSectionPatch, fetchSectionContext, getSections, + previewCrossDesigns, regenerateSections, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { readAlignmentDraft } from "../B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit"; import { type CrossDesignChange, createSectionView, @@ -198,10 +200,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { * 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이 * 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선 * (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API - * 호출이 없다. 측점별 버튼 선택값은 `changeFromDesign()` 경유로 보존한다. + * 호출이 없다. + * + * 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인 + * — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼 + * 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선), + * 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· + * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. */ async function reconcileStaleDesigns(): Promise { - if (!sectionDetail) return; + if (!sectionDetail || !projectId || currentRouteId === null) return; const profiles = sectionDetail.longitudinal.design_profiles; const stale = sectionDetail.cross_sections.filter((section) => { const design = section.design; @@ -213,10 +221,39 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!stale.length) return; showLoadingOverlay(); try { - for (const section of stale) { - const change = changeFromDesign(section.chainage_m); - if (change) await handleDesignChange(section.chainage_m, change); + const alignment = sectionDetail.longitudinal.profile_alignment as + | { + edits?: { + station_offsets?: Record; + curve_radii?: Record; + }; + } + | undefined; + const edits = readAlignmentDraft(currentRouteId) ?? { + station_offsets: alignment?.edits?.station_offsets ?? {}, + curve_radii: alignment?.edits?.curve_radii ?? {}, + }; + const response = await previewCrossDesigns( + projectId, + currentRouteId, + edits, + standardPanel?.getValues(), + { fullDesigns: true, rockBoundaryOffsets: Object.fromEntries(rockOffsets) }, + ); + const designByChainage = new Map( + response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), + ); + for (const section of sectionDetail.cross_sections) { + const next = designByChainage.get(section.chainage_m.toFixed(3)); + if (next) { + // full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다. + section.design = next as typeof section.design; + sectionView.refreshCard(section.chainage_m); + } } + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Design_Failed")}${detail}`, "error"); } finally { hideLoadingOverlay(); } diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 1e31e8ba..dc7fc34b 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -116,10 +116,15 @@ export function formatVolume(value: number): string { } /** - * Y축 상·하한을 잡는다. 0선은 항상 보이도록 범위에 포함하고, 위아래로 5% 여유를 준다. - * 값이 전부 0이면 폭이 0이 되어 나눗셈이 깨지므로 최소 폭을 씌운다. - * - * 범위는 **표시 중인 곡선이 아니라 전체 곡선**에서 잡는다(축 고정). + * Y축 기본 스케일(㎥). 계획선을 편집할 때마다 범위가 데이터로 다시 잡히면 그래프가 + * 출렁여 변화를 읽기 어렵다(2026-08-04 사용자 확정) — 기본 ±200㎥으로 고정하고, + * 곡선이 이 범위를 벗어날 때만 그 방향으로 넓힌다. + */ +const VOLUME_RANGE_BASE_M3 = 200; + +/** + * Y축 상·하한을 잡는다. 기본 −200~+200㎥ 고정(0선 항상 포함), 곡선이 넘치는 쪽만 + * 데이터에 5% 여유를 더해 확장한다. B05·B06이 같은 함수를 쓰므로 두 화면이 함께 고정된다. */ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { let rawMin = 0; @@ -129,7 +134,10 @@ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { rawMax = Math.max(rawMax, entry.result.max_cumulative_m3); } const padding = Math.max((rawMax - rawMin) * 0.05, 1); - return { min: rawMin - padding, max: rawMax + padding }; + return { + min: Math.min(-VOLUME_RANGE_BASE_M3, rawMin - padding), + max: Math.max(VOLUME_RANGE_BASE_M3, rawMax + padding), + }; } /** diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 09bcccc5..dde3aa13 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -49,6 +49,7 @@ export const ui_locales_common = { Common_Msg_RequiredField: ["필수 입력 항목입니다", "This field is required"], Common_Msg_InvalidValue: ["올바르지 않은 값입니다", "Invalid value"], Workflow_Progress_Title: ["진행단계", "Progress"], + Workflow_Step_Dashboard: ["대시보드", "Dashboard"], Workflow_Overlay_Collapse: ["패널 접기", "Collapse panel"], Workflow_Overlay_Expand: ["패널 펼치기", "Expand panel"], diff --git a/ui_template/ui_template_workflow_layout.css b/ui_template/ui_template_workflow_layout.css index 73d104ae..bdd50474 100644 --- a/ui_template/ui_template_workflow_layout.css +++ b/ui_template/ui_template_workflow_layout.css @@ -62,6 +62,14 @@ cursor: pointer; } +/* 대시보드 이동 버튼 — 워크플로 단계 목록의 탈출구. 단계 버튼과 구분되게 아래 구분선을 둔다. */ +.ui-workflow-layout__step--home { + margin-bottom: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + .ui-workflow-layout__step.is-enabled:hover { background: var(--color-surface); } diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index f062f525..b040bb77 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -1,6 +1,8 @@ import "./ui_template_workflow_layout.css"; +import { ROUTES } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { createWorkflowOverlays } from "./ui_template_overlay"; +import { navigateTo } from "../A00_Common/router"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; let dashboardRolePromise: Promise | undefined; @@ -42,6 +44,8 @@ export interface StepBarOptions { orientation?: "horizontal" | "vertical"; icons?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; + /** 목록 최상단에 대시보드 이동 버튼을 단다(진행단계 오버레이 전용). */ + homeButton?: boolean; } export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "🏗️", "∑", "📄"] as const; @@ -55,6 +59,24 @@ export function createStepBar( bar.className = "ui-workflow-layout__steps"; if (options?.compact) bar.classList.add("is-compact"); if (options?.orientation === "vertical") bar.classList.add("is-vertical"); + // 진행단계 오버레이(세로형) 최상단에 대시보드로 돌아가는 버튼을 둔다(2026-08-04 사용자 + // 지시). 워크플로 단계가 아니라 탈출구라 stage 배지 없이 항상 활성이다. + if (options?.homeButton) { + const home = document.createElement("button"); + home.type = "button"; + home.className = "ui-workflow-layout__step is-enabled ui-workflow-layout__step--home"; + const icon = document.createElement("span"); + icon.className = "ui-workflow-layout__step-icon"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "🏠"; + const label = document.createElement("span"); + label.className = "ui-workflow-layout__step-label"; + label.textContent = t("Workflow_Step_Dashboard"); + home.append(icon, label); + home.title = t("Workflow_Step_Dashboard"); + home.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT)); + bar.append(home); + } steps.forEach((step, index) => { const button = document.createElement("button"); button.type = "button"; @@ -141,6 +163,7 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa orientation: "vertical", icons: WORKFLOW_STEP_ICONS, onStepClick: options.onStepClick, + homeButton: true, }); const overlays = createWorkflowOverlays({ title: options.title,