From 521e792a59b7e61721f1bbd334b2d513e115069a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 18:36:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EC=A2=85=EB=8B=A8=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=A0=20=ED=8E=B8=EC=A7=91(=E2=96=B2/=E2=96=BC)?= =?UTF-8?q?=EC=9D=84=20B06=20=EC=97=90=EB=8F=84=20=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05·B06 종단을 같은 템플릿으로 맞추면서 **계획선 편집 버튼층만 빠져 있었음** — B06 에는 편집분을 얹어 계획선·전 측점 횡단을 다시 푸는 코드도, 편집 초안 세션도 이미 있었는데 누를 버튼이 없었음. B05 와 같은 부품(createEditOverlay)을 종단 그래프에 얹고, 누른 값은 B05 와 같은 세션 초안에 쌓음. 누르는 즉시 계획선·횡단을 다시 풀어 카드에 반영함. 저장도 이었음 — 종전에는 B06 이 초안을 읽기만 해서, B06 에서 [저장]·[확정]하면 계획선 편집이 정본에 안 남고 다음 진입 때 사라졌음. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR --- B06_Section/B06_Section_UI_Page.ts | 47 ++++++++++++++++++- B06_Section/B06_Section_UI_Page_Persist.ts | 21 +++++++++ B06_Section/B06_Section_UI_Section_View.ts | 20 +++++--- .../B06_Section_UI_Section_View_Chart.ts | 4 +- .../B06_Section_UI_Section_View_Draw.ts | 27 +++++++++++ 5 files changed, 111 insertions(+), 8 deletions(-) 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..1466a6c3 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -45,7 +45,11 @@ 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, + drawLongitudinalPanel, + type LongitudinalPanelInput, +} from "./B06_Section_UI_Section_View_Draw"; import { type StructureInstance, type StructureType, @@ -118,6 +122,8 @@ export interface SectionViewController { /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다 * (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 gradeEditProvider: (() => LongitudinalPanelInput["grade"]) | null = null; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; @@ -508,6 +515,7 @@ export function createSectionView( naturalSpoilSlope: currentNaturalSpoilSlope, selectStation: (stationId) => selectStation(stationId, true), setMassBadge: (values) => massBadge.set(values), + grade: gradeEditProvider?.() ?? null, }); // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); @@ -525,11 +533,7 @@ export function createSectionView( } } - attachWindowScroll( - chartWrap, - () => updateChartWindow, - () => drawPanel(), - ); + attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel); const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; @@ -663,6 +667,10 @@ export function createSectionView( structureEdit = edit; drawPanel(); }, + setGradeEdit(provider) { + gradeEditProvider = 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..1e9c073f 100644 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -32,6 +32,8 @@ export interface LongitudinalChartInput { viewportWidth: number; /** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */ onDragStation?: (stationId: string, toChainageM: number) => void; + /** X축·측점 라벨을 바닥에서 이만큼(px) 올린다 — 계획고 편집 ▼ 버튼과 겹치지 않게. */ + bottomInsetPx?: number; } export interface LongitudinalChartResult { @@ -114,7 +116,7 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi undefined, // 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화). input.onDragStation, - 0, + input.bottomInsetPx ?? 0, 0, visibleElevationRange(detail, fromM, toM) ?? undefined, ), diff --git a/B06_Section/B06_Section_UI_Section_View_Draw.ts b/B06_Section/B06_Section_UI_Section_View_Draw.ts index 8888e8f1..d14408d8 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,14 @@ export interface LongitudinalPanelInput { conversion?: EarthworkConversion; naturalSpoilSlope?: number; selectStation: (stationId: string) => void; + /** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/▼ 버튼층이 선다(B05 와 같은 부품). + * 안 넘기면 버튼이 없고 그래프만 보인다(옛 저장분처럼 선형이 없는 노선). */ + grade?: { + alignment: ProfileAlignment; + /** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */ + stepM: number; + onStation: (chainageM: number, delta: number) => void; + } | null; /** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */ setMassBadge: (values: ReturnType | null) => void; } @@ -91,6 +101,9 @@ export function drawLongitudinalPanel( minWidth: chartWidth, scrollLeft: input.keepScrollLeft, viewportWidth: chartWrap.clientWidth || chartWidth, + // 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다 + // (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다. + bottomInsetPx: input.grade ? 15 : 0, // 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동. onDragStation: edit ? moveMark : undefined, }); @@ -130,6 +143,20 @@ export function drawLongitudinalPanel( } chartWrap.replaceChildren(...nodes); + // 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자: + // B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에 + // 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다. + if (input.grade) { + chartWrap.append( + createEditOverlay({ + alignment: input.grade.alignment, + width: chartWidth, + x: chart.toX, + step: input.grade.stepM, + onStation: input.grade.onStation, + }), + ); + } // 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군 // → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다). if (edit) {