Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1

This commit is contained in:
2026-09-12 18:42:45 +09:00
5 changed files with 111 additions and 8 deletions
+46 -1
View File
@@ -35,7 +35,17 @@ import {
type SectionPersistContext, type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist"; } from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; 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 { import {
readStructurePick, readStructurePick,
writeStructurePick, writeStructurePick,
@@ -481,6 +491,41 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
); );
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); 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<Parameters<typeof sectionView.setGradeEdit>[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 은 한 // 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와 // 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
// 같은 함수를 타므로 목록·폼·알약이 함께 선다. // 같은 함수를 타므로 목록·폼·알약이 함께 선다.
@@ -18,6 +18,11 @@ import {
} from "./B06_Section_Api_Fetch"; } from "./B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "./B06_Section_Section_Store"; import { invalidateSectionDetail } from "./B06_Section_Section_Store";
import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; 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 { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; 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<void> {
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<void> { async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
// ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도 // ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도
@@ -439,6 +453,13 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string):
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). // (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) => { await flushPendingStructures(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : ""; const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물 저장에 실패했습니다.${detail}`, "error"); showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
+14 -6
View File
@@ -45,7 +45,11 @@ import {
import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; 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 { import {
type StructureInstance, type StructureInstance,
type StructureType, type StructureType,
@@ -118,6 +122,8 @@ export interface SectionViewController {
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다 /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다
* (2026-09-12 B05·B06 일원화). */ * (2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void; setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 지금 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void; dispose: () => void;
} }
@@ -151,6 +157,7 @@ export function createSectionView(
let markStructures: ReadonlyArray<StructureInstance> = []; let markStructures: ReadonlyArray<StructureInstance> = [];
let markTypes: ReadonlyArray<StructureType> = []; let markTypes: ReadonlyArray<StructureType> = [];
let structureEdit: SectionStructureEdit | null = null; let structureEdit: SectionStructureEdit | null = null;
let gradeEditProvider: (() => LongitudinalPanelInput["grade"]) | null = null;
let renderWidth = 0; let renderWidth = 0;
let resizeTimer = 0; let resizeTimer = 0;
let panelResizeTimer = 0; let panelResizeTimer = 0;
@@ -508,6 +515,7 @@ export function createSectionView(
naturalSpoilSlope: currentNaturalSpoilSlope, naturalSpoilSlope: currentNaturalSpoilSlope,
selectStation: (stationId) => selectStation(stationId, true), selectStation: (stationId) => selectStation(stationId, true),
setMassBadge: (values) => massBadge.set(values), setMassBadge: (values) => massBadge.set(values),
grade: gradeEditProvider?.() ?? null,
}); });
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
syncScrollMargin(); syncScrollMargin();
@@ -525,11 +533,7 @@ export function createSectionView(
} }
} }
attachWindowScroll( attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel);
chartWrap,
() => updateChartWindow,
() => drawPanel(),
);
const draw = (): void => { const draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
@@ -663,6 +667,10 @@ export function createSectionView(
structureEdit = edit; structureEdit = edit;
drawPanel(); drawPanel();
}, },
setGradeEdit(provider) {
gradeEditProvider = provider;
drawPanel();
},
setStructureMarks(structures, types) { setStructureMarks(structures, types) {
markStructures = structures; markStructures = structures;
markTypes = types; markTypes = types;
@@ -32,6 +32,8 @@ export interface LongitudinalChartInput {
viewportWidth: number; viewportWidth: number;
/** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */ /** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */
onDragStation?: (stationId: string, toChainageM: number) => void; onDragStation?: (stationId: string, toChainageM: number) => void;
/** X축·측점 라벨을 바닥에서 이만큼(px) 올린다 — 계획고 편집 ▼ 버튼과 겹치지 않게. */
bottomInsetPx?: number;
} }
export interface LongitudinalChartResult { export interface LongitudinalChartResult {
@@ -114,7 +116,7 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi
undefined, undefined,
// 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화). // 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화).
input.onDragStation, input.onDragStation,
0, input.bottomInsetPx ?? 0,
0, 0,
visibleElevationRange(detail, fromM, toM) ?? undefined, visibleElevationRange(detail, fromM, toM) ?? undefined,
), ),
@@ -32,6 +32,8 @@ import {
moveMarkById, moveMarkById,
type SectionStructureEdit, type SectionStructureEdit,
} from "./B06_Section_UI_Section_View_Menu"; } 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 { export interface LongitudinalPanelInput {
@@ -53,6 +55,14 @@ export interface LongitudinalPanelInput {
conversion?: EarthworkConversion; conversion?: EarthworkConversion;
naturalSpoilSlope?: number; naturalSpoilSlope?: number;
selectStation: (stationId: string) => void; selectStation: (stationId: string) => void;
/** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/▼ 버튼층이 선다(B05 와 같은 부품).
* 안 넘기면 버튼이 없고 그래프만 보인다(옛 저장분처럼 선형이 없는 노선). */
grade?: {
alignment: ProfileAlignment;
/** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */
stepM: number;
onStation: (chainageM: number, delta: number) => void;
} | null;
/** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */ /** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */
setMassBadge: (values: ReturnType<typeof badgeValuesFrom> | null) => void; setMassBadge: (values: ReturnType<typeof badgeValuesFrom> | null) => void;
} }
@@ -91,6 +101,9 @@ export function drawLongitudinalPanel(
minWidth: chartWidth, minWidth: chartWidth,
scrollLeft: input.keepScrollLeft, scrollLeft: input.keepScrollLeft,
viewportWidth: chartWrap.clientWidth || chartWidth, viewportWidth: chartWrap.clientWidth || chartWidth,
// 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다
// (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다.
bottomInsetPx: input.grade ? 15 : 0,
// 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동. // 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동.
onDragStation: edit ? moveMark : undefined, onDragStation: edit ? moveMark : undefined,
}); });
@@ -130,6 +143,20 @@ export function drawLongitudinalPanel(
} }
chartWrap.replaceChildren(...nodes); 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 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군 // 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군
// → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다). // → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다).
if (edit) { if (edit) {