diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b00cea810..3482913b7 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,8 @@ export const STATE_REGISTRY = { scope: "project", legacy: (p) => `aislo:structure-pick:${p}`, }, + /** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */ + selection: { bucket: "draft", scope: "project" }, /** 배수관·암거 조정창에서 예약한 옵션 값. */ culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, /** 배수관 이동(측점 옮김) 예약. */ diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 6fa286f59..587b93068 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -4,48 +4,99 @@ * 아래쪽에 둔다. 도구(▲▼ 고치기 · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · * 뱃지)와 막기 규칙(최소 흙덮기 · 기울기 상한)은 보기와 무관하게 하나. * - * 판 자리 둘 — 종단도는 자리만 내주고 판은 밖에서 만들어 끼운다(나중에 떼기 쉽게). - * · side 종단도 오른쪽 판(배수유역도 · sub7). `DesignSideFactory` 로 받는다. - * · overlay 종단도 위 겹침 판(유토곡선 · 표). 종단도가 직접 만든다. + * 그래프와 도면 테이블이 **하나의 가로 스크롤러** 안에 같은 폭으로 쌓여 X축이 자동으로 + * 맞물린다. 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [저장]·[확정] + * 시점에 `saveProfileAlignment()`로 편집 델타만 보낸다. + * + * 판 자리 약속(side · overlay)은 `design_profile_slots.ts` 에 있고 여기서 다시 내보낸다. * ========================================================================== */ +import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; +import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; import type { - DrainagePanel, - DrainagePanelCallbacks, -} from "../../B05_Profile/B05_Profile_UI_Drainage_Panel_Types"; + StructureInstance, + StructureType, +} from "../../B05_Profile/B05_Profile_Api_Structures"; +import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay"; +import { + hasLegacyAlignment, + readAlignment, + toDesignProfile, +} from "../../B05_Profile/B05_Profile_UI_Profile_Data"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { showToast } from "@ui/ui_template_elements"; +import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common"; +import { + blocksMinCover, + findMinCoverViolations, + minCoverPoints, + type MinCoverPipe, + type MinCoverPoint, +} from "../../B05_Profile/B05_Profile_UI_Profile_MinCover"; +import type { + AlignmentBase, + AlignmentEdits, + ProfileAlignment, +} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { + buildAlignment, + chainageKey, + emptyEdits, + toAlignmentBase, +} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { + structureGroupMenuItems, + stationIdAtStructure as stationIdAtStructureOf, + structureIdAtStation as structureIdAtStationOf, +} from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; +import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; +import { + createRouteMassHaulPanel, + type MassHaulSummaryValues, + type RouteMassHaulContext, +} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; +import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance"; +import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights"; +import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render"; +import { + irregularStationId, + type IrregularStation, +} from "../../B05_Profile/B05_Profile_UI_IrregularStations"; +import { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Preview"; +import { createPanelTools } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Tools"; +import { createProfileZoom } from "../../B05_Profile/B05_Profile_UI_Profile_Zoom"; +import { + adoptLegacyProfileKeys, + bindProfileCollapse, + createProfileDrag, +} from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; +import { + planCurveWarning, + sampleAt, + snapToMasterStations, +} from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; +import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; +import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; +import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; +import "../../B06_Section/B06_Section_UI_Style.css"; +// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 +// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. +import "../../B06_Section/B06_Section_UI_Style_Cross.css"; +import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; -/** 가운데 보기 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */ -export type DesignView = "profile" | "cross"; - -/** - * 오른쪽 판을 만들 때 종단도가 넘겨 주는 것 — 판이 알릴 일(관 목록 · 유역 · 관 고름 · - * 우클릭 메뉴 항목)과 처음 보기. 판은 `onPipesChanged` 를 **반드시** 불러야 종단도가 - * 최소 흙덮기를 판정한다. - */ -export interface DesignSideHost extends DrainagePanelCallbacks { - projectId: string; - view: DesignView; -} - -/** - * 오른쪽 판(배수유역도). 종단도는 만든 직후 `load(projectId)` · 노선이 오면 `setRoute` · - * 구조물이 바뀌면 `setIntervalSpans` · 되돌리기에 `pipeChainages`/`setPipeChainages` · - * 보기가 바뀌면 `setView` · 걷을 때 `dispose` 를 부른다. 뿌리(`root`)는 종단 열 오른쪽에 붙는다. - */ -export type DesignSideBoard = DrainagePanel & { - /** 보기가 바뀌었다 — 횡단 보기에서도 켜고 끈다(판이 정함). */ - setView?: (view: DesignView) => void; -}; - -/** 오른쪽 판을 끼우는 함수 모양 — 종단도를 만들 때 한 번 부른다. */ -export type DesignSideFactory = (host: DesignSideHost) => DesignSideBoard; - -/** 위 겹침 판(유토곡선 · 표) — 종단 스크롤러와 가로 스크롤을 맞추는 떼었다 붙이는 판. */ -export interface DesignOverlayBoard { - overlay: HTMLElement; - handle: HTMLElement; - attachScrollSync: (scroller: HTMLElement) => void; -} +export type { + DesignOverlayBoard, + DesignSideBoard, + DesignSideFactory, + DesignSideHost, + DesignView, +} from "./design_profile_slots"; /** 종단도를 만들 때 넘기는 것. */ export interface DesignProfileContext { @@ -54,12 +105,591 @@ export interface DesignProfileContext { view: DesignView; /** 오른쪽 판 — 없으면 판 없이 종단 열만. */ side?: DesignSideFactory; + /** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */ + onSelectStation: (stationId: string | null) => void; + /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */ + onResetAll?: () => void; + callbacks?: RouteProfilePanelCallbacks; } -/** 종단도 한 벌. */ -export interface DesignProfile { - root: HTMLElement; - /** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */ - setView: (view: DesignView) => void; - dispose: () => void; +/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms). + * ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다. + * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — + * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. + * 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가 + * 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */ +const CROSS_PREVIEW_DEBOUNCE_MS = 16; + +export function createDesignProfile(ctx: DesignProfileContext) { + const { projectId, onSelectStation, onResetAll, callbacks } = ctx; + adoptLegacyProfileKeys(); + const root = document.createElement("section"); + root.className = "b05-route-profile"; + root.dataset.view = ctx.view; + // 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시). + const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단"); + const balanceBar = document.createElement("div"); + balanceBar.className = "b05-route-profile__balance"; + const body = document.createElement("div"); + body.className = "b05-route-profile__body"; + const empty = document.createElement("p"); + empty.className = "b05-route-profile__empty"; + empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; + body.append(empty); + // 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성. + // 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). + // 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다. + // body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + const bodyWrap = document.createElement("div"); + bodyWrap.className = "b05-route-profile__body-wrap"; + // 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도 + // 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고). + bodyWrap.append(balanceBar, body, progress.root); + // 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정). + // 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다. + // 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고 + // (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은 + // 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw. + const subPanelChanged = (): void => { + if (drag.isSubDragging()) scheduleLightSync(); + else draw(); + }; + /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). + * 유토곡선을 접어도 이 값은 남아야 한다. */ + const massBadge = createMassHaulBadge(); + // 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. + massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); + bodyWrap.append(massBadge.root); + let massHaulSummary: MassHaulSummaryValues | null = null; + // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 + // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. + const haulPrefetch = createHaulPlanPrefetch( + projectId, + () => routeId ?? undefined, + subPanelChanged, + ); + const massHaul = createRouteMassHaulPanel( + subPanelChanged, + (summary) => { + const before = massHaulSummary; + massHaulSummary = summary; + if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); + }, + haulPrefetch, + ); + bodyWrap.append(massHaul.overlay, massHaul.handle); + // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. + massHaul.attachScrollSync(body); + // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). + const tableOverlay = createProfileTableOverlay(subPanelChanged); + bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); + tableOverlay.attachScrollSync(body); + // 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시). + // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. + body.addEventListener("contextmenu", (event) => event.preventDefault()); + massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); + const content = document.createElement("div"); + content.className = "b05-route-profile__content"; + /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ + let minCoverTargets: MinCoverPoint[] = []; + /** 최소고를 편집에서 강제할지 — 페이지 설정 체크박스를 따라온다(기본 해제). */ + let enforceMinCover = false; + /** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */ + const takePipes = (pipes: ReadonlyArray): void => { + // 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다. + minCoverTargets = minCoverPoints([...pipes]); + renderBalance(); + }; + // 오른쪽 판(배수유역도) — 판 쪽이 만들어 넘긴다(판 자리 약속 `DesignSideFactory`). + const drainagePanel: DesignSideBoard | null = + ctx.side?.({ + projectId, + view: ctx.view, + onPipesChanged: (pipes) => { + takePipes(pipes); + callbacks?.onPipesChanged?.(pipes); + }, + onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM), + onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM), + // 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록 + // (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로). + structureMenuItems: (chainage) => + structureGroupMenuItems(structureTypes, (typeId) => + callbacks?.onStructureTypeAdd?.(chainage, typeId), + ), + }) ?? null; + content.append(bodyWrap); + if (drainagePanel) content.append(drainagePanel.root); + const drag = createProfileDrag({ + root, + body, + massHaul, + tableOverlay, + draw: () => draw(), + }); + root.append(panelHandle.root, drag.resizerRoot, content); + drainagePanel?.load(projectId); + + let detail: SectionDetailResponse | null = null; + let selectedStationId: string | null = null; + let stationInterval: number | undefined; + let routeId: number | null = null; + let irregularStations: IrregularStation[] = []; + // 구조물 정본(structures.json) 목록과 타입 레지스트리 — 그래프 서클마크·벌룬에 쓴다. + let structures: StructureInstance[] = []; + let structureTypes: StructureType[] = []; + let selectedStructureId: string | null = null; + // 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지). + let stationDisplay = { station: 0, cumulative: 0 }; + let base: AlignmentBase | null = null; + let alignment: ProfileAlignment | null = null; + let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); + /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ + let savedEdits: AlignmentEdits = emptyEdits(); + let redrawPending = false; + let lastWidth = 0; + let lastHeight = 0; + + /** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 몫(700줄 한계). */ + const structureIdAtStation = (stationId: string | null): string | null => + structureIdAtStationOf(stationId, irregularStations, structures); + const stationIdAtStructure = (structureId: string | null): string | null => + stationIdAtStructureOf(structureId, irregularStations, structures); + + /** + * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). + * 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 페이지에 알린다. + * 같은 자리 알약도 함께 켜고 끈다(2026-08-17 사용자 지시 — 둘은 한 구조물이다). + */ + function selectStation(stationId: string | null): void { + const next = stationId !== null && stationId === selectedStationId ? null : stationId; + selectedStationId = next; + selectedStructureId = structureIdAtStation(next); + draw(); + onSelectStation(next); + } + + function renderBalance(): void { + const minCoverViolations = findMinCoverViolations( + minCoverTargets, + (chainageM) => sampleAt(alignment, chainageM, "ground_elevation_m"), + (chainageM) => sampleAt(alignment, chainageM, "elevation_m"), + ); + renderBalanceBar({ + planCurve: planCurveWarning(alignment, detail), + minCoverViolations, + balanceBar, + tools: tools.render(), + trailing: profileZoom.bar, + alignment, + legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), + edited: store.edited(), + hasIrregularStations: irregularStations.length > 0, + dirty: store.dirty(), + onResetAll: () => { + store.resetAll(); + onResetAll?.(); + }, + }); + } + + /* 줌 조작구(가로 배율) — 상태를 종단도가 들고 있어 편집·재계산으로 다시 그려도 유지된다. + 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ + const profileZoom = createProfileZoom(() => draw()); + + /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ + let elevationWindow: { min: number; max: number } | undefined; + /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ + let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; + /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ + let heightEditing = false; + const holdElevationRange = ( + next: { min: number; max: number } | null, + ): { min: number; max: number } | undefined => { + if (heightEditing) return elevationWindow; + elevationWindow = next ?? undefined; + return elevationWindow; + }; + // 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다. + body.addEventListener("pointerdown", (event) => { + if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; + heightEditing = true; + const release = (): void => { + heightEditing = false; + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + draw(); + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + }); + + /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ + const { tools, history, handleToolPick } = createPanelTools({ + root, + base: () => base, + alignment: () => alignment, + edits: () => store.edits(), + applyEdits: (next) => applyEdits(next), + selectedStationId: () => selectedStationId, + irregularStations: () => irregularStations, + stationIdOf: (station) => irregularStationId(station.id), + moveStation: (station, toChainageM) => + callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), + // 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다. + drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined }, + restore: () => { + // 세션이 정본이므로 편집 초안을 다시 읽어 그린다. + store = createProfileEditStore(routeId, savedEdits, () => rebuild()); + rebuild(); + }, + restoreSaved: () => store.restoreSaved(), + canRestoreSaved: () => store.canRestoreSaved(), + refresh: () => renderBalance(), + }); + + /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ + function applyEdits(next: AlignmentEdits): void { + if (!base || !alignment) return; + const candidate = buildAlignment(base, next); + if ( + base.policy.grade_violation_policy === "block" && + candidate.violations.length > alignment.violations.length + ) { + showToast( + `종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`, + "error", + ); + return; + } + // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). + if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; + store.replace(next); + history.record(); + } + + /** + * 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만 + * 실제 렌더링하도록 모은다. + */ + function rebuild(): void { + if (base) alignment = buildAlignment(base, store.edits()); + // 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유 + // 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다 + // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). + scheduleCrossPreview(); + if (redrawPending) return; + redrawPending = true; + requestAnimationFrame(() => { + redrawPending = false; + draw(); + }); + } + + /** 횡단 설계 프리뷰는 `_Profile_Preview` 몫(700줄 한계). */ + const crossPreview = createCrossPreview({ + projectId, + detail: () => detail, + routeId: () => routeId, + edits: () => store.edits(), + debounceMs: CROSS_PREVIEW_DEBOUNCE_MS, + onApplied: (updated) => { + draw(); + callbacks?.onCrossDesignsUpdated?.(updated); + }, + }); + const scheduleCrossPreview = (): void => crossPreview.schedule(); + + /** + * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 + * 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면 + * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 + * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). + * + * draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는 + * 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다. + */ + const heights = createHeightCascade({ + root, + body, + massHaul, + tableOverlay, + isMainDragging: drag.isMainDragging, + isSubDragging: drag.isSubDragging, + mainDragRef: drag.mainDragRef, + isMainDragCooldown: drag.isMainDragCooldown, + hasDetail: () => detail !== null, + }); + const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } => + heights.apply(allowGrow); + const scheduleLightSync = (): void => heights.scheduleLightSync(); + + function draw(): void { + renderProfile({ + body, + callbacks, + massHaul, + tableOverlay, + store, + detail: () => detail, + alignment: () => alignment, + base: () => base, + stationInterval: () => stationInterval, + irregularStations: () => irregularStations, + minCoverTargets: () => minCoverTargets, + structures: () => structures, + structureTypes: () => structureTypes, + selectedStationId: () => selectedStationId, + setSelectedStationId: (value) => { + selectedStationId = value; + }, + selectedStructureId: () => selectedStructureId, + setSelectedStructureId: (value) => { + selectedStructureId = value; + }, + stationDisplay: () => stationDisplay, + setLastSize: (width, height) => { + lastWidth = width; + lastHeight = height; + }, + renderBalance, + applyHeightCascade, + clearMainDragCooldown: drag.clearMainDragCooldown, + selectStation, + applyEdits, + handleToolPick, + zoom: profileZoom.state, + holdElevationRange, + toolActive: () => tools.mode() !== "none", + selectedRuns: () => tools.selectedRuns(), + stationIdAtStructure, + redraw: draw, + setElevationWindowUpdater: (update) => { + updateElevationWindow = update; + }, + }); + } + + /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ + const SCROLL_SETTLE_MS = 80; + /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 + // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 + // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. + body.addEventListener("scroll", () => { + if (heightEditing) return; + const fitted = updateElevationWindow?.() ?? null; + window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; + scrollSettleTimer = window.setTimeout(() => { + if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = body.scrollLeft; + draw(); + }, SCROLL_SETTLE_MS); + }); + + // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 + // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). + attachWheelHorizontalScroll(body); + + const resizeObserver = new ResizeObserver(() => { + if ( + body.clientWidth <= 0 || + body.clientHeight <= 0 || + (Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1) + ) + return; + // 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번. + // (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고) + scheduleLightSync(); + drag.scheduleDraw(); + }); + resizeObserver.observe(body); + + // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). + bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); + + /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ + function syncDrainageSpans(): void { + drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes)); + } + + return { + root, + /** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */ + setView(next: DesignView) { + root.dataset.view = next; + drainagePanel?.setView?.(next); + }, + render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) { + detail = nextDetail; + stationInterval = nextStationInterval; + const stored = readAlignment(nextDetail.longitudinal); + // 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다. + // 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭). + const routeChanged = nextRouteId !== routeId; + const carried = routeChanged && store.edited() ? store.edits() : null; + // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. + if (routeChanged || !store.dirty()) { + routeId = nextRouteId ?? routeId; + savedEdits = stored?.edits ?? emptyEdits(); + store = createProfileEditStore(routeId, savedEdits, () => rebuild()); + } + base = stored ? toAlignmentBase(stored) : null; + // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). + if (carried) store.replace(carried); + alignment = base ? buildAlignment(base, store.edits()) : null; + // 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다. + // 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다. + configureBalloonOffsets( + `${projectId}:${routeId ?? "-"}`, + nextDetail.balloon_offsets ?? undefined, + ); + draw(); + requestAnimationFrame(draw); + // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — + // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D + // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). + // + // 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이 + // 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기 + // 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다 + // (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현). + if ( + hasStaleDesigns({ + longitudinal: { + design_profiles: alignment + ? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])] + : nextDetail.longitudinal.design_profiles, + }, + cross_sections: nextDetail.cross_sections, + }) + ) { + scheduleCrossPreview(); + } + }, + /** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23). + * 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */ + alignmentSamples: () => alignment?.samples ?? null, + /** + * 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭). + * 페이지가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다. + */ + setEarthworkContext(next: RouteMassHaulContext | null) { + massHaul.setContext(next); + draw(); + }, + setSelectedStation(stationId: string | null) { + selectedStationId = stationId; + // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). + selectedStructureId = structureIdAtStation(stationId); + draw(); + }, + /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ + setEnforceMinCover(enforce: boolean) { + enforceMinCover = enforce; + }, + /** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */ + setMinCoverPipes(pipes: ReadonlyArray) { + takePipes(pipes); + }, + /** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부 + * 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19 + * 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */ + setGradeLimit(maxGradePct: number) { + if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return; + if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return; + base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } }; + alignment = buildAlignment(base, store.edits()); + draw(); + }, + /** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */ + setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) { + drainagePanel?.setRoute(points); + }, + /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ + setIrregularStations(stations: IrregularStation[]) { + irregularStations = snapToMasterStations(alignment, stations); + draw(); + }, + /** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */ + setStructureTypes(types: StructureType[]) { + structureTypes = types; + syncDrainageSpans(); + draw(); + }, + /** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */ + setStructures(next: StructureInstance[]) { + structures = next; + if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) { + selectedStructureId = null; + } + syncDrainageSpans(); + draw(); + }, + /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ + setSelectedStructure(structureId: string | null) { + selectedStructureId = structureId; + selectedStationId = stationIdAtStructure(structureId); + draw(); + }, + /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ + setStationDisplay(next: { station: number; cumulative: number }) { + stationDisplay = next; + draw(); + }, + /** + * 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다. + * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. + */ + resetStationEdit(chainageM: number, moveTo?: number) { + const key = chainageKey(chainageM); + const edits = store.edits(); + if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; + store.resetStation(chainageM, moveTo); + }, + isDirty: () => store.dirty(), + /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ + async save(): Promise { + if (!routeId || !store.dirty()) return; + const saved = await saveProfileAlignment(projectId, routeId, store.edits()); + const next = saved.profile_alignment as ProfileAlignment | undefined; + if (next?.base_pvi?.length) { + base = toAlignmentBase(next); + alignment = next; + if (detail) detail.longitudinal.profile_alignment = next; + } + store.markSaved(); + draw(); + }, + clear() { + detail = null; + base = null; + alignment = null; + selectedStationId = null; + balanceBar.replaceChildren(); + body.replaceChildren(empty); + }, + /** 밖에서 바뀐 횡단 면적(카드 갱신)을 유토곡선 · 뱃지에 다시 싣는다. */ + redraw: () => draw(), + /** 오른쪽 판(배수유역도) — 판 없이 만들었으면 null. */ + side: drainagePanel, + /** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */ + setLoading(label: string | null) { + progress.root.hidden = label === null; + if (label !== null) progress.set(null, label); + }, + dispose() { + drag.dispose(); + crossPreview.dispose(); + resizeObserver.disconnect(); + }, + }; } + +/** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */ +export type DesignProfile = ReturnType; diff --git a/A00_Common/design/design_profile_slots.ts b/A00_Common/design/design_profile_slots.ts new file mode 100644 index 000000000..fc631d922 --- /dev/null +++ b/A00_Common/design/design_profile_slots.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * A00_Common/design/design_profile_slots.ts + * 종단도 한 벌의 **판 자리 약속**(계획서 24-1 · 24-2c) — `design_profile.ts` 가 다시 + * 내보낸다(부르는 쪽은 `design_profile.ts` 에서 가져간다). + * + * 판 자리 둘 — 종단도는 자리만 내주고 판은 밖에서 만들어 끼운다(나중에 떼기 쉽게). + * · side 종단도 오른쪽 판(배수유역도 · sub7). `DesignSideFactory` 로 받는다. + * · overlay 종단도 위 겹침 판(유토곡선 · 표). 종단도가 직접 만든다. + * ========================================================================== */ + +import type { + DrainagePanel, + DrainagePanelCallbacks, +} from "../../B05_Profile/B05_Profile_UI_Drainage_Panel_Types"; + +/** 가운데 보기 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */ +export type DesignView = "profile" | "cross"; + +/** + * 오른쪽 판을 만들 때 종단도가 넘겨 주는 것 — 판이 알릴 일(관 목록 · 유역 · 관 고름 · + * 우클릭 메뉴 항목)과 처음 보기. 판은 `onPipesChanged` 를 **반드시** 불러야 종단도가 + * 최소 흙덮기를 판정한다. + */ +export interface DesignSideHost extends DrainagePanelCallbacks { + projectId: string; + view: DesignView; +} + +/** + * 오른쪽 판(배수유역도). 종단도는 만든 직후 `load(projectId)` · 노선이 오면 `setRoute` · + * 구조물이 바뀌면 `setIntervalSpans` · 되돌리기에 `pipeChainages`/`setPipeChainages` · + * 보기가 바뀌면 `setView` 를 부른다. 뿌리(`root`)는 종단 열 오른쪽에 붙는다. + */ +export type DesignSideBoard = DrainagePanel & { + /** 보기가 바뀌었다 — 횡단 보기에서도 켜고 끈다(판이 정함). */ + setView?: (view: DesignView) => void; +}; + +/** 오른쪽 판을 끼우는 함수 모양 — 종단도를 만들 때 한 번 부른다. */ +export type DesignSideFactory = (host: DesignSideHost) => DesignSideBoard; + +/** 위 겹침 판(유토곡선 · 표) — 종단 스크롤러와 가로 스크롤을 맞추는 떼었다 붙이는 판. */ +export interface DesignOverlayBoard { + overlay: HTMLElement; + handle: HTMLElement; + attachScrollSync: (scroller: HTMLElement) => void; +} diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts new file mode 100644 index 000000000..0dbbaa722 --- /dev/null +++ b/A00_Common/design/design_save.ts @@ -0,0 +1,145 @@ +/* ============================================================================= + * A00_Common/design/design_save.ts + * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. + * + * 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 + * + * 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일** + * (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라, + * 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다. + * + * ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은 + * 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12). + * 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음). + * ========================================================================== */ + +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { flushPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { + flushUphillOverrides, + saveProfileAlignment, +} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { + markAlignmentSaved, + pendingAlignment, +} from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; +import { stateKey } from "../b_page_state"; + +/** 저장 차례 — 이 순서 그대로 돈다. */ +export const DESIGN_SAVE_ORDER = [ + "pipes", + "structures", + "alignment", + "culvertOptions", + "uphill", + "cross", +] as const; + +export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number]; + +export interface DesignSaveContext { + projectId: string; + routeId: () => number | null; + /** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */ + prepare?: () => Promise; + /** + * 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다. + * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤). + */ + steps?: Partial, () => Promise>>; + /** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */ + cross: (confirm: boolean) => Promise; + /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ + done?: (confirm: boolean) => Promise | void; + /** 멈춘 저장을 알린다. */ + failed: (error: unknown, confirm: boolean) => void; +} + +/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ +function reportOnly(label: string): (error: unknown) => void { + return (error) => { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${label} 저장에 실패했습니다.${detail}`, "error"); + }; +} + +/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */ +async function flushAlignmentDraft(projectId: string, routeId: number): Promise { + const draft = pendingAlignment(routeId); + if (!draft) return; + await saveProfileAlignment(projectId, routeId, draft); + markAlignmentSaved(routeId); +} + +/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */ +const TOLERANT: Partial> = { + pipes: "배수관", + structures: "구조물", + alignment: "계획선", +}; + +/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */ +function defaultStep( + step: Exclude, + projectId: string, + routeId: number, +): () => Promise { + switch (step) { + case "pipes": + return () => flushPendingPipes(projectId); + case "structures": + return () => flushPendingStructures(projectId); + case "alignment": + return () => flushAlignmentDraft(projectId, routeId); + case "culvertOptions": + return () => + flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch( + () => undefined, + ); + case "uphill": + return () => flushUphillOverrides(projectId).catch(() => undefined); + } +} + +/** + * [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다. + * 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true. + */ +export async function saveDesign( + ctx: DesignSaveContext, + options: { confirm?: boolean } = {}, +): Promise { + const confirm = options.confirm ?? false; + const routeId = ctx.routeId(); + if (routeId === null) return false; + showLoadingOverlay(); + try { + await ctx.prepare?.(); + for (const step of DESIGN_SAVE_ORDER) { + if (step === "cross") { + await ctx.cross(confirm); + continue; + } + const own = ctx.steps?.[step]; + const base = defaultStep(step, ctx.projectId, routeId); + const run = + step === "pipes" + ? async () => { + await base(); + await own?.(); + } + : (own ?? base); + const label = TOLERANT[step]; + await (label ? run().catch(reportOnly(label)) : run()); + } + await ctx.done?.(confirm); + return true; + } catch (error) { + ctx.failed(error, confirm); + return false; + } finally { + hideLoadingOverlay(); + } +} diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts new file mode 100644 index 000000000..4201d84ed --- /dev/null +++ b/A00_Common/design/design_store.ts @@ -0,0 +1,148 @@ +/* ============================================================================= + * A00_Common/design/design_store.ts + * B05 · B06 설계 상태 **한 곳**(계획서 24-1) — 두 보기가 같은 값을 같은 창구로 읽고 쓴다. + * + * const store = useDesignStore(projectId, routeId); + * store.get("pipes") · store.set("pipes", next) · store.subscribe("pipes", fn) + * + * 밑은 지금 캐시 그대로다 — 값마다 `b_page_state` 등록 키 하나(새 키는 등록부에 먼저). + * 가게는 값을 따로 들고 있지 않는다: 읽을 때마다 캐시를 읽고, 쓸 때 캐시에 쓴 뒤 듣는 쪽을 + * 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다 + * (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것). + * + * ⚠ `alignmentDraft` 만 등록부 밖 옛 키(`b05-profile-alignment-draft:{노선}`)를 감싼다 — + * B05 되돌리기(`_Profile_History`)가 `b05-` 접두어 키만 사진 찍기 때문이다. 등록부 이관은 + * 24-2c 가 되돌리기와 함께 한다(브레인 판정 9-29). + * ========================================================================== */ + +import { readByKey, readState, writeByKey, writeState } from "../b_page_state"; +import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures"; +import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; + +/** 한 페이지의 두 보기(계획서 24-1). */ +export type DesignView = "profile" | "cross"; + +/** 고른 자리 — 측점 · 구조물. 둘 다 비면 고른 것이 없다. */ +export interface DesignSelection { + chainageM: number | null; + structureId: string | null; +} + +/** 가게가 드는 값 — 없으면 null(= 만진 적 없음 · 저장분을 쓸 것). */ +export interface DesignStoreValues { + /** 미저장 구조물 목록(정본 `structures.json`). */ + structures: StructureInstance[] | null; + /** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */ + pipes: DetailPipeInput[] | null; + /** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */ + alignmentDraft: AlignmentEdits | null; + /** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */ + sectionDetail: SectionDetailResponse | null; + /** 고른 측점 · 구조물. */ + selection: DesignSelection | null; +} + +export type DesignStoreKey = keyof DesignStoreValues; + +export interface DesignStore { + get(key: K): DesignStoreValues[K]; + set(key: K, value: DesignStoreValues[K]): void; + /** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */ + subscribe( + key: K, + fn: (value: DesignStoreValues[K]) => void, + ): () => void; +} + +/** 옛 계획고 초안 키 — 위 머리 주석 ⚠ 참조. */ +export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; + +interface Slot { + /** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */ + id: string | null; + read(): T | null; + write(value: T | null): void; +} + +function slotOf( + key: K, + projectId: string | null, + routeId: number | null, +): Slot { + type V = DesignStoreValues[K]; + if (key === "alignmentDraft") { + const raw = routeId === null ? null : `${ALIGNMENT_DRAFT_KEY_PREFIX}:${routeId}`; + return { + id: raw, + read: () => { + const text = raw ? readByKey(raw) : null; + if (!text) return null; + try { + const parsed = JSON.parse(text) as Partial; + return { + station_offsets: parsed.station_offsets ?? {}, + curve_radii: parsed.curve_radii ?? {}, + } as V; + } catch { + return null; + } + }, + write: (value) => { + if (raw) writeByKey(raw, value === null ? null : JSON.stringify(value)); + }, + }; + } + // 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다. + const name = ( + { + structures: "structures", + pipes: "pipes", + sectionDetail: "section-detail", + selection: "selection", + } as const + )[key as Exclude]; + const routeScoped = name === "section-detail"; + if (!projectId || (routeScoped && routeId === null)) { + return { id: null, read: () => null, write: () => undefined }; + } + const scopeRoute = routeScoped ? routeId : undefined; + return { + id: `${name}:${projectId}${routeScoped ? `:${routeId}` : ""}`, + read: () => readState(name, projectId, scopeRoute), + write: (value) => writeState(name, value, projectId, scopeRoute), + }; +} + +const listeners = new Map void>>(); + +/** + * 그 프로젝트 · 노선의 가게. 부를 때마다 새로 만들어도 된다 — 값도 듣는 쪽도 가게 밖 + * (캐시 · 모듈 표)에 있어서 같은 칸을 가리키면 같은 가게다. + * + * 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고 + * 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다. + */ +export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore { + return { + get(key) { + return slotOf(key, projectId, routeId).read() as DesignStoreValues[typeof key]; + }, + set(key, value) { + const slot = slotOf(key, projectId, routeId); + slot.write(value); + if (slot.id) listeners.get(`${key}|${slot.id}`)?.forEach((fn) => fn(value)); + }, + subscribe(key, fn) { + const id = slotOf(key, projectId, routeId).id; + if (!id) return () => undefined; + const bucket = `${key}|${id}`; + const set = listeners.get(bucket) ?? new Set(); + listeners.set(bucket, set); + const listener = fn as (value: unknown) => void; + set.add(listener); + return () => set.delete(listener); + }, + }; +} diff --git a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts index 03c7fe960..acc0eb4d2 100644 --- a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts +++ b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts @@ -7,9 +7,12 @@ * 하나뿐이었다). 다른 조작값과 같은 규칙으로 세션에 쌓고 저장 앞단에서 함께 내보낸다. * * 자동저장은 하지 않는다(CLAUDE.md 5장). + * + * 관 목록의 주인은 설계 가게(`design_store` 의 `pipes`) 하나다(계획서 24-2a) — B05 배수유역도 · + * B06 구조물 목록 모두 이 창구로 읽고 쓴다. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; +import { useDesignStore } from "../A00_Common/design/design_store"; import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -17,11 +20,11 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch export type PendingPipes = DetailPipeInput[]; export function readPendingPipes(projectId: string): PendingPipes | null { - return readState("pipes", projectId); + return useDesignStore(projectId, null).get("pipes"); } export function writePendingPipes(projectId: string, next: PendingPipes | null): void { - writeState("pipes", next, projectId); + useDesignStore(projectId, null).set("pipes", next); } /** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Side.ts b/B05_Profile/B05_Profile_UI_Drainage_Side.ts index 2ff20f4f8..ca78ebeb4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Side.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Side.ts @@ -1,47 +1,47 @@ /* ============================================================================= * B05_Profile_UI_Drainage_Side.ts - * 배수유역도를 종단도의 **side 판**으로 끼우는 홀로 선 창구(계획서 24-2e). + * 배수유역도를 종단도 오른쪽 판(`side`)으로 끼우는 홀로 선 함수(계획서 24-2c · 24-2e). + * 판 자리 약속은 `A00_Common/design/design_profile.ts` 의 `DesignSideFactory` 다 — + * `createDrainageSide` 가 그 모양 그대로다(`(host) => 판`). * - * 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `DrainageSideHost` - * (= `DrainagePanelCallbacks`, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은 - * `panel` 의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` · + * 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `host` + * (`DrainagePanelCallbacks` + projectId · view, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은 + * 판의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` · * `selectPipeAtChainage` …)로만 한다. * · 관 목록이 바뀜(최소 흙덮기 경고 · 구조물 라인 · 통합 목록) = `host.onPipesChanged` - * · 관 자리 읽기 · 쓰기(되돌리기) = `panel.pipeChainages()` · `panel.setPipeChainages()` + * · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()` * · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems` - * · 구간 띠 · 노선 = `panel.setIntervalSpans()` · `panel.setRoute()` + * · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()` * 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역 * 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다. * ========================================================================== */ +import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다. +import type { DesignSideBoard, DesignSideHost } from "../A00_Common/design/design_profile"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; -import type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types"; -/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`) 그대로. */ -export type DrainageSideHost = DrainagePanelCallbacks; +/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */ +export type DrainageSideHost = DesignSideHost; -export interface DrainageSide { - /** 종단도 side 자리에 붙일 뿌리. */ - root: HTMLElement; - /** 판의 창구 — 종단쪽이 노선 · 구간 띠 · 관 자리 · 선택을 밀어 넣는다. */ - panel: DrainagePanel; +/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */ +export type DrainageSideBoard = DesignSideBoard & { /** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */ setVisible: (visible: boolean) => void; isVisible: () => boolean; /** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */ onVisibleChange: (listener: (visible: boolean) => void) => () => void; - dispose: () => void; -} +}; export interface DrainageSideOptions { /** 처음 켤지(기본 켬). */ visible?: boolean; } +/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */ export function createDrainageSide( - host: DrainageSideHost = {}, + host: DrainageSideHost, options: DrainageSideOptions = {}, -): DrainageSide { +): DrainageSideBoard { const panel = createDrainagePanel(host); const listeners = new Set<(visible: boolean) => void>(); let visible = true; @@ -59,8 +59,9 @@ export function createDrainageSide( } return { - root: panel.root, - panel, + ...panel, + // 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다. + setView: () => {}, setVisible, isVisible: () => visible, onVisibleChange(listener) { @@ -74,8 +75,8 @@ export function createDrainageSide( }; } -/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 `side` 를 뒤집는다. */ -export function createDrainageToggleButton(side: DrainageSide): HTMLButtonElement { +/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */ +export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "ui-btn ui-btn--ghost b05-drainage__toggle"; diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 8b822cad1..a08bab992 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -23,9 +23,7 @@ import { solveRoute, type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; -import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; -import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft"; -import { stateKey } from "../A00_Common/b_page_state"; +import { saveDesign } from "../A00_Common/design/design_save"; import { invalidateSectionDetail, saveCachedCrossPatches, @@ -127,74 +125,68 @@ export async function solveRouteAction(ctx: PageActionContext): Promise { } /** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다. - * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */ + * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). + * 차례는 저장 한 줄기(`saveDesign`)가 정한다 — B06 [저장]과 같다(계획서 24-2a). */ export async function tempSaveAction(ctx: PageActionContext): Promise { if (!ctx.routeReady()) return; const latest = ctx.latest(); const projectId = ctx.projectId; - // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다. - // 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다. - await flushPendingPipes(projectId).catch(() => undefined); - await ctx - .profilePanel() - .drainage.savePipes() - .catch(() => 0); - showLoadingOverlay(); - try { - // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다. - await ctx.profilePanel().save(); - // 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로 - // 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다. - await ctx.bridge().saveStructuresIfDirty(); - // 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. - await confirmRoute( - projectId, - { - filter_key: latest?.surface_params.source_filter, - method: latest?.surface_params.method, - smooth: latest?.surface_params.smooth, - surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, - irregular_stations: ctx - .bridge() - .irregularStations() - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure, - })), - // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. - uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({ - chainage_m: Number(chainage), - side, - })), + await saveDesign({ + projectId, + routeId: () => latest?.route?.id ?? null, + steps: { + // 세션 초안이 나간 뒤 화면 목록으로 한 번 더 맞춘다(시설 보관소 되붙이기 · 응답으로 화면 갱신). + pipes: async () => { + await ctx.profilePanel().drainage.savePipes(); }, - false, - ); - // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 - // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. - // 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄. - if (latest?.route?.id != null) { - await flushCulvertOptions( - projectId, - stateKey("culvertopt", projectId, latest.route.id), - ).catch(() => undefined); - } + // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고 + // 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장). + structures: () => ctx.bridge().saveStructuresIfDirty(), + // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다(패널도 다시 그림). + alignment: () => ctx.profilePanel().save(), + // 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. + uphill: async () => { + await confirmRoute( + projectId, + { + filter_key: latest?.surface_params.source_filter, + method: latest?.surface_params.method, + smooth: latest?.surface_params.smooth, + surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, + irregular_stations: ctx + .bridge() + .irregularStations() + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure, + })), + // 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다. + uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({ + chainage_m: Number(chainage), + side, + })), + }, + false, + ); + }, + }, // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 // 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 // 최종본으로 얹힌다(2026-08-24 사용자 지적). - if (latest?.route?.id != null) { - await saveCachedCrossPatches(projectId, latest.route.id); - } - // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. - invalidateSectionDetail(projectId); - ctx.renderLatest(await ctx.loadLatest(true)); - // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. - if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id); - showToast(L("B05_Route_TempSave_Success"), "success"); - } catch (error) { - showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } + cross: async () => { + if (latest?.route?.id != null) await saveCachedCrossPatches(projectId, latest.route.id); + }, + done: async () => { + // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. + invalidateSectionDetail(projectId); + ctx.renderLatest(await ctx.loadLatest(true)); + // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. + if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id); + showToast(L("B05_Route_TempSave_Success"), "success"); + }, + failed: (error) => + showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"), + }); } /** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts new file mode 100644 index 000000000..cf4f144ef --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Drag.ts + * 종단도 **높이 끌기** — 위쪽 경계 손잡이와, 메인 · 서브패널(유토곡선 · 표) 손잡이를 + * 끄는 동안의 출처 추적. + * + * 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다 + * (계획서 24-2c). 본문 로직과 수치는 그대로다. + * ========================================================================== */ + +import { createPanelResizer } from "@ui/ui_template_resizer"; +import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; +import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; +import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; + +const MIN_CHART_HEIGHT = 100; +/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. + * 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다 + * (2026-08-17 사용자 지시 2). */ +const MASSHAUL_HANDLE_GUTTER_PX = 13; +/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ +const MIN_PANEL_HEIGHT = 180; +/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ +const MAX_PANEL_HEIGHT_RATIO = 0.9; + +/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */ +const HEIGHT_KEY = stateKey("profile-height") ?? ""; + +/** 옛 횡단 보기 위 종단 패널의 접힘 · 높이 값을 한 번 옮겨 받는다(새 키가 비었을 때만). */ +export function adoptLegacyProfileKeys(): void { + const pairs = [ + ["section-panel-collapsed", "profile-collapsed"], + ["section-panel-height", "profile-height"], + ] as const; + for (const [from, to] of pairs) { + const old = readStateRaw(from); + if (old === null) continue; + if (readStateRaw(to) === null) writeStateRaw(to, old); + writeStateRaw(from, null); + } +} + +/** 접기 손잡이 — 누르면 접고 펴며, 펼칠 때 다시 그린다. 처음 상태는 저장값. */ +export function bindProfileCollapse( + root: HTMLElement, + handle: { root: HTMLElement; setOpen: (open: boolean) => void }, + onOpen: () => void, +): void { + const setCollapsed = (collapsed: boolean): void => { + root.classList.toggle("is-collapsed", collapsed); + handle.setOpen(!collapsed); + writeStateRaw("profile-collapsed", String(collapsed)); + if (!collapsed) onOpen(); + }; + handle.root.addEventListener("click", () => + setCollapsed(!root.classList.contains("is-collapsed")), + ); + setCollapsed(readStateRaw("profile-collapsed") === "true"); +} + +/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */ +interface SubPanel { + overlay: HTMLElement; + commitHeight: (height: number) => void; +} + +export interface ProfileDragContext { + root: HTMLElement; + body: HTMLElement; + massHaul: SubPanel; + tableOverlay: SubPanel & { isOpen: () => boolean }; + /** 손을 뗀 뒤 한 번 다시 세운다. */ + draw: () => void; +} + +export interface ProfileDrag { + resizerRoot: HTMLElement; + isMainDragging: () => boolean; + isSubDragging: () => boolean; + mainDragRef: () => { chart: number; mass: number; table: number } | null; + isMainDragCooldown: () => boolean; + clearMainDragCooldown: () => void; + /** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */ + scheduleDraw: () => void; + cancelDraw: () => void; + dispose: () => void; +} + +export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { + const { root, body, massHaul, tableOverlay } = ctx; + let resizeTimer = 0; + const scheduleDraw = (): void => { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(ctx.draw, 120); + }; + // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 + // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. + const heightResizer = createPanelResizer({ + axis: "vertical", + target: root, + cssVar: "--b05-profile-height", + direction: -1, + // 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정). + // 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) → + // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). + min: () => { + const chrome = Math.max(0, root.offsetHeight - body.clientHeight); + const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT; + const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; + return Math.max( + MIN_PANEL_HEIGHT, + chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin, + ); + }, + max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, + storageKey: HEIGHT_KEY, + }); + + /* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ────────────── + * 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는 + * 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */ + let mainPanelDragging = false; + let subPanelDragging = false; + /** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는 + * 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해 + * 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */ + let mainDragCooldown = false; + /** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준. + * 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */ + let mainDragRef: { chart: number; mass: number; table: number } | null = null; + heightResizer.root.addEventListener("pointerdown", () => { + mainPanelDragging = true; + const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight; + const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; + mainDragRef = { + chart: Math.max( + MIN_CHART_HEIGHT, + body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX, + ), + mass: massH, + table: tableH, + }; + }); + [massHaul.overlay, tableOverlay.overlay].forEach((overlay) => + overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => { + subPanelDragging = true; + }), + ); + const clearDragFlags = (): void => { + if (mainPanelDragging) mainDragCooldown = true; + if (mainPanelDragging && mainDragRef) { + // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 + // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 + // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). + if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight); + if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); + massHaul.overlay.style.height = ""; + tableOverlay.overlay.style.height = ""; + } + mainDragRef = null; + // 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다. + if (mainPanelDragging || subPanelDragging) scheduleDraw(); + mainPanelDragging = false; + subPanelDragging = false; + }; + window.addEventListener("pointerup", clearDragFlags); + window.addEventListener("pointercancel", clearDragFlags); + + return { + resizerRoot: heightResizer.root, + isMainDragging: () => mainPanelDragging, + isSubDragging: () => subPanelDragging, + mainDragRef: () => mainDragRef, + isMainDragCooldown: () => mainDragCooldown, + clearMainDragCooldown: () => { + mainDragCooldown = false; + }, + scheduleDraw, + cancelDraw: () => window.clearTimeout(resizeTimer), + dispose() { + window.clearTimeout(resizeTimer); + heightResizer.dispose(); + window.removeEventListener("pointerup", clearDragFlags); + window.removeEventListener("pointercancel", clearDragFlags); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 0eacc6fb3..ccf10047d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -17,8 +17,8 @@ import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment"; +import { ALIGNMENT_DRAFT_KEY_PREFIX, useDesignStore } from "../A00_Common/design/design_store"; -const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ const BUTTON_CLEARANCE_PX = 23; const BUTTON_HALF_PX = 10; @@ -79,6 +79,9 @@ export interface ProfileEditStore { edited(): boolean; } +/** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */ +const draftOf = (routeId: number) => useDesignStore(null, routeId); + /** * 노선의 미저장 편집 초안을 읽는다(B06 공용). * @@ -87,7 +90,7 @@ export interface ProfileEditStore { * 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다. */ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null { - return routeId === null ? null : readDraft(`${DRAFT_KEY_PREFIX}:${routeId}`); + return routeId === null ? null : draftOf(routeId).get("alignmentDraft"); } /** @@ -96,13 +99,15 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul * 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에 * 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: * 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남). + * 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다. */ export function clearAlignmentDrafts(): void { + shared.clear(); try { const keys: string[] = []; for (let index = 0; index < sessionStorage.length; index += 1) { const key = sessionStorage.key(index); - if (key?.startsWith(`${DRAFT_KEY_PREFIX}:`)) keys.push(key); + if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key); } keys.forEach((key) => sessionStorage.removeItem(key)); } catch { @@ -110,18 +115,47 @@ export function clearAlignmentDrafts(): void { } } -function readDraft(storageKey: string): AlignmentEdits | null { - try { - const raw = sessionStorage.getItem(storageKey); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return { - station_offsets: parsed.station_offsets ?? {}, - curve_radii: parsed.curve_radii ?? {}, - }; - } catch { - return null; +/** + * 노선마다 **한 벌**인 편집 상태 — B05 종단 패널이 주인이고 B06 계획선 편집은 같은 벌을 + * 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 — + * 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다. + */ +interface EditState { + current: AlignmentEdits; + unsaved: boolean; + /** + * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). + * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 + * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. + */ + savedBaseline: AlignmentEdits; + /** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */ + saved: AlignmentEdits; +} + +const shared = new Map(); + +/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */ +function freshState(routeId: number | null, saved: AlignmentEdits): EditState { + const draft = readAlignmentDraft(routeId); + return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved }; +} + +/** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */ +export function pendingAlignment(routeId: number): AlignmentEdits | null { + const state = shared.get(routeId); + if (state) return state.unsaved ? state.current : null; + return readAlignmentDraft(routeId); +} + +/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */ +export function markAlignmentSaved(routeId: number): void { + const state = shared.get(routeId); + if (state) { + state.unsaved = false; + state.savedBaseline = state.current; } + draftOf(routeId).set("alignmentDraft", null); } /** @@ -131,6 +165,9 @@ function readDraft(storageKey: string): AlignmentEdits | null { * 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면 * 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다. * + * 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤 + * 다시 읽는 길). 이미 선 한 벌에 붙기만 하려면 `useProfileEditStore`. + * * routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에 * 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다. */ @@ -139,56 +176,62 @@ export function createProfileEditStore( saved: AlignmentEdits, onChange: () => void, ): ProfileEditStore { - const storageKey = routeId === null ? null : `${DRAFT_KEY_PREFIX}:${routeId}`; - const draft = storageKey ? readDraft(storageKey) : null; - let current = draft ?? saved; - let unsaved = draft !== null; - /** - * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). - * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 - * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. - */ - let savedBaseline = saved; + const fresh = freshState(routeId, saved); + // 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다. + const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh); + if (routeId !== null) shared.set(routeId, state); + return handleOf(routeId, state, onChange); +} + +/** 그 노선에 이미 선 한 벌에 붙는다 — 없으면 `createProfileEditStore` 와 같다(B06 계획선 편집). */ +export function useProfileEditStore( + routeId: number, + saved: AlignmentEdits, + onChange: () => void, +): ProfileEditStore { + const state = shared.get(routeId); + return state + ? handleOf(routeId, state, onChange) + : createProfileEditStore(routeId, saved, onChange); +} + +function handleOf( + routeId: number | null, + state: EditState, + onChange: () => void, +): ProfileEditStore { + const draft = routeId === null ? null : draftOf(routeId); function dropDraft(): void { - if (!storageKey) return; - try { - sessionStorage.removeItem(storageKey); - } catch { - // 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다. - } + draft?.set("alignmentDraft", null); } function commit(next: AlignmentEdits): void { - current = next; - unsaved = true; - try { - if (storageKey) sessionStorage.setItem(storageKey, JSON.stringify(current)); - } catch { - // 초안 보관 실패는 편집을 막지 않는다. - } + state.current = next; + state.unsaved = true; + draft?.set("alignmentDraft", state.current); onChange(); } return { - edits: () => current, + edits: () => state.current, replace: commit, markSaved() { - unsaved = false; - savedBaseline = current; + state.unsaved = false; + state.savedBaseline = state.current; dropDraft(); }, restoreSaved() { - current = savedBaseline; - unsaved = false; + state.current = state.savedBaseline; + state.unsaved = false; dropDraft(); onChange(); }, - canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline), + canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline), resetStation(chainageM, moveTo) { const key = chainageKey(chainageM); - const stationOffsets = { ...current.station_offsets }; - const curveRadii = { ...current.curve_radii }; + const stationOffsets = { ...state.current.station_offsets }; + const curveRadii = { ...state.current.curve_radii }; if (moveTo !== undefined) { const to = chainageKey(moveTo); if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key]; @@ -200,7 +243,7 @@ export function createProfileEditStore( delete stationOffsets[key]; // 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면 // 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23). - const savedRadius = saved.curve_radii?.[key]; + const savedRadius = state.saved.curve_radii?.[key]; if (savedRadius === undefined) delete curveRadii[key]; else curveRadii[key] = savedRadius; commit({ station_offsets: stationOffsets, curve_radii: curveRadii }); @@ -209,10 +252,10 @@ export function createProfileEditStore( // 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경 // (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 — // 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단). - commit({ ...emptyEdits(), curve_radii: { ...(saved.curve_radii ?? {}) } }); + commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } }); }, - dirty: () => unsaved, - edited: () => hasEdits(current), + dirty: () => state.unsaved, + edited: () => hasEdits(state.current), }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Guard.ts b/B05_Profile/B05_Profile_UI_Profile_Guard.ts new file mode 100644 index 000000000..6314fb40d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Guard.ts @@ -0,0 +1,99 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Guard.ts + * 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고, + * 구조물 측점을 종단 정본 측점으로 맞추기. + * + * 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다 + * (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다. + * ========================================================================== */ + +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; + +/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는 + * mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */ +const STATION_SNAP_TOLERANCE_M = 0.1; + +/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */ +export function sampleAt( + alignment: ProfileAlignment | null, + chainageM: number, + field: "elevation_m" | "ground_elevation_m", +): number | null { + const samples = alignment?.samples; + if (!samples?.length) return null; + if (chainageM <= samples[0].chainage_m) return samples[0][field]; + for (let i = 1; i < samples.length; i += 1) { + if (chainageM <= samples[i].chainage_m) { + const span = samples[i].chainage_m - samples[i - 1].chainage_m; + const t = span <= 1e-12 ? 0 : (chainageM - samples[i - 1].chainage_m) / span; + return samples[i - 1][field] + (samples[i][field] - samples[i - 1][field]) * t; + } + } + return samples[samples.length - 1][field]; +} + +/** + * 법정 평면 최소곡선반지름을 밑도는 측점을 센다(별표2 Ⅰ.2.다.(1), 2026-09-06). + * 반경은 횡단 측점마다 실려 오고(`plan_radius_m`), 하한은 계획선 정책이 들고 있다. + * **경고만** 낸다 — 노선을 고칠지는 사용자 판단이다. + */ +export function planCurveWarning( + alignment: ProfileAlignment | null, + detail: SectionDetailResponse | null, +): { + count: number; + worstRadiusM: number; + limitM: number; + hairpin: number; +} | null { + const limit = alignment?.policy.min_plan_radius_m ?? 0; + const sections = detail?.cross_sections ?? []; + if (!limit || !sections.length) return null; + const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0; + let count = 0; + let hairpin = 0; + let worst = Number.POSITIVE_INFINITY; + sections.forEach((section) => { + const radius = section.plan_radius_m; + if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return; + count += 1; + worst = Math.min(worst, radius); + if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1; + }); + return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null; +} + +/** + * 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04). + * + * 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`, + * 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든 + * 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이 + * 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). + * + * 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다. + * 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다. + */ +export function snapToMasterStations( + alignment: ProfileAlignment | null, + stations: IrregularStation[], +): IrregularStation[] { + const master = alignment?.stations; + if (!master?.length) return stations; + return stations.map((station) => { + let best: number | null = null; + let gap = STATION_SNAP_TOLERANCE_M; + for (const node of master) { + const distance = Math.abs(node.chainage_m - station.chainage_m); + if (distance <= gap) { + gap = distance; + best = node.chainage_m; + } + } + return best === null || best === station.chainage_m + ? station + : { ...station, chainage_m: best }; + }); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 523394d95..7b2616414 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -1,113 +1,18 @@ /* ============================================================================= * B05_Profile_UI_Profile_Panel.ts - * 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집. + * 종단 보기의 하단 종단도 — 종단도 한 벌(`A00_Common/design/design_profile.ts`)에 + * 오른쪽 판(배수유역도)을 끼워 만든다(계획서 24-2c). * - * 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에 - * 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요). - * 본문 세로는 그래프 40% : 테이블 60%로 나눈다. - * - * 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에 - * `saveProfileAlignment()`로 편집 델타만 보낸다. + * 본체는 종단도 한 벌로 옮겼다. 여기는 종단 보기가 부르던 이름 · 모양을 지킨다 — + * 페이지는 `drainage` 로 배수유역도를 바로 부른다. * ========================================================================== */ -import { routeSpansFromStructures } from "./B05_Profile_UI_Drainage_Spans"; -import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; -import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createPanelResizer } from "@ui/ui_template_resizer"; -import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; -import { createHaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan"; -import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; -import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; -import { - createProfileTableOverlay, - TABLE_OVERLAY_MIN_HEIGHT, -} from "./B05_Profile_UI_Profile_TableOverlay"; -import { hasLegacyAlignment, readAlignment, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; -import { createProgressCircle } from "@ui/ui_template_progress"; -import { showToast } from "@ui/ui_template_elements"; -import { saveProfileAlignment } from "./B05_Profile_Api_Fetch"; -import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common"; -import { - blocksMinCover, - findMinCoverViolations, - minCoverPoints, - type MinCoverPoint, -} from "./B05_Profile_UI_Profile_MinCover"; -import type { - AlignmentBase, - AlignmentEdits, - ProfileAlignment, -} from "./B05_Profile_UI_Profile_Alignment"; -import { - buildAlignment, - chainageKey, - emptyEdits, - toAlignmentBase, -} from "./B05_Profile_UI_Profile_Alignment"; -import { createProfileEditStore } from "./B05_Profile_UI_Profile_Edit"; -import { structureGroupMenuItems } from "./B05_Profile_UI_Profile_Structures"; -import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { - createRouteMassHaulPanel, - type MassHaulSummaryValues, - MASSHAUL_MIN_HEIGHT, - type RouteMassHaulContext, -} from "./B05_Profile_UI_Profile_MassHaul"; -import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance"; -import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights"; -import { renderProfile } from "./B05_Profile_UI_Profile_Render"; -import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; -import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview"; -import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools"; -import { createProfileZoom } from "./B05_Profile_UI_Profile_Zoom"; -import { - stationIdAtStructure as stationIdAtStructureOf, - structureIdAtStation as structureIdAtStationOf, -} from "./B05_Profile_UI_Profile_Structures"; -import "../B06_Section/B06_Section_UI_Style.css"; -// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 -// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. -import "../B06_Section/B06_Section_UI_Style_Cross.css"; -import "../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; - -/** 접힘·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ -const HEIGHT_KEY = stateKey("profile-height") ?? ""; -/* 테이블 높이는 오버레이 서브패널(--b05-table-height 리사이저)이 관리한다 — - 예전 4:6 고정 분할(TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO)은 폐지(2026-08-05). */ -const MIN_CHART_HEIGHT = 100; -/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. - * 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다 - * (2026-08-17 사용자 지시 2). */ -const MASSHAUL_HANDLE_GUTTER_PX = 13; -/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ -const MIN_PANEL_HEIGHT = 180; -/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ -const MAX_PANEL_HEIGHT_RATIO = 0.9; -/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms). - * ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다. - * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — - * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. - * 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가 - * 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */ -const CROSS_PREVIEW_DEBOUNCE_MS = 16; -/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는 - * mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */ -const STATION_SNAP_TOLERANCE_M = 0.1; - -/** - * 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**. - * - * 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에 - * `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는 - * 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다. - */ - -/** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로 - * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ +import { createDesignProfile } from "../A00_Common/design/design_profile"; +import type { DesignSideBoard } from "../A00_Common/design/design_profile"; +import { createDrainageSide } from "./B05_Profile_UI_Drainage_Side"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; -import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; -import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; + +/** 콜백 타입은 `_Panel_Types` 에 있다 — 옛 임포트 경로가 그대로 동작하도록 다시 내보낸다. */ export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( @@ -118,710 +23,15 @@ export function createRouteProfilePanel( onResetAll?: () => void, callbacks?: RouteProfilePanelCallbacks, ) { - const root = document.createElement("section"); - root.className = "b05-route-profile"; - // 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시). - const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단"); - const balanceBar = document.createElement("div"); - balanceBar.className = "b05-route-profile__balance"; - const body = document.createElement("div"); - body.className = "b05-route-profile__body"; - const empty = document.createElement("p"); - empty.className = "b05-route-profile__empty"; - empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; - body.append(empty); - // 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성. - // 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). - // 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다. - // body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다. - const progress = createProgressCircle({ overlay: true }); - progress.root.hidden = true; - const bodyWrap = document.createElement("div"); - bodyWrap.className = "b05-route-profile__body-wrap"; - // 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도 - // 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고). - bodyWrap.append(balanceBar, body, progress.root); - // 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정). - // 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다. - // 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고 - // (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은 - // 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw. - const subPanelChanged = (): void => { - if (subPanelDragging) scheduleLightSync(); - else draw(); - }; - /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). - * 유토곡선을 접어도, B06 처럼 곡선을 아예 안 그려도 이 값은 남아야 한다. */ - const massBadge = createMassHaulBadge(); - // B05 는 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. - massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); - bodyWrap.append(massBadge.root); - let massHaulSummary: MassHaulSummaryValues | null = null; - // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 - // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. - const haulPrefetch = createHaulPlanPrefetch( + const profile = createDesignProfile({ projectId, - () => routeId ?? undefined, - subPanelChanged, - ); - const massHaul = createRouteMassHaulPanel( - subPanelChanged, - (summary) => { - const before = massHaulSummary; - massHaulSummary = summary; - if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); - }, - haulPrefetch, - ); - bodyWrap.append(massHaul.overlay, massHaul.handle); - // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. - massHaul.attachScrollSync(body); - // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). - const tableOverlay = createProfileTableOverlay(subPanelChanged); - bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); - tableOverlay.attachScrollSync(body); - // 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시). - // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. - body.addEventListener("contextmenu", (event) => event.preventDefault()); - massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); - const content = document.createElement("div"); - content.className = "b05-route-profile__content"; - // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). - /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ - let minCoverTargets: MinCoverPoint[] = []; - /** 최소고를 편집에서 강제할지 — 사이드 「페이지 설정」 체크박스를 따라온다(기본 해제). */ - let enforceMinCover = false; - const drainagePanel = createDrainagePanel({ - onPipesChanged: (pipes) => { - // 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다. - minCoverTargets = minCoverPoints(pipes); - renderBalance(); - callbacks?.onPipesChanged?.(pipes); - }, - onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM), - onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM), - // 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록 - // (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로). - structureMenuItems: (chainage) => - structureGroupMenuItems(structureTypes, (typeId) => - callbacks?.onStructureTypeAdd?.(chainage, typeId), - ), + view: "profile", + side: createDrainageSide, + onSelectStation, + onResetAll, + callbacks, }); - content.append(bodyWrap, drainagePanel.root); - // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 - // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. - const heightResizer = createPanelResizer({ - axis: "vertical", - target: root, - cssVar: "--b05-profile-height", - direction: -1, - // 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정). - // 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) → - // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). - min: () => { - const chrome = Math.max(0, root.offsetHeight - body.clientHeight); - const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT; - const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; - return Math.max( - MIN_PANEL_HEIGHT, - chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin, - ); - }, - max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, - storageKey: HEIGHT_KEY, - }); - root.append(panelHandle.root, heightResizer.root, content); - drainagePanel.load(projectId); - - /* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ────────────── - * 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는 - * 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */ - let mainPanelDragging = false; - let subPanelDragging = false; - /** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는 - * 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해 - * 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */ - let mainDragCooldown = false; - /** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준. - * 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */ - let mainDragRef: { chart: number; mass: number; table: number } | null = null; - heightResizer.root.addEventListener("pointerdown", () => { - mainPanelDragging = true; - const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight; - const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; - mainDragRef = { - chart: Math.max( - MIN_CHART_HEIGHT, - body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX, - ), - mass: massH, - table: tableH, - }; - }); - [massHaul.overlay, tableOverlay.overlay].forEach((overlay) => - overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => { - subPanelDragging = true; - }), - ); - const clearDragFlags = (): void => { - if (mainPanelDragging) mainDragCooldown = true; - if (mainPanelDragging && mainDragRef) { - // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 - // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 - // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). - if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight); - if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); - massHaul.overlay.style.height = ""; - tableOverlay.overlay.style.height = ""; - } - mainDragRef = null; - // 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다. - if (mainPanelDragging || subPanelDragging) { - window.clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(draw, 120); - } - mainPanelDragging = false; - subPanelDragging = false; - }; - window.addEventListener("pointerup", clearDragFlags); - window.addEventListener("pointercancel", clearDragFlags); - - let detail: SectionDetailResponse | null = null; - let selectedStationId: string | null = null; - let stationInterval: number | undefined; - let routeId: number | null = null; - let irregularStations: IrregularStation[] = []; - // 구조물 정본(structures.json) 목록과 타입 레지스트리 — 그래프 서클마크·벌룬에 쓴다. - let structures: StructureInstance[] = []; - let structureTypes: StructureType[] = []; - let selectedStructureId: string | null = null; - // 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지). - let stationDisplay = { station: 0, cumulative: 0 }; - let base: AlignmentBase | null = null; - let alignment: ProfileAlignment | null = null; - let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); - /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ - let savedEdits: AlignmentEdits = emptyEdits(); - let resizeTimer = 0; - let redrawPending = false; - let lastWidth = 0; - let lastHeight = 0; - - /** - * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). - * 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다. - */ - /** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 로 옮겼다(700줄 한계). */ - const structureIdAtStation = (stationId: string | null): string | null => - structureIdAtStationOf(stationId, irregularStations, structures); - const stationIdAtStructure = (structureId: string | null): string | null => - stationIdAtStructureOf(structureId, irregularStations, structures); - - /** - * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). - * 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다. - * 같은 자리 알약도 함께 켜고 끈다(2026-08-17 사용자 지시 — 둘은 한 구조물이다). - */ - function selectStation(stationId: string | null): void { - const next = stationId !== null && stationId === selectedStationId ? null : stationId; - selectedStationId = next; - selectedStructureId = structureIdAtStation(next); - draw(); - onSelectStation(next); - } - - /** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */ - function sampleAt(chainageM: number, field: "elevation_m" | "ground_elevation_m"): number | null { - const samples = alignment?.samples; - if (!samples?.length) return null; - if (chainageM <= samples[0].chainage_m) return samples[0][field]; - for (let i = 1; i < samples.length; i += 1) { - if (chainageM <= samples[i].chainage_m) { - const span = samples[i].chainage_m - samples[i - 1].chainage_m; - const t = span <= 1e-12 ? 0 : (chainageM - samples[i - 1].chainage_m) / span; - return samples[i - 1][field] + (samples[i][field] - samples[i - 1][field]) * t; - } - } - return samples[samples.length - 1][field]; - } - - /** - * 법정 평면 최소곡선반지름을 밑도는 측점을 센다(별표2 Ⅰ.2.다.(1), 2026-09-06). - * 반경은 횡단 측점마다 실려 오고(`plan_radius_m`), 하한은 계획선 정책이 들고 있다. - * **경고만** 낸다 — 노선을 고칠지는 사용자 판단이다. - */ - function planCurveWarning(): { - count: number; - worstRadiusM: number; - limitM: number; - hairpin: number; - } | null { - const limit = alignment?.policy.min_plan_radius_m ?? 0; - const sections = detail?.cross_sections ?? []; - if (!limit || !sections.length) return null; - const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0; - let count = 0; - let hairpin = 0; - let worst = Number.POSITIVE_INFINITY; - sections.forEach((section) => { - const radius = section.plan_radius_m; - if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return; - count += 1; - worst = Math.min(worst, radius); - if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1; - }); - return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null; - } - - function renderBalance(): void { - const minCoverViolations = findMinCoverViolations( - minCoverTargets, - (chainageM) => sampleAt(chainageM, "ground_elevation_m"), - (chainageM) => sampleAt(chainageM, "elevation_m"), - ); - renderBalanceBar({ - planCurve: planCurveWarning(), - minCoverViolations, - balanceBar, - tools: tools.render(), - trailing: profileZoom.bar, - alignment, - legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), - edited: store.edited(), - hasIrregularStations: irregularStations.length > 0, - dirty: store.dirty(), - onResetAll: () => { - store.resetAll(); - onResetAll?.(); - }, - }); - } - - /* 줌 조작구(가로 배율) — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. - 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ - const profileZoom = createProfileZoom(() => draw()); - - /** - * 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04). - * - * 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`, - * 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든 - * 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이 - * 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). - * - * 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다. - * 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다. - */ - function snapToMasterStations(stations: IrregularStation[]): IrregularStation[] { - const master = alignment?.stations; - if (!master?.length) return stations; - return stations.map((station) => { - let best: number | null = null; - let gap = STATION_SNAP_TOLERANCE_M; - for (const node of master) { - const distance = Math.abs(node.chainage_m - station.chainage_m); - if (distance <= gap) { - gap = distance; - best = node.chainage_m; - } - } - return best === null || best === station.chainage_m - ? station - : { ...station, chainage_m: best }; - }); - } - - /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ - let elevationWindow: { min: number; max: number } | undefined; - /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ - let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; - /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ - let heightEditing = false; - const holdElevationRange = ( - next: { min: number; max: number } | null, - ): { min: number; max: number } | undefined => { - if (heightEditing) return elevationWindow; - elevationWindow = next ?? undefined; - return elevationWindow; - }; - // 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다. - body.addEventListener("pointerdown", (event) => { - if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; - heightEditing = true; - const release = (): void => { - heightEditing = false; - window.removeEventListener("pointerup", release); - window.removeEventListener("pointercancel", release); - draw(); - }; - window.addEventListener("pointerup", release); - window.addEventListener("pointercancel", release); - }); - - /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */ - const { tools, history, handleToolPick } = createPanelTools({ - root, - base: () => base, - alignment: () => alignment, - edits: () => store.edits(), - applyEdits: (next) => applyEdits(next), - selectedStationId: () => selectedStationId, - irregularStations: () => irregularStations, - stationIdOf: (station) => irregularStationId(station.id), - moveStation: (station, toChainageM) => - callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), - drainage: drainagePanel, - restore: () => { - // 세션이 정본이므로 편집 초안을 다시 읽어 그린다. - store = createProfileEditStore(routeId, savedEdits, () => rebuild()); - rebuild(); - }, - restoreSaved: () => store.restoreSaved(), - canRestoreSaved: () => store.canRestoreSaved(), - refresh: () => renderBalance(), - }); - - /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ - function applyEdits(next: AlignmentEdits): void { - if (!base || !alignment) return; - const candidate = buildAlignment(base, next); - if ( - base.policy.grade_violation_policy === "block" && - candidate.violations.length > alignment.violations.length - ) { - showToast( - `종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`, - "error", - ); - return; - } - // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). - if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; - store.replace(next); - history.record(); - } - - /** - * 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만 - * 실제 렌더링하도록 모은다. - */ - function rebuild(): void { - if (base) alignment = buildAlignment(base, store.edits()); - // 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 서버에 한 번 물어 전 측점을 - // 다시 계산해 공유 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선이 따라 움직인다 - // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). - scheduleCrossPreview(); - if (redrawPending) return; - redrawPending = true; - requestAnimationFrame(() => { - redrawPending = false; - draw(); - }); - } - - /** 횡단 설계 프리뷰는 `_Profile_Preview` 로 뺐다(700줄 한계). */ - const crossPreview = createCrossPreview({ - projectId, - detail: () => detail, - routeId: () => routeId, - edits: () => store.edits(), - debounceMs: CROSS_PREVIEW_DEBOUNCE_MS, - onApplied: () => { - draw(); - callbacks?.onCrossDesignsUpdated?.(); - }, - }); - const scheduleCrossPreview = (): void => crossPreview.schedule(); - - /** - * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 - * 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면 - * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 - * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). - * - * draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는 - * 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다. - */ - const heights = createHeightCascade({ - root, - body, - massHaul, - tableOverlay, - isMainDragging: () => mainPanelDragging, - isSubDragging: () => subPanelDragging, - mainDragRef: () => mainDragRef, - isMainDragCooldown: () => mainDragCooldown, - hasDetail: () => detail !== null, - }); - const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } => - heights.apply(allowGrow); - const scheduleLightSync = (): void => heights.scheduleLightSync(); - - function draw(): void { - renderProfile({ - body, - callbacks, - massHaul, - tableOverlay, - store, - detail: () => detail, - alignment: () => alignment, - base: () => base, - stationInterval: () => stationInterval, - irregularStations: () => irregularStations, - minCoverTargets: () => minCoverTargets, - structures: () => structures, - structureTypes: () => structureTypes, - selectedStationId: () => selectedStationId, - setSelectedStationId: (value) => { - selectedStationId = value; - }, - selectedStructureId: () => selectedStructureId, - setSelectedStructureId: (value) => { - selectedStructureId = value; - }, - stationDisplay: () => stationDisplay, - setLastSize: (width, height) => { - lastWidth = width; - lastHeight = height; - }, - renderBalance, - applyHeightCascade, - clearMainDragCooldown: () => { - mainDragCooldown = false; - }, - selectStation, - applyEdits, - handleToolPick, - zoom: profileZoom.state, - holdElevationRange, - toolActive: () => tools.mode() !== "none", - selectedRuns: () => tools.selectedRuns(), - stationIdAtStructure, - redraw: draw, - setElevationWindowUpdater: (update) => { - updateElevationWindow = update; - }, - }); - } - - /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ - const SCROLL_SETTLE_MS = 80; - /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ - let settledScrollLeft = 0; - let scrollSettleTimer = 0; - // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 - // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 - // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. - body.addEventListener("scroll", () => { - if (heightEditing) return; - const fitted = updateElevationWindow?.() ?? null; - window.clearTimeout(scrollSettleTimer); - if (!needsFullRedraw(fitted)) return; - scrollSettleTimer = window.setTimeout(() => { - if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; - settledScrollLeft = body.scrollLeft; - draw(); - }, SCROLL_SETTLE_MS); - }); - - // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 - // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). - attachWheelHorizontalScroll(body); - - const resizeObserver = new ResizeObserver(() => { - if ( - body.clientWidth <= 0 || - body.clientHeight <= 0 || - (Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1) - ) - return; - // 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번. - // (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고) - scheduleLightSync(); - window.clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(draw, 120); - }); - resizeObserver.observe(body); - - function setCollapsed(collapsed: boolean): void { - root.classList.toggle("is-collapsed", collapsed); - panelHandle.setOpen(!collapsed); - writeStateRaw("profile-collapsed", String(collapsed)); - if (!collapsed) requestAnimationFrame(draw); - } - - panelHandle.root.addEventListener("click", () => - setCollapsed(!root.classList.contains("is-collapsed")), - ); - setCollapsed(readStateRaw("profile-collapsed") === "true"); - - /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ - function syncDrainageSpans(): void { - drainagePanel.setIntervalSpans(routeSpansFromStructures(structures, structureTypes)); - } - - return { - root, - render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) { - detail = nextDetail; - stationInterval = nextStationInterval; - const stored = readAlignment(nextDetail.longitudinal); - // 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다. - // 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭). - const routeChanged = nextRouteId !== routeId; - const carried = routeChanged && store.edited() ? store.edits() : null; - // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. - if (routeChanged || !store.dirty()) { - routeId = nextRouteId ?? routeId; - savedEdits = stored?.edits ?? emptyEdits(); - store = createProfileEditStore(routeId, savedEdits, () => rebuild()); - } - base = stored ? toAlignmentBase(stored) : null; - // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). - if (carried) store.replace(carried); - alignment = base ? buildAlignment(base, store.edits()) : null; - // 유토곡선 balloon 위치 캐시 — B06과 **같은 scope**를 써서 두 화면이 같은 자리를 공유한다. - // 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다(B06과 같은 규칙). - configureBalloonOffsets( - `${projectId}:${routeId ?? "-"}`, - nextDetail.balloon_offsets ?? undefined, - ); - draw(); - requestAnimationFrame(draw); - // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — - // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D - // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). - // - // 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이 - // 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기 - // 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다 - // (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현). - if ( - hasStaleDesigns({ - longitudinal: { - design_profiles: alignment - ? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])] - : nextDetail.longitudinal.design_profiles, - }, - cross_sections: nextDetail.cross_sections, - }) - ) { - scheduleCrossPreview(); - } - }, - /** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23). - * 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */ - alignmentSamples: () => alignment?.samples ?? null, - /** - * 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭). - * Page가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다. - */ - setEarthworkContext(next: RouteMassHaulContext | null) { - massHaul.setContext(next); - draw(); - }, - setSelectedStation(stationId: string | null) { - selectedStationId = stationId; - // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). - selectedStructureId = structureIdAtStation(stationId); - draw(); - }, - /** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부 - * 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19 - * 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */ - /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ - setEnforceMinCover(enforce: boolean) { - enforceMinCover = enforce; - }, - setGradeLimit(maxGradePct: number) { - if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return; - if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return; - base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } }; - alignment = buildAlignment(base, store.edits()); - draw(); - }, - /** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */ - setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) { - drainagePanel.setRoute(points); - }, - /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ - setIrregularStations(stations: IrregularStation[]) { - irregularStations = snapToMasterStations(stations); - draw(); - }, - /** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */ - setStructureTypes(types: StructureType[]) { - structureTypes = types; - syncDrainageSpans(); - draw(); - }, - /** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */ - setStructures(next: StructureInstance[]) { - structures = next; - if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) { - selectedStructureId = null; - } - syncDrainageSpans(); - draw(); - }, - /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ - setSelectedStructure(structureId: string | null) { - selectedStructureId = structureId; - selectedStationId = stationIdAtStructure(structureId); - draw(); - }, - /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ - setStationDisplay(next: { station: number; cumulative: number }) { - stationDisplay = next; - draw(); - }, - /** - * 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다. - * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. - */ - resetStationEdit(chainageM: number, moveTo?: number) { - const key = chainageKey(chainageM); - const edits = store.edits(); - if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; - store.resetStation(chainageM, moveTo); - }, - isDirty: () => store.dirty(), - /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ - async save(): Promise { - if (!routeId || !store.dirty()) return; - const saved = await saveProfileAlignment(projectId, routeId, store.edits()); - const next = saved.profile_alignment as ProfileAlignment | undefined; - if (next?.base_pvi?.length) { - base = toAlignmentBase(next); - alignment = next; - if (detail) detail.longitudinal.profile_alignment = next; - } - store.markSaved(); - draw(); - }, - clear() { - detail = null; - base = null; - alignment = null; - selectedStationId = null; - balanceBar.replaceChildren(); - body.replaceChildren(empty); - }, - /** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */ - drainage: drainagePanel, - /** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */ - setLoading(label: string | null) { - progress.root.hidden = label === null; - if (label !== null) progress.set(null, label); - }, - dispose() { - window.clearTimeout(resizeTimer); - crossPreview.dispose(); - resizeObserver.disconnect(); - heightResizer.dispose(); - window.removeEventListener("pointerup", clearDragFlags); - window.removeEventListener("pointercancel", clearDragFlags); - }, - }; + /** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */ + const drainage = profile.side as DesignSideBoard; + return { ...profile, drainage }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts index d219725cd..0ece33c2a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -50,6 +50,7 @@ export interface RouteProfilePanelCallbacks { /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ onPipeSelected?: (chainageM: number | null) => void; /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — - * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ - onCrossDesignsUpdated?: () => void; + * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). 다시 계산한 측점 누가거리를 넘긴다 — + * 횡단 보기는 그 카드만 갈아 끼운다(계획서 24-2c). */ + onCrossDesignsUpdated?: (updated: ReadonlyArray) => void; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index b6c74daa5..3e9cbc464 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -19,8 +19,8 @@ export interface CrossPreviewContext { routeId: () => number | null; edits: () => AlignmentEdits; debounceMs: number; - /** 반영이 끝난 뒤 다시 그린다. */ - onApplied: () => void; + /** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */ + onApplied: (updated: number[]) => void; } export interface CrossPreview { @@ -56,7 +56,7 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { shouldApply: isCurrent, }) .then((updated) => { - if (updated.length) ctx.onApplied(); + if (updated.length) ctx.onApplied(updated); }) .catch(() => { /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ diff --git a/B06_Section/B06_Section_UI_Page_Grade_Edit.ts b/B06_Section/B06_Section_UI_Page_Grade_Edit.ts index 8c5c87c5a..ff12e945b 100644 --- a/B06_Section/B06_Section_UI_Page_Grade_Edit.ts +++ b/B06_Section/B06_Section_UI_Page_Grade_Edit.ts @@ -5,7 +5,7 @@ * ========================================================================== */ import { - createProfileEditStore, + useProfileEditStore, type ProfileEditStore, } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; @@ -28,8 +28,8 @@ export interface GradeEditContext { } /** - * 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만 - * 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`), + * 계획선 편집(▲/▼) — B05 종단 패널의 **같은 가게**(노선마다 한 벌)를 쓴다(계획서 24-2a · + * 2026-09-12 사용자: B06 에만 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`), * 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장). */ export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider { @@ -53,7 +53,7 @@ export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider { if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다. if (!gradeStore || gradeRouteId !== currentRouteId) { gradeRouteId = currentRouteId; - gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined); + gradeStore = useProfileEditStore(currentRouteId, stored.edits, () => undefined); } const store = gradeStore; const base = toAlignmentBase(stored); diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index b28270815..aaf97f6eb 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -7,7 +7,7 @@ * (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만). * ========================================================================== */ -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { confirmSections, @@ -17,14 +17,7 @@ import { type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { invalidateSectionDetail } from "./B06_Section_Section_Store"; -import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; -import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { - clearAlignmentDrafts, - readAlignmentDraft, -} from "../B05_Profile/B05_Profile_UI_Profile_Edit"; -import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; -import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { saveDesign, type DesignSaveContext } from "../A00_Common/design/design_save"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; import { @@ -423,102 +416,64 @@ export function collectSectionEdits(ctx: SectionPersistContext): { }; } -/** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */ -async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise { - if (routeId === null) return; - const draft = readAlignmentDraft(routeId); - if (!draft) return; - await saveProfileAlignment(projectId, routeId, draft); - clearAlignmentDrafts(); -} - -/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */ -async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise { - // ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도 - // 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가· - // 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로 - // 하면 스냅샷이 B06 편집을 통째로 덮는다. - await flushPendingPipes(projectId).catch((error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`배수관 저장에 실패했습니다.${detail}`, "error"); - }); - // 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 - // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). - await ctx.flushCulvertOptions(); - // B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만 - // 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다 - // (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래 - // 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다. - await flushUphillOverrides(projectId).catch(() => undefined); - // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 - // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 - // (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). - // 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서 - // 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서 - // 저장하면 계획선 편집이 다음 진입 때 사라졌다. - await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`계획선 저장에 실패했습니다.${detail}`, "error"); - }); - await flushPendingStructures(projectId).catch((error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`구조물 저장에 실패했습니다.${detail}`, "error"); - }); +/** + * B06 의 저장 한 줄기 창구(계획서 24-2a) — 차례는 `saveDesign` 이 정하고(B05 [임시저장]과 같음), + * 여기서는 B06 몫만 준다: 저장 앞 전 측점 재계산 · 조정창 구간값 · 횡단 본문 · 뒤처리. + */ +function sectionSaveContext(ctx: SectionPersistContext, projectId: string): DesignSaveContext { + return { + projectId, + routeId: ctx.routeId, + // ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤 + // 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측). + prepare: () => ctx.reconcileDesigns(), + // 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로). + steps: { culvertOptions: () => ctx.flushCulvertOptions() }, + cross: async (confirm) => { + const routeId = ctx.routeId(); + if (routeId === null) return; + const edits = collectSectionEdits(ctx); + await (confirm ? confirmSections : saveSections)( + projectId, + routeId, + ctx.standardValues(), + edits.crossPatches.length ? edits.crossPatches : undefined, + edits.massHaul, + ); + }, + done: (confirm) => { + if (confirm) { + showToast(L("B06_Profile_Confirm_Success"), "success"); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]); + return; + } + // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 + // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 + // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). + const routeId = ctx.routeId(); + if (routeId !== null) invalidateSectionDetail(projectId, routeId); + showToast(L("B06_Profile_Save_Success"), "success"); + }, + failed: (error, confirm) => { + if (confirm) { + const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); + showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); + return; + } + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); + }, + }; } /** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */ export async function saveCurrentSections(ctx: SectionPersistContext): Promise { - const routeId = ctx.routeId(); - if (!ctx.projectId || routeId === null) return; - showLoadingOverlay(); - try { - // ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤 - // 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측). - await ctx.reconcileDesigns(); - await flushPendingEdits(ctx, ctx.projectId); - const edits = collectSectionEdits(ctx); - await saveSections( - ctx.projectId, - routeId, - ctx.standardValues(), - edits.crossPatches.length ? edits.crossPatches : undefined, - edits.massHaul, - ); - // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 - // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 - // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). - invalidateSectionDetail(ctx.projectId, routeId); - showToast(L("B06_Profile_Save_Success"), "success"); - } catch (error) { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); - } finally { - hideLoadingOverlay(); - } + if (!ctx.projectId) return; + await saveDesign(sectionSaveContext(ctx, ctx.projectId)); } /** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */ export async function confirmCurrentSections(ctx: SectionPersistContext): Promise { - const routeId = ctx.routeId(); - if (!ctx.projectId || routeId === null) return; - showLoadingOverlay(); - try { - await ctx.reconcileDesigns(); // 저장과 같은 이유 — 옛 면적이 정본으로 굳는 것을 막는다 - await flushPendingEdits(ctx, ctx.projectId); - const edits = collectSectionEdits(ctx); - await confirmSections( - ctx.projectId, - routeId, - ctx.standardValues(), - edits.crossPatches.length ? edits.crossPatches : undefined, - edits.massHaul, - ); - showToast(L("B06_Profile_Confirm_Success"), "success"); - goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]); - } catch (error) { - const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); - showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); - } finally { - hideLoadingOverlay(); - } + if (!ctx.projectId) return; + await saveDesign(sectionSaveContext(ctx, ctx.projectId), { confirm: true }); } diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 37b2e7a01..85dfc190b 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -1,25 +1,22 @@ /* ============================================================================= * B06_Section_UI_Section_View.ts - * 종·횡단 도면 뷰 컨트롤러: 상단 패널(종단면도 + 유토곡선)과 횡단 카드 그리드를 조립하고, - * 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 패널 높이 조절, 같은 행 높이 통일, - * 단건 카드 갱신을 관장한다. + * 횡단 보기 컨트롤러: 위 칸 = 횡단 카드 그리드(그 안에서 스크롤) · 가운데 아래쪽 = + * 종단도 한 벌(종단 보기와 같은 자리 · 같은 도구, 계획서 24-0 · 24-2c)을 조립하고, + * 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 같은 행 높이 통일, 단건 카드 갱신을 + * 관장한다. * - * 700줄 제한 대응으로 렌더러 본체는 분리했다: + * 나눈 자리: * - 공통 상수·유틸: `_UI_Section_Common` - * - 종단 렌더러: `_UI_Longitudinal` - * - 유토곡선 계산: `_UI_MassHaul` - * - 유토곡선 렌더러: `_UI_MassHaul_View` + * - 종단도 이음: `_UI_Section_View_Profile`(종단도 한 벌 `design_profile`) * - 횡단 카드 렌더러: `_UI_Cross_View` * 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다. * ========================================================================== */ import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; -import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; -import { createPanelResizer } from "@ui/ui_template_resizer"; -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import type { CrossSection, EarthworkConversion, + HaulEquipmentLimit, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; @@ -42,33 +39,9 @@ import { effectiveCardHalfWidth, } from "./B06_Section_UI_Cross_View_Metrics"; import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; -import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; -import { - attachWindowScroll, - createElevationHold, - drawLongitudinalPanel, - type LongitudinalPanelInput, -} from "./B06_Section_UI_Section_View_Draw"; -import { - type StructureInstance, - type StructureType, -} from "../B05_Profile/B05_Profile_Api_Structures"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; -import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; -import { type ElevationWindowResult } from "@util/common_util_chart_ywindow"; -import { - BASE_PANEL_HEIGHT, - chartHeights, - MAX_PANEL_HEIGHT_RATIO, - MIN_LONG_HEIGHT, - MIN_PANEL_HEIGHT, - PANEL_CHROME_PX, - PANEL_HEIGHT_KEY, -} from "./B06_Section_UI_Section_View_Panel"; import { CROSS_GRID_GAP, CROSS_GRID_MIN_WIDTH, @@ -82,6 +55,7 @@ import { } from "./B06_Section_UI_Section_Common"; import type { SectionViewController } from "./B06_Section_UI_Section_View_Types"; import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice"; +import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController }; @@ -103,6 +77,10 @@ export function createSectionView( ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; + /** 위 칸 — 횡단 카드는 **이 안에서** 스크롤한다(종단도는 아래에 붙박이, 계획서 24-0). */ + const cards = document.createElement("div"); + cards.className = "b06-section__cards"; + root.append(cards); let currentDetail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; /** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */ @@ -110,192 +88,54 @@ export function createSectionView( let currentExaggeration = 1; let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; - let currentConversion: EarthworkConversion | undefined; - let currentNaturalSpoilSlope: number | undefined; - let markStructures: ReadonlyArray = []; - let markTypes: ReadonlyArray = []; let structureEdit: SectionStructureEdit | null = null; - let gradeEdit: (() => LongitudinalPanelInput["grade"]) | null = null; let renderWidth = 0; let resizeTimer = 0; - let panelResizeTimer = 0; - // 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다). - let lastChartAvailable = 0; - let chartFitScheduled = false; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). const cachedRowHeight = new Map(); + /** 종단도에 넘길 값 — 다시 그릴 때마다 같은 값을 다시 준다. */ + let profileInput: { + balloonScope: string; + conversion?: EarthworkConversion; + haulLimits?: HaulEquipmentLimit[]; + naturalSpoilMinSlope?: number; + } = { balloonScope: "-" }; - /* ── 상단 슬라이드 패널(종단면도 + 유토곡선) ───────────────────────── - * 패널·리사이저는 한 번만 만들고 draw()에서는 차트 내용만 갈아 끼운다. - * 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */ - const panel = document.createElement("section"); - // 접기/펴기는 B05 하단 패널과 같은 방식이다 — 전역 `ui-collapsible` 규칙에 기대지 않고 - // `is-collapsed`에 걸 자체 CSS로 높이를 0으로 접고 자식을 숨긴다(손잡이만 남는다). - panel.className = "b06-section__panel"; - // 최소 높이는 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 = "b06-section__panel-status"; - const panelCount = document.createElement("span"); - panelHeader.append(panelCount); - const chartWrap = document.createElement("div"); - chartWrap.className = "b06-section__chart-wrap"; - const panelBody = document.createElement("div"); - panelBody.className = "b06-section__panel-body"; - panelBody.append(chartWrap); - // 접기 손잡이는 패널 **하단 가운데**에 둔다(2026-08-02 사용자 지시). 모양은 좌측 사이드 - // 패널과 같은 삼각형 손잡이를 90° 돌린 것이며, 위로 접히는 패널이라 `collapseToward: "up"`이다. - // - // 공용 `ui-collapsible__title`은 **쓰지 않는다**. 그 클래스를 달면 전역 규칙이 캐럿(▾/▸)을 - // 하나 더 그리는데, 접힘 상태 전역 선택자(`.ui-collapsible.is-collapsed > .ui-collapsible__title`)가 - // B06 쪽 무력화 규칙보다 특정도가 높아 캐럿이 삼각형 아이콘과 겹쳐 보였다. 토글은 여기서 직접 한다. - const panelToggle = createWorkflowPanelHandle("bottom", "up", "종단/유토곡선"); - panelToggle.root.classList.add("b06-section__panel-toggle"); - panel.append(panelHeader, panelBody, panelToggle.root); - // 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙). - if (readStateRaw("section-panel-collapsed") === "true") panel.classList.add("is-collapsed"); - panelToggle.setOpen(!panel.classList.contains("is-collapsed")); - panelToggle.root.addEventListener("click", () => { - const collapsed = panel.classList.toggle("is-collapsed"); - writeStateRaw("section-panel-collapsed", String(collapsed)); - panelToggle.setOpen(!collapsed); - // 펼칠 때는 레이아웃이 잡힌 **다음 프레임**에 그린다 — 그래프 몫을 실측으로 잡기 때문에 - // 같은 프레임에 그리면 접혀 있던 0 높이를 읽는다(B05 하단 패널과 같은 처리). - if (!collapsed) requestAnimationFrame(drawPanel); - }); - - const panelResizer = createPanelResizer({ - axis: "vertical", - target: panel, - cssVar: "--b06-profile-height", - // 손잡이가 패널 아래쪽에 있으므로 아래로 끌면 커진다. - direction: 1, - min: MIN_PANEL_HEIGHT, - // 부모(`.b06-profile__main`)는 횡단 카드까지 담은 스크롤 콘텐츠라 높이가 수천 px다. - // 이 패널은 화면 위에 sticky로 붙어 있으니 상한은 화면 높이 기준이어야 한다. - max: () => window.innerHeight * MAX_PANEL_HEIGHT_RATIO, - storageKey: PANEL_HEIGHT_KEY, - onResize: () => { - // 끄는 동안 매 프레임 SVG를 다시 그리면 손이 늦게 따라온다 — 멈춘 뒤 한 번만 그린다. - window.clearTimeout(panelResizeTimer); - panelResizeTimer = window.setTimeout(() => { - drawPanel(); - // 패널이 커지면 선택 카드가 그 아래로 들어간다 — 새 패널 높이에 맞춰 다시 끌어올린다. - revealCard(selectedStationId, "auto"); - }, 120); - }, - }); - panel.append(panelResizer.root); - // 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로. + // 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로. const fillSlopeNotice = createFillSlopeNotice((id) => selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), ); - /** - * 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시). - * - * 끝에 닿았다고 기본 동작을 넘기면 아래 횡단도 목록이 대신 스크롤돼 화면이 튄다 — 패널 위에 - * 커서가 있는 동안은 세로 스크롤을 통째로 막는다. 캡처 단계에서 잡는 이유는 패널 안의 다른 - * 가로 스크롤 상자(범례 줄)가 먼저 먹어 버리지 않게 하기 위해서다. - */ - panel.addEventListener( - "wheel", - (event) => { - if (event.ctrlKey) return; // 브라우저 확대 제스처는 건드리지 않는다 - event.preventDefault(); - const room = chartWrap.scrollWidth - chartWrap.clientWidth; - const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; - if (room <= 1 || !delta) return; - chartWrap.scrollLeft = Math.min(Math.max(chartWrap.scrollLeft + delta, 0), room); + /** 종단도 한 벌 — 첫 그리기에서 프로젝트를 알게 되면 만든다(`_Section_View_Profile`). */ + const profile = createSectionProfile({ + selectCard: (stationId) => { + if (stationId === null) clearSelection(); + else if (stationId !== selectedStationId) selectStation(stationId, true); + else revealCard(stationId, "smooth"); }, - { passive: false, capture: true }, - ); - - /** - * 가운데 버튼 드래그로 그래프를 좌우로 민다(2026-08-02 사용자 지시). 좌클릭은 측점 선택 몫이다. - * 가운데 버튼을 누르면 브라우저가 자동 스크롤을 켜는데, 그건 `mousedown` 기본동작이라 - * `pointerdown`에서는 못 막는다 — `mousedown`/`auxclick`에서 막아야 팬만 남는다. - */ - let panPointerId: number | null = null; - let panLastX = 0; - chartWrap.addEventListener("mousedown", (event) => { - if (event.button === 1) event.preventDefault(); + edit: () => structureEdit, + refreshCards: (chainages) => refreshCards(chainages), }); - chartWrap.addEventListener("auxclick", (event) => { - if (event.button === 1) event.preventDefault(); - }); - chartWrap.addEventListener("pointerdown", (event) => { - if (event.button !== 1) return; - event.preventDefault(); - panPointerId = event.pointerId; - panLastX = event.clientX; - chartWrap.classList.add("is-panning"); - chartWrap.setPointerCapture(event.pointerId); - }); - chartWrap.addEventListener("pointermove", (event) => { - if (panPointerId !== event.pointerId) return; - chartWrap.scrollLeft -= event.clientX - panLastX; - panLastX = event.clientX; - }); - const endChartPan = (event: PointerEvent): void => { - if (panPointerId !== event.pointerId) return; - panPointerId = null; - chartWrap.classList.remove("is-panning"); - try { - chartWrap.releasePointerCapture(event.pointerId); - } catch { - /* 이미 해제됨 */ - } - }; - chartWrap.addEventListener("pointerup", endChartPan); - chartWrap.addEventListener("pointercancel", endChartPan); - - const panelHeight = (): number => { - // 첫 렌더 때 panel은 아직 root에 붙기 전(detached)이라 getComputedStyle이 빈 값을 준다. - // 리사이저가 복원해 둔 인라인 변수부터 읽어야 저장된 높이가 첫 화면부터 반영된다. - // (computed만 읽으면 기본값으로 폴백해 종단도가 잘린 채로 그려진다 — C05 검증 F1) - const inline = Number.parseFloat(panel.style.getPropertyValue("--b06-profile-height")); - if (Number.isFinite(inline) && inline > 0) return inline; - const raw = Number.parseFloat(getComputedStyle(panel).getPropertyValue("--b06-profile-height")); - return Number.isFinite(raw) && raw > 0 ? raw : BASE_PANEL_HEIGHT; - }; const contentWidth = (): number => { // 요소가 DOM 밖(detached)이면 computed padding이 ""라 parseFloat이 NaN을 만든다. - // NaN이 renderWidth로 흘러가면 종단 SVG 전 좌표가 NaN이 되므로 0으로 방어한다. - const style = getComputedStyle(root); + // NaN이 renderWidth로 흘러가면 카드 폭이 NaN이 되므로 0으로 방어한다. + const style = getComputedStyle(cards); const pad = (value: string): number => Number.parseFloat(value) || 0; - const width = root.clientWidth - pad(style.paddingLeft) - pad(style.paddingRight); + const width = cards.clientWidth - pad(style.paddingLeft) - pad(style.paddingRight); return Number.isFinite(width) ? Math.max(0, width) : 0; }; /** - * 상단 패널이 sticky라 카드 스크롤 정지선은 `scroll-margin-top`(패널 높이 + 손잡이 몫)이다. - * 패널은 리사이저·접기 손잡이로 수시로 높이가 변하고, 접을 때는 drawPanel이 돌지 않으므로 - * **스크롤 직전에 실측**해야 정지선이 현재 패널과 맞는다. - */ - const syncScrollMargin = (): void => { - // 접기 손잡이가 패널 아래 24px 밖으로 나와 있고, 선택 강조(빨간 테두리 + 2px ring)가 - // 잘리지 않도록 8px 여유를 더한다. - const panelPx = panel.getBoundingClientRect().height || panelHeight(); - root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 32)}px`); - }; - - /** - * 선택 카드를 화면에 남긴다. `nearest`는 카드가 아래에 있을 때 카드 **하단**을 브라우저 - * 바닥에 맞춰 멈추는데, 그러면 카드 상단(측점 라벨)이 화면 밖이다(2026-08-04 사용자 지적). - * `start`로 카드 **상단**을 접기 손잡이 바로 아래 정지선에 붙인다. 목록 맨 끝 카드는 - * 스크롤이 바닥에 걸려 그 자리까지 못 올라오며, 그대로 둔다(브라우저가 알아서 멈춘다). + * 선택 카드를 위 칸 안에서 보이게 한다 — `start`로 카드 **상단**(측점 라벨)을 칸 위에 + * 붙인다(2026-08-04 사용자 지적). 목록 맨 끝 카드는 스크롤이 바닥에 걸려 그 자리까지 못 + * 올라오며, 그대로 둔다(브라우저가 알아서 멈춘다). */ const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => { if (!stationId) return; - syncScrollMargin(); document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "start" }); }; @@ -322,19 +162,21 @@ export function createSectionView( }; if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights; + /** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */ + let stationSelectListener: ((stationId: string | null) => void) | null = null; + /** * 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**. * 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다 * (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와 - * 강조 상태만 갈아 끼운다. + * 강조 상태만 갈아 끼운다. 종단도 선택 표시는 카드를 따라간다(계획서 24-0). */ - /** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */ - let stationSelectListener: ((stationId: string | null) => void) | null = null; - const applySelection = (previousStationId: string | null): void => { markCard(previousStationId); if (selectedStationId !== previousStationId) markCard(selectedStationId); - drawPanel(); + profile.showCard( + currentDetail?.cross_sections.find((entry) => entry.station_id === selectedStationId) ?? null, + ); if (selectedStationId !== previousStationId) stationSelectListener?.(selectedStationId); }; @@ -435,80 +277,34 @@ export function createSectionView( ); }; - /** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */ - /** 최종 누가토량 배지 — 종단 그래프 좌측 상단(2026-09-06 사용자 지시). */ - const massBadge = createMassHaulBadge(); - panelBody.append(massBadge.root); - /** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */ - let updateChartWindow: (() => ElevationWindowResult | null) | null = null; - - /** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */ - function drawPanel(): void { - if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; - // 차트를 갈아 끼우면 가로 스크롤이 0으로 돌아간다 — 측점 버튼 하나 눌렀다고 - // 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다. - const keepScrollLeft = chartWrap.scrollLeft; - const detail = currentDetail; - fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침 - // 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가 - // 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다. - // 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다. - const measured = chartWrap.clientHeight; - lastChartAvailable = - measured > 0 ? measured : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT); - // 알약 레인도 `chartWrap` 안에 서므로 잰 높이에 **이미 들어 있다**. 그 몫을 빼지 않으면 - // 다음 회차에 잰 값이 그만큼 더 커지고, 그 값으로 그래프를 키우면 또 커지는 되먹임이 - // 생겨 패널이 16,000px 까지 부푼다(2026-09-07 실측). 빼 두면 한 번에 수렴한다. - const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0; - const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT)); - const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); - updateChartWindow = drawLongitudinalPanel({ - detail, - chartWrap, - renderWidth: Math.max(renderWidth, minWidth), - chartHeight: heights.long, - keepScrollLeft, - selectedStationId, - exaggeration: currentExaggeration, - stationInterval: cachedStationInterval, - structures: markStructures, - types: markTypes, - edit: structureEdit, - conversion: currentConversion, - naturalSpoilSlope: currentNaturalSpoilSlope, - selectStation: (stationId) => selectStation(stationId, true), - setMassBadge: (values) => massBadge.set(values), - grade: gradeEdit?.() ?? null, - holdRange, - }); - // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. - syncScrollMargin(); - - // 첫 렌더(detached)나 패널 높이 변경 직후에는 위에서 잰 값이 아직 옛 레이아웃일 수 있다. - // 다음 프레임에 한 번만 다시 재서 어긋나면 고쳐 그린다. `chartWrap` 높이는 내용과 무관하니 - // 되먹임 없이 한 번에 수렴한다. - if (!chartFitScheduled) { - chartFitScheduled = true; - requestAnimationFrame(() => { - chartFitScheduled = false; - const now = chartWrap.clientHeight; - if (now > 0 && Math.abs(now - lastChartAvailable) > 2) drawPanel(); - }); - } + /** 카드 면적이 바뀌었다 — 경고 줄 · 유토곡선 · 뱃지만 다시 싣는다. */ + function redrawProfile(): void { + if (!currentDetail) return; + fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval); + profile.redraw(); } - attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel); - const holdRange = createElevationHold(chartWrap, drawPanel); + /** 종단도 · 경고 줄을 지금 자료로 다시 맞춘다(카드는 안 건드린다). */ + function syncProfile(): void { + if (!currentDetail) return; + fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval); + profile.render({ + detail: currentDetail, + stationInterval: cachedStationInterval, + ...profileInput, + }); + const profileRoot = profile.root(); + if (profileRoot && profileRoot.parentElement !== root) root.append(profileRoot); + } const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; const detail = currentDetail; cachedStationInterval = currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); - drawPanel(); - // 횡단면도 제목·개수 표기는 두지 않는다(2026-08-02 사용자 지시) — 상단 패널과 카드 사이 - // 공간만 먹고, 카드마다 측점이 적혀 있어 무엇을 보는지는 이미 분명하다. + // 횡단면도 제목·개수 표기는 두지 않는다(2026-08-02 사용자 지시) — 카드마다 측점이 + // 적혀 있어 무엇을 보는지는 이미 분명하다. const grid = document.createElement("div"); grid.className = "b06-section__grid"; // 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다. @@ -558,13 +354,14 @@ export function createSectionView( } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); } - // panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다. - const keepScrollLeft = chartWrap.scrollLeft; - root.replaceChildren(panel, fillSlopeNotice.root, grid); - chartWrap.scrollLeft = keepScrollLeft; + // 위 칸 스크롤 자리를 지킨다 — 다시 그렸다고 보던 카드가 맨 앞으로 튀면 못 쓴다. + const keepScrollTop = cards.scrollTop; + cards.replaceChildren(fillSlopeNotice.root, grid); + cards.scrollTop = keepScrollTop; + syncProfile(); }; - /** 카드 한 장만 갈아 끼운다(상단 패널은 안 건드린다). 못 찾으면 false. */ + /** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */ const rebuildCard = (chainageM: number): boolean => { if (!currentDetail) return false; const section = currentDetail.cross_sections.find( @@ -579,12 +376,12 @@ export function createSectionView( }; const refreshCard = (chainageM: number): void => { - // 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음). - if (rebuildCard(chainageM)) drawPanel(); + // 단면적이 바뀌면 유토곡선도 함께 흔들리므로 종단도만 다시 그린다(카드 전체 재렌더 없음). + if (rebuildCard(chainageM)) redrawProfile(); }; /** - * 여러 측점을 한 번에 갈아 끼운다 — **상단 패널은 끝에 한 번만** 다시 그린다. + * 여러 측점을 한 번에 갈아 끼운다 — **종단도는 끝에 한 번만** 다시 그린다. * * 측점마다 `refreshCard` 를 부르면 측점 수만큼 종단 그래프·유토곡선을 다시 그려, * 계획선 ▲▼ 한 번에 화면이 몇 백 ms 씩 멈췄다(2026-09-12 B05 대비 느림 원인). @@ -592,7 +389,7 @@ export function createSectionView( const refreshCards = (chainages: ReadonlyArray): void => { let touched = false; for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true; - if (touched) drawPanel(); + if (touched) redrawProfile(); }; const resizeObserver = new ResizeObserver(() => { @@ -605,7 +402,7 @@ export function createSectionView( draw(); }, 150); }); - resizeObserver.observe(root); + resizeObserver.observe(cards); return { root, @@ -615,9 +412,7 @@ export function createSectionView( crossHalfWidth, stationInterval, earthworkConversion, - // 운반 장비 한계는 곡선(운반 블록)에만 쓰던 값이라 B06 에서는 더 받지 않는다 - // (2026-09-06 유토곡선 그래프 제거). 인자 자리는 호출부 호환을 위해 남긴다. - _haulEquipmentLimits, + haulEquipmentLimits, balloonScope, naturalSpoilMinSlope, ) { @@ -627,15 +422,21 @@ export function createSectionView( crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined; currentStationInterval = stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; - if (earthworkConversion) currentConversion = earthworkConversion; - if (Number.isFinite(naturalSpoilMinSlope)) currentNaturalSpoilSlope = naturalSpoilMinSlope; - // balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다. - configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined); + // 유토곡선 · 뱃지 설정과 balloon 자리 scope(프로젝트:노선)는 종단도 한 벌이 받는다 — + // 영구저장소 값(detail.balloon_offsets)이 이기는 규칙도 그쪽에 있다. + profileInput = { + balloonScope: balloonScope ?? "-", + conversion: earthworkConversion ?? profileInput.conversion, + haulLimits: haulEquipmentLimits ?? profileInput.haulLimits, + naturalSpoilMinSlope: Number.isFinite(naturalSpoilMinSlope) + ? naturalSpoilMinSlope + : profileInput.naturalSpoilMinSlope, + }; // 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로 // 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다. renderWidth = contentWidth(); draw(); - if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); + if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards)); }, refreshCard, refreshCards, @@ -648,15 +449,12 @@ export function createSectionView( }, setStructureEdit(edit) { structureEdit = edit; - drawPanel(); - }, - setGradeEdit(provider) { - gradeEdit = provider; - drawPanel(); }, + // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 + // 막기 규칙. 옛 제공자는 받기만 하고 쓰지 않는다(페이지 쪽 정리는 24-2a 몫). + setGradeEdit() {}, setStructureMarks(structures, types) { - markStructures = structures; - markTypes = types; + profile.setMarks(structures, types); // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 // (`__corridorBuild` 등과 같은 용도). (window as unknown as { __b06Marks?: unknown }).__b06Marks = { @@ -664,18 +462,16 @@ export function createSectionView( types: types.length, drawn: !!currentDetail, }; - if (currentDetail) drawPanel(); }, clear() { currentDetail = null; selectedStationId = null; - root.replaceChildren(); + cards.replaceChildren(); }, dispose() { window.clearTimeout(resizeTimer); - window.clearTimeout(panelResizeTimer); - panelResizer.dispose(); resizeObserver.disconnect(); + profile.dispose(); }, }; } diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts deleted file mode 100644 index 5ed3d1b67..000000000 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Section_View_Chart.ts - * B06 상단 패널의 **종단면도 몫**만 떼어 낸 조립기 (2026-09-04 · 700줄 제한). - * - * 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, B05 와 같은 규칙·같은 - * 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. 스크롤할 때 그래프를 다시 - * 만들지 않고 세로 창을 **변환으로** 옮기는 것도 B05 와 같다 - * (`common_util_chart_ywindow`). - * ========================================================================== */ - -import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { createLongitudinalProfile } from "./B06_Section_UI_Longitudinal"; -import { - LONG_PAD, - longitudinalMaxChainage, - windowElevationRange, -} from "./B06_Section_UI_Section_Common"; -import { unwrapChart } from "./B06_Section_UI_Section_View_Panel"; - -export interface LongitudinalChartInput { - detail: SectionDetailResponse; - selectedStationId: string | null; - /** 세로 과장(사용자 조절값). */ - exaggeration: number; - selectStation: (stationId: string) => void; - stationInterval: number; - chartWidth: number; - chartHeight: number; - minWidth: number; - /** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */ - scrollLeft: number; - viewportWidth: number; - /** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */ - onDragStation?: (stationId: string, toChainageM: number) => void; - /** X축·측점 라벨을 바닥에서 이만큼(px) 올린다 — 계획고 편집 ▼ 버튼과 겹치지 않게. */ - bottomInsetPx?: number; - /** 세로 창을 가로채는 자리 — 계획고를 ▲▼ 로 만지는 동안 창을 **고정**하는 데 쓴다. - * 안 넘기면 늘 보이는 구간에 맞춘다(종전 동작). */ - holdRange?: ( - next: { min: number; max: number } | null, - ) => { min: number; max: number } | undefined; -} - -export interface LongitudinalChartResult { - node: Element; - axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null; - /** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */ - toChainage: (px: number) => number; - /** 누가거리(m) → 화면 x(px). 구조물 알약 레인이 그래프와 같은 자리를 쓰게 한다. */ - toX: (chainageM: number) => number; - maxChainageM: number; - viewFromM: number; - viewToM: number; -} - -/** 지금 보이는 구간(누가거리 m)을 잰다 — 그릴 때와 스크롤 갱신 때가 **같은 식**을 쓴다. */ -export function visibleChainageRange( - toChainage: (px: number) => number, - maxChainageM: number, - scrollLeft: number, - viewportWidth: number, -): { fromM: number; toM: number } { - return { - fromM: Math.max(0, toChainage(scrollLeft)), - toM: Math.min(maxChainageM, toChainage(scrollLeft + viewportWidth)), - }; -} - -/** 보이는 구간의 표고 범위 — 종단 세로 창의 기준. */ -export function visibleElevationRange( - detail: SectionDetailResponse, - fromM: number, - toM: number, -): { min: number; max: number } | null { - return ( - windowElevationRange( - [ - detail.longitudinal.samples, - ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), - ], - fromM, - toM, - ) ?? null - ); -} - -export function buildLongitudinalChart(input: LongitudinalChartInput): LongitudinalChartResult { - const { detail } = input; - const maxChainageM = longitudinalMaxChainage(detail.longitudinal); - const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right); - const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; - const toX = (chainageM: number): number => - LONG_PAD.left + (maxChainageM > 0 ? (chainageM / maxChainageM) * plotWidth : 0); - const { fromM, toM } = visibleChainageRange( - toChainage, - maxChainageM, - input.scrollLeft, - input.viewportWidth, - ); - - // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 - // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). - let axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; - const node = unwrapChart( - createLongitudinalProfile( - detail.longitudinal, - input.selectedStationId, - input.exaggeration, - // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. - undefined, - input.selectStation, - input.stationInterval, - input.chartWidth, - input.chartHeight, - input.minWidth, - detail.longitudinal.design_profiles ?? [], - 0, - (next) => { - axis = next; - }, - undefined, - // 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화). - input.onDragStation, - input.bottomInsetPx ?? 0, - 0, - input.holdRange - ? input.holdRange(visibleElevationRange(detail, fromM, toM)) - : (visibleElevationRange(detail, fromM, toM) ?? undefined), - ), - ); - return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM }; -} diff --git a/B06_Section/B06_Section_UI_Section_View_Draw.ts b/B06_Section/B06_Section_UI_Section_View_Draw.ts deleted file mode 100644 index 96d518240..000000000 --- a/B06_Section/B06_Section_UI_Section_View_Draw.ts +++ /dev/null @@ -1,282 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Section_View_Draw.ts - * B06 상단 패널의 **종단 그래프 한 벌**을 세운다 — 그래프·구조물 알약 레인·우클릭 메뉴· - * 누가토량 배지·고정 Y축, 그리고 가로 스크롤에 따라붙는 세로 창 갱신기까지. - * - * 왜 갈랐나 — 종단 화면 본체(`_Section_View`)가 700줄을 넘겨(CLAUDE.md 4장) 기능별로 - * 나눠야 했다. 이 파일은 **그리는 몫만** 맡고, 높이 재기·되먹임 보정·선택 상태는 본체가 - * 그대로 쥔다. 넘겨받는 값은 전부 인자라 여기에는 화면 상태가 남지 않는다. - * ========================================================================== */ - -import type { EarthworkConversion, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; -import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; -import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; -import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; -import { computeMassHaulSeries } from "@util/common_util_mass_haul"; -import { badgeValuesFrom } from "@util/common_util_mass_haul_badge"; -import { - applyElevationWindow, - needsFullRedraw, - Y_AXIS_WINDOW_CLASS, - type ElevationWindowResult, -} from "@util/common_util_chart_ywindow"; -import { LONG_PAD } from "./B06_Section_UI_Section_Common"; -import { - buildLongitudinalChart, - visibleChainageRange, - visibleElevationRange, -} from "./B06_Section_UI_Section_View_Chart"; -import { - mountSectionStructureMenu, - moveMarkById, - type SectionStructureEdit, -} from "./B06_Section_UI_Section_View_Menu"; -import { createEditOverlay } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; -import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; - -/** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */ -export interface LongitudinalPanelInput { - detail: SectionDetailResponse; - /** 그래프가 들어앉는 칸. 내용은 이 함수가 통째로 갈아 끼운다. */ - chartWrap: HTMLElement; - /** 본체가 잰 그릴 폭·높이. */ - renderWidth: number; - chartHeight: number; - /** 갈아 끼우기 전 가로 스크롤 자리 — 되돌려 놓는다. */ - keepScrollLeft: number; - selectedStationId: string | null; - exaggeration: number; - stationInterval: number; - structures: ReadonlyArray; - types: ReadonlyArray; - /** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭·끌기를 안 붙인다). */ - edit: SectionStructureEdit | null; - conversion?: EarthworkConversion; - naturalSpoilSlope?: number; - selectStation: (stationId: string) => void; - /** 세로 창 가로채기 — 계획고를 만지는 동안 창을 고정한다(B05 와 같은 장치). */ - holdRange?: ( - next: { min: number; max: number } | null, - ) => { min: number; max: number } | undefined; - /** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/▼ 버튼층이 선다(B05 와 같은 부품). - * 안 넘기면 버튼이 없고 그래프만 보인다(옛 저장분처럼 선형이 없는 노선). */ - grade?: { - alignment: ProfileAlignment; - /** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */ - stepM: number; - onStation: (chainageM: number, delta: number) => void; - } | null; - /** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */ - setMassBadge: (values: ReturnType | null) => void; -} - -/** 알약 레인이 설 높이(px) — 본체가 그릴 높이를 셈할 때 같은 값을 써야 한다. */ -export { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; - -/** - * 종단 그래프 한 벌을 세우고 **세로 창 갱신기**를 돌려준다. - * 갱신기는 가로 스크롤마다 불러 보이는 구간에 세로를 맞추는 데 쓴다. - */ -export function drawLongitudinalPanel( - input: LongitudinalPanelInput, -): () => ElevationWindowResult | null { - const { detail, chartWrap, structures, types, edit } = input; - const chartWidth = input.renderWidth; - - /** 측점선·알약을 끌어 놓았을 때 — 관인지 구조물인지는 공용 판별이 가른다. */ - const moveMark = (markId: string, toChainageM: number): void => { - if (!edit) return; - moveMarkById(markId, toChainageM, { - structures, - stations: detail.longitudinal.stations ?? [], - edit, - }); - }; - - const chart = buildLongitudinalChart({ - detail, - selectedStationId: input.selectedStationId, - exaggeration: input.exaggeration, - selectStation: input.selectStation, - stationInterval: input.stationInterval, - chartWidth, - chartHeight: input.chartHeight, - minWidth: chartWidth, - scrollLeft: input.keepScrollLeft, - viewportWidth: chartWrap.clientWidth || chartWidth, - // 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다 - // (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다. - bottomInsetPx: input.grade ? 15 : 0, - holdRange: input.holdRange, - // 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동. - onDragStation: edit ? moveMark : undefined, - }); - - const nodes: Element[] = [chart.node]; - // 구조물 알약 레인 — B05 종단과 **같은 부품**을 그대로 쓴다. 배수관·세월교도 같은 - // 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). - if (structures.length && types.length) { - nodes.push( - buildStructureLane({ - structures, - types, - x: chart.toX, - chainageAt: chart.toChainage, - maxChainageM: chart.maxChainageM, - widthPx: chartWidth, - axisWidthPx: LONG_PAD.left, - stationIntervalM: input.stationInterval, - selectedId: null, - // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. - onSelect: (structureId) => { - if (!structureId) return; - const hit = structures.find((entry) => entry.structure_id === structureId); - if (!hit) return; - const at = structureAnchorM(hit); - let best: { id: string; gap: number } | null = null; - for (const section of detail.cross_sections) { - const gap = Math.abs(section.chainage_m - at); - if (!best || gap < best.gap) best = { id: section.station_id, gap }; - } - if (best) input.selectStation(best.id); - }, - // 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서. - onMove: moveMark, - }), - ); - } - - chartWrap.replaceChildren(...nodes); - // 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자: - // B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에 - // 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다. - if (input.grade) { - const editLayer = createEditOverlay({ - alignment: input.grade.alignment, - width: chartWidth, - x: chart.toX, - step: input.grade.stepM, - onStation: input.grade.onStation, - }); - // 버튼층은 `inset: 0` 으로 부모를 꽉 채운다 — B05 는 부모가 그래프뿐이지만 B06 은 - // **알약 레인도 같은 칸 안**에 있어, 그대로 두면 ▼ 가 레인 위로 밀려난다(실측: - // 그래프 바닥 231px 인데 버튼이 270px). 그래프 높이로 잘라 B05 와 같은 자리에 세운다. - editLayer.style.height = `${input.chartHeight}px`; - editLayer.style.bottom = "auto"; - chartWrap.append(editLayer); - } - // 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군 - // → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다). - if (edit) { - mountSectionStructureMenu(chartWrap, { - structures, - types, - x: chart.toX, - chainageAt: chart.toChainage, - maxChainageM: chart.maxChainageM, - edit, - }); - } - - // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 - // 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서 - // 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단). - if (input.conversion) { - const series = computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - input.conversion, - input.naturalSpoilSlope, - ); - const cross = series.find((entry) => entry.basis === "cross") ?? series[0]; - input.setMassBadge(cross ? badgeValuesFrom(cross.result) : null); - } else { - input.setMassBadge(null); - } - - // 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 - // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). - if (chart.axis) { - const overlay = buildStickyYAxis(chart.axis, input.chartHeight); - // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). - overlay.classList.add(Y_AXIS_WINDOW_CLASS); - chartWrap.prepend(overlay); - } - chartWrap.scrollLeft = input.keepScrollLeft; - - // 세로 창 갱신기 — 스크롤마다 종단을 변환으로 따라오게 한다(다시 그리지 않는다). - return () => { - const { fromM, toM } = visibleChainageRange( - chart.toChainage, - chart.maxChainageM, - chartWrap.scrollLeft, - chartWrap.clientWidth || chartWidth, - ); - const next = visibleElevationRange(detail, fromM, toM); - return applyElevationWindow( - chartWrap, - input.holdRange ? input.holdRange(next) : (next ?? undefined), - ); - }; -} - -/** 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ -const SCROLL_SETTLE_MS = 80; - -/** - * 가로 스크롤에 세로 창을 따라붙인다 — **한 번만** 부른다(리스너를 남긴다). - * - * 세로 맞춤은 그리기가 아니라 **변환**이다(2026-09-04 사용자 확정, B05 와 같은 규칙). - * 변환으로 못 살리는 자리에서만 스크롤이 멎은 뒤 한 번 다시 그린다. - */ -export function attachWindowScroll( - chartWrap: HTMLElement, - updater: () => (() => ElevationWindowResult | null) | null, - redraw: () => void, -): void { - let settledScrollLeft = 0; - let timer = 0; - chartWrap.addEventListener("scroll", () => { - const fitted = updater()?.() ?? null; - window.clearTimeout(timer); - if (!needsFullRedraw(fitted)) return; - timer = window.setTimeout(() => { - if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; - settledScrollLeft = chartWrap.scrollLeft; - redraw(); - }, SCROLL_SETTLE_MS); - }); -} - -/** - * 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다(B05 와 같은 장치). - * - * 안 그러면 창이 새 계획선에 맞춰 다시 잡혀, 값은 바뀌는데 선은 제자리에 있는 것처럼 - * 보인다(2026-09-12 사용자 보고: 「버튼을 눌러도 계획 종단선이 안 바뀐다」). 손을 뗀 뒤 - * 한 번만 다시 맞춘다. - */ -export function createElevationHold( - chartWrap: HTMLElement, - redraw: () => void, -): (next: { min: number; max: number } | null) => { min: number; max: number } | undefined { - let editing = false; - let held: { min: number; max: number } | undefined; - chartWrap.addEventListener("pointerdown", (event) => { - if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; - editing = true; - const release = (): void => { - editing = false; - window.removeEventListener("pointerup", release); - window.removeEventListener("pointercancel", release); - redraw(); - }; - window.addEventListener("pointerup", release); - window.addEventListener("pointercancel", release); - }); - return (next) => { - if (editing) return held; - held = next ?? undefined; - return held; - }; -} diff --git a/B06_Section/B06_Section_UI_Section_View_Panel.ts b/B06_Section/B06_Section_UI_Section_View_Panel.ts deleted file mode 100644 index 421f0fb25..000000000 --- a/B06_Section/B06_Section_UI_Section_View_Panel.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { MASS_HAUL_DEFAULT_VISIBLE, normalizeVisibleBasis } from "@util/common_util_mass_haul"; -import { readStateRaw, stateKey } from "../A00_Common/b_page_state"; -import { LONG_HEIGHT } from "./B06_Section_UI_Section_Common"; - -export const PANEL_CHROME_PX = 102; -/* 유토곡선 그래프를 B06 에서 뺐다(2026-09-06 사용자 지시) — 패널 높이도 종단면도 몫만 - 잡는다. 곡선은 B05 유토곡선 패널에서 보고, 여기서는 좌측 상단 배지가 총괄값을 보인다. */ -export const BASE_PANEL_HEIGHT = LONG_HEIGHT + PANEL_CHROME_PX; -export const MIN_LONG_HEIGHT = 110; -export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + PANEL_CHROME_PX; -export const MAX_PANEL_HEIGHT_RATIO = 0.8; -/* 패널 접힘·높이·유토곡선 범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. - 범례 키는 B05 와 **같은 값**이어야 한다(두 화면이 같은 그림의 두 창). */ -export const PANEL_HEIGHT_KEY = stateKey("section-panel-height") ?? ""; - -/** 처음 열 때는 **곡선만** — 토량 분배는 범례에서 켠다(2026-09-06 사용자 지시). - * B05 `_UI_Profile_MassHaul` 과 같은 값이어야 한다(두 화면 공용 키). */ -const DEFAULT_VISIBLE_KEYS = [...MASS_HAUL_DEFAULT_VISIBLE]; - -export function readVisibleSeries(): Set { - try { - const raw = readStateRaw("masshaul-visible"); - if (!raw) return new Set(DEFAULT_VISIBLE_KEYS); - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) - ? normalizeVisibleBasis(new Set(parsed.map(String))) - : new Set(DEFAULT_VISIBLE_KEYS); - } catch { - return new Set(DEFAULT_VISIBLE_KEYS); - } -} - -/** 종단면도 높이 — 유토곡선을 빼면서 남는 자리를 **전부** 종단이 쓴다(2026-09-06). */ -export function chartHeights(availableHeightPx: number): { long: number; mass: number } { - if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) { - return { long: LONG_HEIGHT, mass: 0 }; - } - return { long: Math.max(MIN_LONG_HEIGHT, availableHeightPx), mass: 0 }; -} - -export function unwrapChart(node: HTMLElement): Element { - return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild - ? node.firstElementChild - : node; -} diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts new file mode 100644 index 000000000..4233ac5e6 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Profile.ts + * 횡단 보기의 종단도 — 종단 보기와 **같은 종단도 한 벌**(`design_profile`)을 가운데 + * 아래쪽에 두고, 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c). + * + * 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다. + * 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙 + * (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다. + * ========================================================================== */ + +import { createDesignProfile, type DesignProfile } from "../A00_Common/design/design_profile"; +import type { + CrossSection, + EarthworkConversion, + HaulEquipmentLimit, + SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { + irregularStationId, + isPipeStation, + type IrregularStation, +} from "../B05_Profile/B05_Profile_UI_IrregularStations"; +import { + isPipeMark, + moveMarkById, + type SectionStructureEdit, +} from "./B06_Section_UI_Section_View_Menu"; + +/** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ +const MATCH_M = 0.51; +/** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */ +const LEGACY_TYPE_IDS: Record = { + 기성막이: "revetment", + 대피로: "refuge", + 기타: "etc", +}; + +export interface SectionProfileHost { + /** 종단도에서 측점을 골랐다 — 그 카드를 고르고 보이게 한다(null = 해제). */ + selectCard: (stationId: string | null) => void; + /** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭 · 끌기가 아무것도 안 함). */ + edit: () => SectionStructureEdit | null; + /** 계획고 편집으로 다시 계산한 측점 카드만 갈아 끼운다. */ + refreshCards: (chainages: ReadonlyArray) => void; +} + +export interface SectionProfile { + root: () => HTMLElement | null; + render: (input: { + detail: SectionDetailResponse; + balloonScope: string; + stationInterval: number; + conversion?: EarthworkConversion; + haulLimits?: HaulEquipmentLimit[]; + naturalSpoilMinSlope?: number; + }) => void; + setMarks: ( + structures: ReadonlyArray, + types: ReadonlyArray, + ) => void; + /** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */ + redraw: () => void; + /** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */ + showCard: (section: CrossSection | null) => void; + dispose: () => void; +} + +/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ +function pipeStations( + marks: ReadonlyArray, + types: ReadonlyArray, + intervalM: number, +): IrregularStation[] { + return marks + .filter((mark) => isPipeMark(mark) && mark.type_id !== "ford_pavement") + .map((mark) => { + const chainage = structureAnchorM(mark); + const station = Math.floor(chainage / intervalM); + return { + id: String(mark.structure_id), + station, + remainder: chainage - station * intervalM, + chainage_m: chainage, + structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", + structureType: "배관", + origin: "pipe", + } as IrregularStation; + }); +} + +export function createSectionProfile(host: SectionProfileHost): SectionProfile { + let profile: DesignProfile | null = null; + let projectId: string | null = null; + let detail: SectionDetailResponse | null = null; + let marks: ReadonlyArray = []; + let types: ReadonlyArray = []; + let stations: IrregularStation[] = []; + let intervalM = 20; + + /** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */ + const nearestCard = (chainageM: number): CrossSection | null => { + let best: CrossSection | null = null; + for (const section of detail?.cross_sections ?? []) { + if (!best || Math.abs(section.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM)) + best = section; + } + return best; + }; + + /** 종단도 측점 id → 카드 id. 관 측점(`irregular:`)은 누가거리로 카드를 찾는다. */ + const cardOf = (stationId: string | null): string | null => { + if (stationId === null) return null; + const pipe = stations.find((entry) => irregularStationId(entry.id) === stationId); + if (pipe) return nearestCard(pipe.chainage_m)?.station_id ?? null; + return stationId; + }; + + const syncMarks = (): void => { + if (!profile) return; + profile.setStructureTypes([...types]); + profile.setStructures([...marks]); + stations = pipeStations(marks, types, intervalM); + profile.setIrregularStations(stations); + // 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다. + profile.setMinCoverPipes( + marks.filter(isPipeMark).map((mark) => ({ + chainage_m: structureAnchorM(mark), + facility: mark.type_id as never, + options: mark.options, + })), + ); + }; + + const build = (id: string): DesignProfile => + createDesignProfile({ + projectId: id, + view: "cross", + onSelectStation: (stationId) => host.selectCard(cardOf(stationId)), + callbacks: { + onCrossDesignsUpdated: (updated) => host.refreshCards(updated), + onStructureMove: (from, to, station) => { + if (isPipeStation(station)) host.edit()?.movePipe(from, to); + }, + onStructureRemove: (station) => { + if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m); + }, + onPipeAdd: (chainage) => host.edit()?.addPipe(chainage), + onStructureAdd: (chainage, type) => + host.edit()?.addStructureType(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), + onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId), + onIrregularSelect: (station) => + host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), + // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + onStructureSelect: (structureId) => { + const hit = marks.find((entry) => entry.structure_id === structureId); + if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + }, + onStructureMarkMove: (structureId, toChainageM) => { + const edit = host.edit(); + if (!edit) return; + moveMarkById(structureId, toChainageM, { + structures: marks, + stations: detail?.longitudinal.stations ?? [], + edit, + }); + }, + }, + }); + + return { + root: () => profile?.root ?? null, + render(input) { + const [id, route] = input.balloonScope.split(":"); + if (!id || id === "-") return; + if (profile && projectId !== id) { + profile.dispose(); + profile = null; + } + if (!profile) { + projectId = id; + profile = build(id); + syncMarks(); + } + detail = input.detail; + intervalM = input.stationInterval; + const routeId = Number(route); + profile.render( + input.detail, + input.stationInterval, + Number.isFinite(routeId) ? routeId : undefined, + ); + // 최소 흙덮기 강제 여부는 종단 보기 「페이지 설정」 값 — 계획선 정책에 실려 온다. + const policy = input.detail.longitudinal.profile_alignment as + { policy?: { enforce_pipe_clearance?: boolean } } | undefined; + profile.setEnforceMinCover(policy?.policy?.enforce_pipe_clearance === true); + if (input.conversion) { + profile.setEarthworkContext({ + conversion: input.conversion, + haulLimits: input.haulLimits, + naturalSpoilMinSlope: input.naturalSpoilMinSlope, + }); + } + syncMarks(); + }, + setMarks(nextMarks, nextTypes) { + marks = nextMarks; + types = nextTypes; + syncMarks(); + }, + redraw() { + profile?.redraw(); + }, + showCard(section) { + if (!profile) return; + if (!section) { + profile.setSelectedStation(null); + return; + } + const pipe = stations.find( + (entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M, + ); + profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); + }, + dispose() { + profile?.dispose(); + profile = null; + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index cc2c1c775..dc3b5322b 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -10,8 +10,15 @@ import type { SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; -import type { LongitudinalPanelInput } from "./B06_Section_UI_Section_View_Draw"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; + +/** 옛 계획선 편집 제공자가 돌려주던 모양 — 페이지 쪽 정리(24-2a) 전까지 받기만 한다. */ +export interface LegacyGradeEdit { + alignment: ProfileAlignment; + stepM: number; + onStation: (chainageM: number, delta: number) => void; +} /** * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**. @@ -53,7 +60,7 @@ export interface SectionViewController { ) => void; /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ setStructureEdit: (edit: SectionStructureEdit | null) => void; - /** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */ - setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void; + /** 옛 계획선 편집 제공자 자리 — 받기만 한다. ▲/▼ 는 종단도 한 벌이 맡는다(계획서 24-2c). */ + setGradeEdit: (provider: (() => LegacyGradeEdit | null) | null) => void; dispose: () => void; } diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index e3b7602a0..15b6370d0 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -4,104 +4,52 @@ * 700줄 제한 대응으로 _UI_Style.css에서 분리(패널/폼/모식도는 원본 유지). * ========================================================================== */ -/* --- 종·횡단 도면 --- */ +/* --- 종·횡단 도면 --- + 횡단 보기 = 위 칸(횡단 카드 · 그 안에서 스크롤) + 가운데 아래쪽 종단도 한 벌 + (종단 보기와 같은 자리 · 같은 높이 키, 계획서 24-0 · 24-2c). */ .b06-profile__main, .b06-section { display: flex; flex-direction: column; - gap: var(--spacing-24); min-width: 0; + height: 100%; + min-height: 0; } .b06-section { + position: relative; box-sizing: border-box; - padding-inline: var(--spacing-24); + overflow: hidden; +} + +.b06-section__cards { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: var(--spacing-24); + min-height: 0; + padding: var(--spacing-16) var(--spacing-24); + overflow: auto; +} + +/* 종단도는 종단 보기에서 3D 위에 떠 있는 판(absolute)이다 — 횡단 보기에서는 카드 칸 + 아래 흐름에 선다. 높이 · 접기 규칙은 종단 보기와 같은 것을 그대로 쓴다. */ +.b06-section > .b05-route-profile { + position: relative; + flex: none; } -/* 상단 패널이 sticky라 카드로 스크롤하면 그 아래로 숨는다. 패널 높이를 JS가 - `--b06-cross-scroll-margin`으로 실어 주고, 스크롤 정지 위치를 그만큼 아래로 민다 - (패널을 키우거나 줄이면 이 여백도 따라 바뀐다). */ .b06-cross-card { overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-cards); background: var(--color-surface-raised); - scroll-margin-top: var(--b06-cross-scroll-margin, 240px); + scroll-margin-top: var(--spacing-8); /* 카드를 **자체 쌓임 맥락**으로 만든다. 이게 없으면 카드 안 오버레이(줌 버튼·면적 표)의 - z-index가 최상위 축에서 겨루게 되고, 상단 패널(sticky, z-index 2)과 같은 층에서 DOM - 순서로 이겨 **스크롤할 때 패널 위로 삐져나온다**(2026-08-02 사용자 지적). */ + z-index가 최상위 축에서 겨루게 되어 이웃 판 위로 삐져나온다(2026-08-02 사용자 지적). */ isolation: isolate; } -/* 상단 슬라이드 패널(종단면도 + 유토곡선). - 높이는 인라인이 아니라 CSS 변수로만 받는다 — 인라인 height는 접기 규칙을 이겨 - 패널이 접히지 않는다(ui_template_resizer.ts 주석). sticky는 그 자체로 위치 지정 - 요소라 리사이저 손잡이(absolute)의 기준이 되므로 position: relative가 따로 필요 없다. */ -.b06-section__panel { - position: sticky; - z-index: 2; - top: 0; - display: flex; - flex-direction: column; - height: var(--b06-profile-height, auto); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - /* `overflow: hidden`을 걸지 않는다 — 접기 손잡이가 패널 경계 **밖 아래**에 떠 있어 - 잘라내면 통째로 사라진다(B05 하단 패널도 같은 이유로 걸지 않는다). 안쪽 스크롤은 - `.b06-section__panel-body`가 맡는다. */ -} - -/* 최소 높이는 **펼친 상태에만** 건다. 접힘에도 걸리면(인라인 min-height가 그랬다) 접어도 - 그 높이만큼 빈 배경이 남는다. 값은 JS가 `--b06-profile-min-height`로 실어 준다. */ -.b06-section__panel:not(.is-collapsed) { - min-height: var(--b06-profile-min-height, 360px); -} - -/* 접으면 높이를 0으로 접고 알맹이를 숨긴다 — 패널 밖 아래에 뜬 손잡이만 남아 다시 펼 수 있다. - (B05 하단 패널과 같은 방식: 전역 `ui-collapsible` 규칙에 기대지 않고 여기서 명시한다.) */ -.b06-section__panel.is-collapsed { - height: 0; - min-height: 0; -} - -.b06-section__panel.is-collapsed > header, -.b06-section__panel.is-collapsed > .b06-section__panel-body, -.b06-section__panel.is-collapsed > .ui-resizer { - display: none; -} - -/* 공용 리사이저는 손잡이를 패널 위쪽 경계에 두지만(B05 하단 패널 기준), - 이 패널은 화면 위에 붙어 있으므로 아래쪽 경계로 옮긴다(아래로 끌면 커짐). */ -.b06-section__panel > .ui-resizer--vertical { - top: auto; - /* 경계에 그대로 둔다 — 손잡이가 패널 밖으로 나가 z축으로 덮으므로 자리를 비켜 줄 필요가 없다. */ - bottom: 0; - height: 8px; -} - -/* 상시 표시선은 히트 영역 가운데가 아니라 **패널 아래 경계**에 정확히 붙인다 — - 이 리사이저는 히트 영역이 패널 안쪽(bottom 0~8px)에 있어 가운데 정렬이면 표시선이 - 경계에서 4px 위(요약 막대 위)로 넘어간다(2026-08-04 사용자 지적). */ -.b06-section__panel > .ui-resizer--vertical::before { - top: auto; - bottom: 0; - transform: none; -} - -/* position: relative — 유토곡선 범례가 이 상자를 기준으로 우측 상단에 겹친다. */ -.b06-section__panel-body { - position: relative; - display: flex; - flex: 1; - flex-direction: column; - gap: var(--spacing-8); - min-height: 0; - padding-bottom: var(--spacing-8); - overflow: hidden; -} - -.b06-section__panel > header, .b06-cross-card > header, .b06-cross-card > footer { display: flex; @@ -114,27 +62,6 @@ font-size: var(--text-caption); } -/* 상태 문구만 담는 행 — 비어 있을 때가 많아 높이를 최소로 둔다. */ -.b06-section__panel > header { - justify-content: center; - padding-block: 2px; - border-bottom: 1px solid var(--color-border); -} - -/* B05 하단 패널 손잡이와 **같은 구조**로, 상하만 뒤집는다: 공용 `--bottom` 손잡이(64×24)를 - 패널 경계 **바깥**으로 내보내 리사이저를 z축으로 덮는다. 패널 안 흐름에 두면 리사이저와 - 위아래로 나란히 서서, 손잡이를 누르러 가는 길에 리사이저 hover 색이 먼저 켜진다 - (2026-08-02 사용자 지적). 이 때문에 패널에는 `overflow: hidden`을 걸지 않는다. */ -.b06-section__panel-toggle { - z-index: 3; - top: auto; - bottom: calc(-1 * var(--spacing-24)); - border-top: 0; - /* 아래 테두리도 좌우와 같은 강조색 — 색이 섞이면 외곽선이 틀어져 보인다(2026-08-04). */ - border-bottom: 1px solid var(--panel-handle-border, var(--color-border)); - border-radius: 0 0 var(--radius-buttons) var(--radius-buttons); -} - /* 종단면도와 유토곡선이 이 컨테이너 하나를 공유한다 — 가로 스크롤을 함께 써야 같은 측점이 두 그래프에서 같은 자리에 선다. wrap을 나누면 정렬이 깨진다. */ .b06-section__chart-wrap { diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs new file mode 100644 index 000000000..03b124495 --- /dev/null +++ b/resources/tester/helper_design_store_save.cjs @@ -0,0 +1,180 @@ +/* 설계 가게 · 저장 한 줄기(계획서 24-2a) 단위검증 헬퍼 — + * `A00_Common/design/design_store.ts` · `design_save.ts` · `B05_Profile_UI_Profile_Edit.ts` 를 + * 그 자리에서 트랜스파일해 Node 로 돌린다. 서버 창구는 가짜로 두고 부른 차례만 적는다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +const session = memoryStorage(); +globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; +globalThis.sessionStorage = session; + +const calls = []; +const record = (name, fail) => async () => { + calls.push(name); + if (fail) throw new Error(`${name} 실패`); +}; +let failStructures = false; +const STUBS = { + "@ui/ui_template_elements": { + showToast: (text, kind) => calls.push(`toast:${kind}`), + showLoadingOverlay: () => calls.push("overlay:on"), + hideLoadingOverlay: () => calls.push("overlay:off"), + }, + "../../B05_Profile/B05_Profile_Api_Pipes_Draft": { + flushPendingPipes: () => record("pipes")(), + }, + "../../B05_Profile/B05_Profile_Api_Structures": { + flushPendingStructures: () => record("structures", failStructures)(), + }, + "../../B05_Profile/B05_Profile_Api_Fetch": { + flushUphillOverrides: () => record("uphill")(), + saveProfileAlignment: async (_p, routeId, edits) => + calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`), + }, + "../../B06_Section/B06_Section_Api_Culvert_Options": { + flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`), + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts")); +const { saveDesign, DESIGN_SAVE_ORDER } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_save.ts"), +); + +async function run() { + const out = {}; + + // ① 가게 — 등록 키 밑 · 같은 칸이면 두 가게가 같은 값 · 듣는 쪽 + const a = useDesignStore("p1", 7); + const b = useDesignStore("p1", 99); // 프로젝트 칸은 노선과 무관 + const heard = []; + const off = b.subscribe("pipes", (value) => heard.push(value.length)); + a.set("pipes", [{ chainage_m: 10, source: "user" }]); + out.pipesShared = b.get("pipes"); + out.pipesKey = [...Array(session.length).keys()].map((i) => session.key(i)); + off(); + a.set("pipes", [{ chainage_m: 10 }, { chainage_m: 20 }]); + out.heard = heard; + out.otherProject = useDesignStore("p2", 7).get("pipes"); + out.noRoute = useDesignStore("p1", null).get("alignmentDraft"); + a.set("selection", { chainageM: 12.5, structureId: "s-1" }); + out.selection = b.get("selection"); + a.set("pipes", null); + a.set("selection", null); + + // ② 계획고 가게 한 벌 — B05 패널 · B06 계획선 편집이 같은 벌 + const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } }; + const b05 = edit.createProfileEditStore(7, saved, () => undefined); + const b06 = edit.useProfileEditStore(7, saved, () => undefined); + b06.replace({ station_offsets: { "20.000": 0.5 }, curve_radii: saved.curve_radii }); + out.b05SeesB06 = b05.edits().station_offsets; + out.b05Dirty = b05.dirty(); + // B05 가 자기 손에 쥔 값으로 이어 고쳐도 B06 편집이 안 사라짐(옛 두 가게 흠) + b05.replace({ + station_offsets: { ...b05.edits().station_offsets, "60.000": -0.2 }, + curve_radii: b05.edits().curve_radii, + }); + out.afterBoth = b06.edits().station_offsets; + out.draft = useDesignStore(null, 7).get("alignmentDraft").station_offsets; + out.pending = edit.pendingAlignment(7).station_offsets; + // 되돌리기가 초안을 바꾼 뒤 B05 가 다시 세움 — B06 쪽 손잡이도 같은 벌을 봄 + useDesignStore(null, 7).set("alignmentDraft", { + station_offsets: { "20.000": 0.1 }, + curve_radii: {}, + }); + edit.createProfileEditStore(7, saved, () => undefined); + out.rebuiltSeenByB06 = b06.edits().station_offsets; + + // ③ 저장 한 줄기 — 차례 · 기본 창구 · 보기 몫 · 계획고 저장됨 표시 + calls.length = 0; + const ok = await saveDesign({ + projectId: "p1", + routeId: () => 7, + prepare: record("prepare"), + steps: { pipes: record("pipes:screen") }, + cross: async (confirm) => calls.push(`cross:${confirm}`), + done: async (confirm) => calls.push(`done:${confirm}`), + failed: () => calls.push("failed"), + }); + out.saveOk = ok; + out.saveCalls = [...calls]; + out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") }; + out.order = DESIGN_SAVE_ORDER; + + // ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감 + calls.length = 0; + failStructures = true; + await saveDesign( + { + projectId: "p1", + routeId: () => 7, + steps: { uphill: record("uphill:screen") }, + cross: async (confirm) => calls.push(`cross:${confirm}`), + failed: () => calls.push("failed"), + }, + { confirm: true }, + ); + failStructures = false; + out.tolerant = [...calls]; + calls.length = 0; + out.noRouteSave = await saveDesign({ + projectId: "p1", + routeId: () => null, + cross: async () => calls.push("cross"), + failed: () => calls.push("failed"), + }); + out.noRouteCalls = [...calls]; + + // ⑤ [초기화] — 초안 · 한 벌 모두 버림 + b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} }); + edit.clearAlignmentDrafts(); + out.cleared = { + draft: useDesignStore(null, 7).get("alignmentDraft"), + pending: edit.pendingAlignment(7), + }; + return out; +} + +run().then( + (out) => console.log(JSON.stringify(out)), + (error) => { + console.error(error); + process.exit(1); + }, +); diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py index dbbcd2356..7bddfb6f8 100644 --- a/resources/tester/test_b05_b06_save_roundtrip.py +++ b/resources/tester/test_b05_b06_save_roundtrip.py @@ -474,23 +474,25 @@ def _resave_unchanged(flow: Flow) -> None: flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"]) +# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 — +# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a). def _b05_save(flow: Flow) -> None: - """B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단.""" + """B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정.""" flow.put_pipes(flow.b05_pipe_payload()) - flow.put_alignment() flow.put_structures(flow.screen_structures(reread=True)) - flow.confirm(with_irregular=True) + flow.put_alignment() flow.flush_culvert() + flow.confirm(with_irregular=True) flow.save_sections() def _b06_save(flow: Flow) -> None: - """B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단.""" + """B06 [저장] — 오르막 차례는 상단측만 확정.""" flow.put_pipes(flow.b06_pipe_payload()) + flow.put_structures(flow.screen_structures(reread=False)) + flow.put_alignment() flow.flush_culvert() flow.confirm(with_irregular=False) - flow.put_alignment() - flow.put_structures(flow.screen_structures(reread=False)) flow.save_sections() diff --git a/resources/tester/test_b05_drainage_side.py b/resources/tester/test_b05_drainage_side.py index 71e302ae8..0650f76e3 100644 --- a/resources/tester/test_b05_drainage_side.py +++ b/resources/tester/test_b05_drainage_side.py @@ -53,6 +53,9 @@ def test_켜고_끄는_창구(): ): assert name in side assert "root.hidden" in side or "panel.root.hidden" in side + # 종단도 판 자리 약속(`DesignSideFactory` · design_profile.ts) 모양 그대로 + assert "DesignSideBoard" in side and "DesignSideHost" in side + assert "export function createDrainageSide(" in side types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8") assert "export interface DrainagePipeReport" in types assert "onPipesChanged?: (pipes: DrainagePipeReport[]) => void;" in types diff --git a/resources/tester/test_b05_map_interval_spans.py b/resources/tester/test_b05_map_interval_spans.py index 1222053da..d8cc48e40 100644 --- a/resources/tester/test_b05_map_interval_spans.py +++ b/resources/tester/test_b05_map_interval_spans.py @@ -19,7 +19,8 @@ _B05 = PROJECT_ROOT / "B05_Profile" _SPANS = _B05 / "B05_Profile_UI_Drainage_Spans.ts" _RENDER = _B05 / "B05_Profile_UI_Drainage_Render.ts" _PANEL = _B05 / "B05_Profile_UI_Drainage_Panel.ts" -_PROFILE = _B05 / "B05_Profile_UI_Profile_Panel.ts" +# 종단 패널 본체는 종단도 한 벌로 옮겼다(계획서 24-2c) — 배수유역도는 오른쪽 판(side). +_PROFILE = _B05.parent / "A00_Common" / "design" / "design_profile.ts" def test_띠를_그리는_부품이_있다(): @@ -54,7 +55,7 @@ def test_종단과_같은_자료에서_뽑는다(): assert 'item.placement !== "interval"' in spans, "구간형만 띠로 그린다" assert "type.style?.color" in spans, "색을 새로 정하지 않고 레지스트리 값을 쓴다" profile = _PROFILE.read_text(encoding="utf-8") - assert "drainagePanel.setIntervalSpans(routeSpansFromStructures(" in profile + assert "drainagePanel?.setIntervalSpans(routeSpansFromStructures(" in profile def test_목록이_바뀌면_다시_그린다(): diff --git a/resources/tester/test_b05_min_cover_guard.py b/resources/tester/test_b05_min_cover_guard.py index 5e9106aa9..666687795 100644 --- a/resources/tester/test_b05_min_cover_guard.py +++ b/resources/tester/test_b05_min_cover_guard.py @@ -17,7 +17,8 @@ if str(PROJECT_ROOT) not in sys.path: RENDER = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Render.ts").read_text( encoding="utf-8" ) -PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text( +# 종단 패널 본체는 종단도 한 벌로 옮겼다(계획서 24-2c) — 편집 한 길도 그 안에 있다. +PANEL = (PROJECT_ROOT / "A00_Common" / "design" / "design_profile.ts").read_text( encoding="utf-8" ) GUARD = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text( diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py index fdc3d1d6e..757cedb61 100644 --- a/resources/tester/test_b05_stale_design_sync.py +++ b/resources/tester/test_b05_stale_design_sync.py @@ -18,7 +18,8 @@ if str(PROJECT_ROOT) not in sys.path: COMMON = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text( encoding="utf-8" ) -B05_PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text( +# 종단 패널 본체는 종단도 한 벌로 옮겼다(계획서 24-2c). +B05_PANEL = (PROJECT_ROOT / "A00_Common" / "design" / "design_profile.ts").read_text( encoding="utf-8" ) # 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13). diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index 59a1a75ad..1ba209c4d 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -63,14 +63,36 @@ def test_창구_넷은_초안이_비면_요청을_안_낸다(): def test_저장이_창구를_다섯_다_거친다(): - """순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다.""" - persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + """순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다. + + 차례는 저장 한 줄기(`A00_Common/design/design_save.ts`) 한 곳에 있다(계획서 24-2a) — + B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다.""" + save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8") for token in ( - "flushCulvertOptions()", + "flushCulvertOptions(", "flushUphillOverrides(", "flushPendingPipes(", "flushPendingStructures(", ): - assert token in persist, f"{token} 가 저장 앞단에서 사라짐" - # 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다. - assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음" + assert token in save, f"{token} 가 저장 한 줄기에서 사라짐" + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐" + # 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다. + assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음" + order = save[save.index("DESIGN_SAVE_ORDER = [") :] + order = order[: order.index("]")] + names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] + assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), ( + "저장 차례가 바뀜 — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단" + ) + + +def test_두_페이지가_저장_한_줄기를_탄다(): + """B05 [임시저장] · B06 [저장] · [확정]이 창구를 따로 부르지 않는다(계획서 24-2a).""" + actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8") + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + for name, text in (("B05", actions), ("B06", persist)): + assert "saveDesign(" in text, f"{name} 가 저장 한 줄기를 안 탐" + for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("): + assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림" + assert "{ confirm: true }" in persist, "[확정]이 한 줄기를 안 탐" diff --git a/resources/tester/test_b06_save_recompute.py b/resources/tester/test_b06_save_recompute.py index 162bceeb1..c4ce42d83 100644 --- a/resources/tester/test_b06_save_recompute.py +++ b/resources/tester/test_b06_save_recompute.py @@ -8,6 +8,8 @@ ⇒ `saveCurrentSections`·`confirmCurrentSections` 가 `collectSectionEdits` **앞에서** `ctx.reconcileDesigns()` 를 부른다. 순서가 뒤집히면 옛 면적이 그대로 실린다. + (계획서 24-2a — 둘 다 저장 한 줄기 `saveDesign` 을 탄다: B06 창구 `sectionSaveContext` 의 + `prepare` 가 재계산, `cross` 가 면적 수집이고, `saveDesign` 이 `prepare` 를 먼저 부른다.) """ from __future__ import annotations @@ -17,6 +19,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] PERSIST = ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts" PAGE = ROOT / "B06_Section" / "B06_Section_UI_Page.ts" +SAVE = ROOT / "A00_Common" / "design" / "design_save.ts" def _body(source: str, marker: str) -> str: @@ -30,11 +33,17 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None: "export async function saveCurrentSections", "export async function confirmCurrentSections", ): - body = _body(text, marker) - assert "ctx.reconcileDesigns()" in body, f"{marker} 가 재계산을 안 부른다" - assert body.index("ctx.reconcileDesigns()") < body.index("collectSectionEdits(ctx)"), ( - f"{marker}: 재계산이 면적 수집보다 뒤에 있다 — 옛 면적이 실린다" + assert "saveDesign(sectionSaveContext(" in _body(text, marker)[:400], ( + f"{marker} 가 B06 창구로 저장 한 줄기를 안 탄다" ) + body = _body(text, "function sectionSaveContext") + assert "prepare: () => ctx.reconcileDesigns()" in body, "B06 창구가 재계산을 안 넘긴다" + assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖" + save = SAVE.read_text(encoding="utf-8") + run = _body(save, "export async function saveDesign") + assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), ( + "저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다" + ) def test_페이지가_재계산_통로를_이어_준다() -> None: diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py new file mode 100644 index 000000000..16e4b9947 --- /dev/null +++ b/resources/tester/test_design_store_save.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""설계 가게 한 곳 · 저장 한 줄기(계획서 24-2a) 회귀검증. + +- 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽. +- 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠). +- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 · + 계획고는 실패해도 다음 차례로. +Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_design_store_save.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_가게는_등록_키_밑_같은_칸은_같은_값(out): + assert out["pipesShared"] == [{"chainage_m": 10, "source": "user"}] + assert out["pipesKey"] == ["aislo:draft:pipes:p1"] + assert out["heard"] == [1], "끊은 뒤에는 안 불려야 함" + assert out["otherProject"] is None + assert out["noRoute"] is None + assert out["selection"] == {"chainageM": 12.5, "structureId": "s-1"} + + +def test_계획고_가게_한_벌(out): + assert out["b05SeesB06"] == {"20.000": 0.5} + assert out["b05Dirty"] is True + assert out["afterBoth"] == {"20.000": 0.5, "60.000": -0.2} + assert out["draft"] == out["afterBoth"] == out["pending"] + assert out["rebuiltSeenByB06"] == {"20.000": 0.1} + + +def test_저장_차례(out): + assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"] + assert out["saveOk"] is True + assert out["saveCalls"] == [ + "overlay:on", + "prepare", + "pipes", + "pipes:screen", + "structures", + 'alignment:7:{"20.000":0.1}', + "culvert:aislo:draft:culvertopt:p1:7", + "uphill", + "cross:false", + "done:false", + "overlay:off", + ] + assert out["afterSave"] == {"dirty": False, "draft": None} + + +def test_구조물_실패는_알리고_넘어감_확정_노선없음(out): + calls = out["tolerant"] + assert "toast:error" in calls + assert calls.index("structures") < calls.index("toast:error") < calls.index("uphill:screen") + assert "uphill" not in calls, "보기 몫이 기본 창구를 갈음" + assert calls[-2:] == ["cross:true", "overlay:off"] + assert "failed" not in calls + assert out["noRouteSave"] is False and out["noRouteCalls"] == [] + + +def test_초기화는_초안과_한_벌을_버림(out): + assert out["cleared"] == {"draft": None, "pending": None}