/* ============================================================================= * A00_Common/design/design_profile.ts * 종단도 한 벌(계획서 24-1 · 24-2c) — 종단 보기 · 횡단 보기가 **같은 종단도**를 가운데 * 아래쪽에 둔다. 도구(▲▼ 고치기 · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · * 뱃지)와 막기 규칙(최소 흙덮기 · 기울기 상한)은 보기와 무관하게 하나. * * 그래프와 도면 테이블이 **하나의 가로 스크롤러** 안에 같은 폭으로 쌓여 X축이 자동으로 * 맞물린다. 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [저장]·[확정] * 시점에 `saveProfileAlignment()`로 편집 델타만 보낸다. * * 판 자리 약속(side · overlay)은 `design_profile_slots.ts` 에 있고 여기서 다시 내보낸다. * ========================================================================== */ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans"; import { readState, writeState } from "../b_page_state"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import type { StructureInstance, StructureType, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { hasLegacyAlignment, readAlignment, toDesignProfile, } from "../../B05_Profile/logic/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/logic/B05_Profile_UI_Profile_Alignment"; import { buildAlignment, emptyEdits, toAlignmentBase, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { structureGroupMenuItems, stationIdAtStructure as stationIdAtStructureOf, structureIdKeeping, } from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import 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, createElevationHold, createProfileDrag, } from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; import { planCurveWarning, sampleAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Guard"; import { listedBase } from "./design_profile_base"; import * as pagePipeline from "./design_pipeline_page"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { frameOf } from "@util/common_util_view_window"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; import { noticeDroppedEdits } from "./design_save"; import { createProfileOverlays } from "./design_profile_overlays"; import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport"; 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"; export type { DesignOverlayBoard, DesignSideBoard, DesignSideFactory, DesignSideHost, DesignView, } from "./design_profile_slots"; /** 종단도를 만들 때 넘기는 것. */ export interface DesignProfileContext { projectId: string; /** 처음 보기. */ view: DesignView; /** 오른쪽 판 — 없으면 판 없이 종단 열만. */ side?: DesignSideFactory; /** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */ toolLead?: HTMLElement; /** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */ onSelectStation: (stationId: string | null) => void; /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */ onResetAll?: () => void; callbacks?: RouteProfilePanelCallbacks; } /** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms). * ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다. * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. * 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가 * 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. * 횡단 보기는 120ms — 계산마다 카드를 갈아 끼워 길게 누르면 멈춤이 쌓임(9da48d5e7 · 54-22). */ 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); // 유토곡선 — 종단 칸 아래 자기 판(354-1) · 테이블 — 종단 칸 바닥 겹침 판. // 둘 다 위 경계 리사이저로 높이를 조절한다. // 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고 // (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은 // 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw. const subPanelChanged = (): void => { if (drag.isSubDragging()) scheduleLightSync(); else draw(); }; // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계). const { massHaul, tableOverlay, column } = createProfileOverlays({ projectId, routeId: () => routeId, bodyWrap, body, onChange: subPanelChanged, }); const content = document.createElement("div"); content.className = "b05-route-profile__content"; /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ let minCoverTargets: MinCoverPoint[] = []; /** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제). * 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */ let enforceMinCover = readState("profile-enforce-min-cover", projectId) === true; /** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */ 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), // 배수유역도 우클릭 빈 자리 = 사이드 패널과 같은 군 → 종류 목록(129-1 · addAt 경로). structureMenuItems: (chainage) => structureGroupMenuItems(callbacks?.structureGroups?.() ?? [], (typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId), ), }) ?? null; content.append(column); 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; /** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */ let storedBase: AlignmentBase | null = null; let alignment: ProfileAlignment | null = null; let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); /** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 노선 0 칸(alignment-draft · 346-1). */ let preview: typeof store | null = null; /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ let savedEdits: AlignmentEdits = emptyEdits(); let redrawPending = false; let lastWidth = 0; let lastHeight = 0; /** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 몫(700줄 한계). */ /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ const structureIdAtStation = (stationId: string | null): string | null => structureIdKeeping( stationId, selectedStructureId, irregularStations, structures, detail, structureTypes, ); const stationIdAtStructure = (structureId: string | null): string | null => stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes); /** * 측점 선택 — 같은 측점 재선택이면 해제한다(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: withToolLead(tools.render()), trailing, 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()); /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; /** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */ const elevationHold = createElevationHold(body, () => draw()); const trailing = document.createElement("div"); trailing.className = "b05-route-profile__trailing"; trailing.append(profileZoom.bar); /** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */ const withToolLead = (wrap: HTMLElement): HTMLElement => { if (ctx.toolLead) wrap.prepend(ctx.toolLead); return wrap; }; /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ const { tools, history, handleToolPick, setListening } = 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: () => { // 세션이 정본 — 편집 초안을 다시 읽음 · 노선 초안 동안은 노선 0 칸 · 계획고가 그대로면 다시 셈 예약 없음(349-0q) const [was, draft] = [JSON.stringify(store.edits()), store === preview]; const id = draft ? pagePipeline.DRAFT_ROUTE_ID : routeId; store = createProfileEditStore(id, draft ? store.edits() : savedEdits, () => rebuild()); if (draft) preview = store; void (JSON.stringify(store.edits()) !== was ? rebuild() : draw()); }, 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(); } /** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */ const listed = (): AlignmentBase | null => listedBase(storedBase, detail, irregularStations); /** 편집 후 다시 그린다 — 길게 누르기(초당 10회)도 한 프레임에 한 번만 그림. */ function rebuild(): void { if (base) alignment = buildAlignment(base, store.edits()); pagePipeline.pipelineGrade(projectId, store.edits()); // 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유 // 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다 // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). scheduleCrossPreview(); drawSoon(); } /** 다음 틀에 한 번 그림 — 같은 차례 알림 · 갈아끼움 · 설정이 여러 번 불러도 한 번(349-0s). */ function drawSoon(): void { if (redrawPending) return; redrawPending = true; frameOf(root, () => { redrawPending = false; draw(); }); } /** 횡단 설계 프리뷰는 `_Profile_Preview` 몫(700줄 한계). */ const crossPreview = createCrossPreview({ projectId, detail: () => (pagePipeline.pagePipeline(projectId)?.input("route") ? null : detail), // 노선 초안 동안 안 셈(349-0s) routeId: () => routeId, edits: () => store.edits(), alignment: () => (store === preview ? null : alignment), debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS), onApplied: (updated) => { drawSoon(); if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated); }, }); // 노선 초안 동안은 저장 노선 횡단을 안 고침 — 새 노선 횡단은 계산 묶음 cross(346-2) const scheduleCrossPreview = (): void => void (store !== preview && crossPreview.schedule()); /** * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 * 줄고 → ② 테이블이 최소까지 줄고(유토곡선은 자기 판 · 354-1) → ③ 둘 다 최소면 * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). * * draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는 * 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다. */ const heights = createHeightCascade({ root, body, 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 { pagePipeline.pipelineWork("종단도", () => renderProfile({ // 그린 뒤 생긴 낡음도 다시 셈(317-1) · 노선 초안 동안은 묶음 cross 를 기다림 onStaleDesigns: (key) => crossPreview.scheduleIfStale(key, store === preview), body, callbacks, massHaul, tableOverlay, store, detail: () => pagePipeline.pipelineDetail(projectId) ?? 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: elevationHold.hold, toolActive: () => tools.mode() !== "none", tools, stationIdAtStructure, redraw: draw, setElevationWindowUpdater: (update) => { updateElevationWindow = update; }, }), ); } /** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */ const reveal = createSelectionReveal(body); const revealPick = (): void => reveal( (selectedStationId ?? selectedStructureId) ? `${selectedStationId}|${selectedStructureId}` : null, ); // 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계). const unbindViewport = bindProfileViewport({ body, editing: () => elevationHold.editing(), fitElevation: () => updateElevationWindow?.() ?? null, draw: () => draw(), lastSize: () => ({ width: lastWidth, height: lastHeight }), scheduleLightSync, scheduleDraw: () => drag.scheduleDraw(), }); // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). bindProfileCollapse(root, panelHandle, () => frameOf(root, 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 !== preview && !store.dirty())) { routeId = nextRouteId ?? routeId; savedEdits = stored?.edits ?? emptyEdits(); store = createProfileEditStore(routeId, savedEdits, () => rebuild()); } storedBase = stored ? toAlignmentBase(stored) : null; if (store !== preview) base = listed(); // 노선 초안 그림은 그대로(갈아끼운 상세) // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; pagePipeline.restartPagePipeline(projectId); // 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다. // 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다. configureBalloonOffsets( `${projectId}:${routeId ?? "-"}`, nextDetail.balloon_offsets ?? undefined, ); drawSoon(); // 다음 틀 한 번(끌기 중 갈아끼움마다 두 번 그리던 것 · 349-0s) // 저장 횡단이 옛 계획고로 굳어 있으면 진입 즉시 맞춤(08-23) — 비교 = 편집이 반영된 계획선 // (저장분끼리 견주면 세션 편집이 남은 새로고침에서 유토곡선이 빈 채 · 09-03). 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); pagePipeline.pipelineEarthwork(projectId, next); drawSoon(); }, setSelectedStation(stationId: string | null) { selectedStationId = stationId; // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). selectedStructureId = structureIdAtStation(stationId); draw(); revealPick(); }, /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ setEnforceMinCover(enforce: boolean) { enforceMinCover = enforce; writeState("profile-enforce-min-cover", enforce, projectId); }, /** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */ setMinCoverPipes(pipes: ReadonlyArray) { takePipes(pipes); }, /** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부 * 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19 * 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */ setGradeLimit(maxGradePct: number) { if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return; if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return; const policy = { ...base.policy, max_grade_pct: maxGradePct }; storedBase = { ...storedBase, policy }; base = { ...base, policy }; alignment = buildAlignment(base, store.edits()); drawSoon(); }, /** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */ setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) { drainagePanel?.setRoute(points); }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { irregularStations = stations; if (store !== preview) base = listed(); if (base) alignment = buildAlignment(base, store.edits()); pagePipeline.restartPagePipeline(projectId); drawSoon(); }, /** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */ setStructureTypes(types: StructureType[]) { structureTypes = types; syncDrainageSpans(); drawSoon(); }, /** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */ setStructures(next: StructureInstance[]) { structures = next; if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) { selectedStructureId = null; } syncDrainageSpans(); drawSoon(); }, /** 고른 구조물에 알약 · 측점선을 맞춤 — 그 자리면 선 그대로(53-2) · keepStation(B06 카드 = 선 주인) = 선 없는 구조물 · 풂도 카드 선 지킴(177-3). */ setSelectedStructure(structureId: string | null, keepStation = false) { selectedStructureId = structureId; const own = stationIdAtStructure(structureId) ?? (keepStation ? selectedStationId : null); const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; selectedStationId = keep ? selectedStationId : own; draw(); revealPick(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ setStationDisplay(next: { station: number; cumulative: number }) { stationDisplay = next; drawSoon(); }, /** * 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다. * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. */ resetStationEdit(chainageM: number, moveTo?: number) { // 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272) const edits = store.edits(); const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find( (entry) => Math.abs(Number(entry) - chainageM) < 0.006, ); if (key !== undefined) store.resetStation(Number(key), moveTo); }, isDirty: () => store.dirty(), /** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */ pipelineSource: () => ({ base: listed(), edits: store.edits(), detail, routeId }), /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 노선 0 칸(저장 노선에 * 안 씀 · [저장]이 노선 뒤 새 노선에 보냄) · 초안을 버리면 저장 기준 · 세션 편집으로 돌아옴. */ adoptPipeline(nextBase: AlignmentBase, edits: AlignmentEdits, draft: boolean) { const sameEdits = JSON.stringify(edits) === JSON.stringify(store.edits()); if (draft && store === preview && base === nextBase && sameEdits) return; // 이미 그림 if (draft && (store !== preview || !sameEdits)) store = preview = createProfileEditStore(pagePipeline.DRAFT_ROUTE_ID, edits, () => rebuild(), ); if (draft) return void ((base = nextBase), rebuild()); if (store === preview) store = createProfileEditStore(routeId, savedEdits, () => rebuild()); [preview, base] = [null, listed()]; alignment = base && buildAlignment(base, store.edits()); // 그리기만 · 다시 셈 예약 없음(349-0l) drawSoon(); }, /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ async save(): Promise { if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄 const saved = await saveProfileAlignment(projectId, routeId, store.edits()); noticeDroppedEdits(saved); const next = saved.profile_alignment as ProfileAlignment | undefined; if (next?.base_pvi?.length) { storedBase = toAlignmentBase(next); base = listed(); alignment = buildAlignment(base!, store.edits()); if (detail) detail.longitudinal.profile_alignment = next; } store.markSaved(); draw(); }, clear() { detail = null; base = null; storedBase = null; alignment = null; selectedStationId = null; balanceBar.replaceChildren(); body.replaceChildren(empty); }, /** 밖에서 바뀐 횡단 면적(카드 갱신)을 유토곡선 · 뱃지에 다시 싣는다. */ redraw: () => drawSoon(), /** 오른쪽 판(배수유역도) — 판 없이 만들었으면 null. */ side: drainagePanel, /** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */ setLoading(label: string | null) { progress.root.hidden = label === null; if (label !== null) progress.set(null, label); }, /** 보기가 숨으면 false — 창 키 듣기를 뗀다. 다시 보이면 잇고, 다른 보기가 고친 계획고 초안 * (같은 가게) · 관(판)을 다시 읽어 그린다(계획서 24-3a). */ setActive(active: boolean) { setListening(active); if (!active) return; if (base) alignment = buildAlignment(base, store.edits()); drawSoon(); drainagePanel?.load(projectId); }, dispose() { pagePipeline.disposePagePipeline(projectId); setListening(false); drag.dispose(); crossPreview.dispose(); unbindViewport(); }, }; } /** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */ export type DesignProfile = ReturnType;