diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 5bc343a5..78a37c90 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -35,7 +35,17 @@ import { type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; -import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { + createProfileEditStore, + readAlignmentDraft, + type ProfileEditStore, +} from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { readAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Data"; +import { + adjustStation, + buildAlignment, + toAlignmentBase, +} from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readStructurePick, writeStructurePick, @@ -481,6 +491,41 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); + /** + * 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만 + * 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcileStale…`), + * 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장). + */ + let gradeStore: ProfileEditStore | null = null; + let gradeRouteId: number | null = null; + const gradeEditFor = (): ReturnType< + NonNullable[0]> + > => { + const detail = sectionDetail; + if (!detail || currentRouteId === null) return null; + const stored = readAlignment(detail.longitudinal); + if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다. + if (!gradeStore || gradeRouteId !== currentRouteId) { + gradeRouteId = currentRouteId; + gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined); + } + const store = gradeStore; + const base = toAlignmentBase(stored); + const alignment = buildAlignment(base, store.edits()); + return { + alignment, + stepM: alignment.policy.edit_step_m, + onStation: (chainageM, delta) => { + store.replace(adjustStation(base, store.edits(), chainageM, delta)); + // 편집분은 세션 초안에 있으므로 재계산이 그것을 그대로 읽는다(강제로 한 번). + void reconcileStaleDesigns({ force: true }).then(() => + sectionView.setGradeEdit(gradeEditFor), + ); + }, + }; + }; + sectionView.setGradeEdit(gradeEditFor); + // 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한 // 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와 // 같은 함수를 타므로 목록·폼·알약이 함께 선다. diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 6e99d8be..b2827081 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -18,6 +18,11 @@ import { } 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 { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; @@ -418,6 +423,15 @@ 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 사용자: 어느 페이지에서 저장해도 @@ -439,6 +453,13 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): // 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"); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index ba24ec2d..49afaa2f 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -45,7 +45,12 @@ import { 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, drawLongitudinalPanel } from "./B06_Section_UI_Section_View_Draw"; +import { + attachWindowScroll, + createElevationHold, + drawLongitudinalPanel, + type LongitudinalPanelInput, +} from "./B06_Section_UI_Section_View_Draw"; import { type StructureInstance, type StructureType, @@ -115,9 +120,10 @@ export interface SectionViewController { structures: ReadonlyArray, types: ReadonlyArray, ) => void; - /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다 - * (2026-09-12 B05·B06 일원화). */ + /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ setStructureEdit: (edit: SectionStructureEdit | null) => void; + /** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */ + setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void; dispose: () => void; } @@ -151,6 +157,7 @@ export function createSectionView( 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; @@ -508,6 +515,8 @@ export function createSectionView( naturalSpoilSlope: currentNaturalSpoilSlope, selectStation: (stationId) => selectStation(stationId, true), setMassBadge: (values) => massBadge.set(values), + grade: gradeEdit?.() ?? null, + holdRange, }); // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); @@ -525,11 +534,8 @@ export function createSectionView( } } - attachWindowScroll( - chartWrap, - () => updateChartWindow, - () => drawPanel(), - ); + attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel); + const holdRange = createElevationHold(chartWrap, drawPanel); const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; @@ -663,6 +669,10 @@ export function createSectionView( structureEdit = edit; drawPanel(); }, + setGradeEdit(provider) { + gradeEdit = provider; + drawPanel(); + }, setStructureMarks(structures, types) { markStructures = structures; markTypes = types; diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts index 05701438..5ed3d1b6 100644 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -32,6 +32,13 @@ export interface LongitudinalChartInput { 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 { @@ -114,9 +121,11 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi undefined, // 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화). input.onDragStation, + input.bottomInsetPx ?? 0, 0, - 0, - visibleElevationRange(detail, fromM, toM) ?? undefined, + 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 index 8888e8f1..96d51824 100644 --- a/B06_Section/B06_Section_UI_Section_View_Draw.ts +++ b/B06_Section/B06_Section_UI_Section_View_Draw.ts @@ -32,6 +32,8 @@ import { 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 { @@ -53,6 +55,18 @@ export interface LongitudinalPanelInput { 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; } @@ -91,6 +105,10 @@ export function drawLongitudinalPanel( 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, }); @@ -130,6 +148,24 @@ export function drawLongitudinalPanel( } 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) { @@ -177,7 +213,11 @@ export function drawLongitudinalPanel( chartWrap.scrollLeft, chartWrap.clientWidth || chartWidth, ); - return applyElevationWindow(chartWrap, visibleElevationRange(detail, fromM, toM) ?? undefined); + const next = visibleElevationRange(detail, fromM, toM); + return applyElevationWindow( + chartWrap, + input.holdRange ? input.holdRange(next) : (next ?? undefined), + ); }; } @@ -208,3 +248,35 @@ export function attachWindowScroll( }, 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; + }; +}