refactor(B05·B06): 옛 B06 계획선 편집 껍데기 정리 · 유토 뱃지 한 계산(PLAN 24-2a 덧붙임)

- B06_Section_UI_Page_Grade_Edit.ts 삭제 · 페이지 배선 · Section_View setGradeEdit · LegacyGradeEdit 제거
  (▲▼ 는 종단도 한 벌 · 계획고 가게는 노선마다 한 벌 — useProfileEditStore 도 걷음)
- 구조물 폐회로 면적을 재계산 창구(refreshCrossDesigns) 한 자리에서 얹음 — B05 · B06 유토 뱃지 같은 값
  (실측 전 B05 −14,144.5 · B06 −12,533.2 → 후 두 보기 −12,993.7 · B06 편집 뒤 −12,952.9)
- 시험: test_masshaul_single_source 에 한 자리 잠금 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
2026-09-29 21:02:02 +09:00
co-authored by Claude Opus 5.5
parent ac660e12c7
commit dd80662750
8 changed files with 27 additions and 125 deletions
+2 -14
View File
@@ -123,7 +123,7 @@ export function clearAlignmentDrafts(): void {
}
/**
* 노선마다 **한 벌**인 편집 상태 — B05 종단 패널이 주인이고 B06 계획선 편집은 같은 벌을
* 노선마다 **한 벌**인 편집 상태 — 두 보기의 종단도(`design_profile`)가 같은 벌을
* 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 —
* 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다.
*/
@@ -173,7 +173,7 @@ export function markAlignmentSaved(routeId: number): void {
* 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
*
* 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤
* 다시 읽는 길). 이미 선 한 벌에 붙기만 하려면 `useProfileEditStore`.
* 다시 읽는 길). 같은 노선이면 어느 화면이 불러도 같은 한 벌이다.
*
* routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에
* 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다.
@@ -190,18 +190,6 @@ export function createProfileEditStore(
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,
+12 -3
View File
@@ -43,6 +43,7 @@ import {
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import { readState } from "../A00_Common/b_page_state";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
export interface CrossRefreshEdits {
@@ -125,9 +126,17 @@ function preserveUserFields(
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const local = refreshLocally(input);
if (local !== null) return local;
return refreshFromServer(input);
const updated = refreshLocally(input) ?? (await refreshFromServer(input));
// 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시
// 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가
// 갈렸다(계획서 24-2a · 실측 B05 −14,144.5 · B06 −12,533.2㎥).
if (updated.length) {
applyStructureAreaRows(
input.detail.cross_sections,
structureAreaRows(input.detail.cross_sections),
);
}
return updated;
}
/**
+1 -9
View File
@@ -1,5 +1,4 @@
import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync";
import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
@@ -303,14 +302,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
// 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
const gradeEditFor = createGradeEdit({
routeId: () => currentRouteId,
detail: () => sectionDetail,
view: () => sectionView,
reconcile: () => reconcileStaleDesigns({ force: true }),
});
sectionView.setGradeEdit(gradeEditFor);
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
@@ -1,85 +0,0 @@
/* =============================================================================
* B06_Section_UI_Page_Grade_Edit.ts
* 종단 계획선 편집(▲/▼) 제공자. `_UI_Page` 700줄 제한 대응으로 떼어낸 것이며
* 동작은 그대로다(2026-09-13 분리).
* ========================================================================== */
import {
useProfileEditStore,
type ProfileEditStore,
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import {
adjustStation,
buildAlignment,
toAlignmentBase,
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { SectionViewController } from "./B06_Section_UI_Section_View";
type GradeEditProvider = NonNullable<Parameters<SectionViewController["setGradeEdit"]>[0]>;
export interface GradeEditContext {
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
view: () => SectionViewController;
/** 전 측점 횡단 재계산 — 손을 뗀 뒤 한 번만 돈다. */
reconcile: () => Promise<void>;
}
/**
* 계획선 편집(▲/▼) — B05 종단 패널의 **같은 가게**(노선마다 한 벌)를 쓴다(계획서 24-2a ·
* 2026-09-12 사용자: B06 에만 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`),
* 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장).
*/
export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
let gradeStore: ProfileEditStore | null = null;
let gradeRouteId: number | null = null;
/** ▲▼ 길게 누르기는 초당 10회(`HOLD_INTERVAL_MS`) 들어온다 — 그보다 길게 잡아
* 누르는 동안은 선만 움직이고, 손을 뗀 뒤 재계산이 한 번 돈다. */
const GRADE_RECONCILE_DEBOUNCE_MS = 120;
let gradeReconcileTimer = 0;
const scheduleGradeReconcile = (): void => {
window.clearTimeout(gradeReconcileTimer);
gradeReconcileTimer = window.setTimeout(() => {
void ctx.reconcile().then(() => ctx.view().setGradeEdit(gradeEditFor));
}, GRADE_RECONCILE_DEBOUNCE_MS);
};
const gradeEditFor: GradeEditProvider = () => {
const detail = ctx.detail();
const currentRouteId = ctx.routeId();
if (!detail || currentRouteId === null) return null;
const stored = readAlignment(detail.longitudinal);
if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다.
if (!gradeStore || gradeRouteId !== currentRouteId) {
gradeRouteId = currentRouteId;
gradeStore = useProfileEditStore(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));
// ① 선은 **그 자리에서** 움직인다 — 그래프가 읽는 계획선(`design_profiles`)만
// 갈아 끼우고 다시 그린다(재계산을 기다리면 누른 뒤 한참 뒤에 움직였다).
const current = ctx.detail();
if (current) {
current.longitudinal.design_profiles = [
toDesignProfile(
buildAlignment(base, store.edits()),
current.longitudinal.design_profiles?.[0],
),
];
}
ctx.view().setGradeEdit(gradeEditFor);
// ② 전 측점 횡단 재계산·카드 갱신은 무겁다 — 마지막 한 번만(B05 프리뷰와 같은 규칙).
// 편집분은 세션 초안에 있으므로 재계산이 그것을 그대로 읽는다.
scheduleGradeReconcile();
},
};
};
return gradeEditFor;
}
@@ -450,9 +450,6 @@ export function createSectionView(
setStructureEdit(edit) {
structureEdit = edit;
},
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은
// 막기 규칙. 옛 제공자는 받기만 하고 쓰지 않는다(페이지 쪽 정리는 24-2a 몫).
setGradeEdit() {},
setStructureMarks(structures, types) {
profile.setMarks(structures, types);
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
@@ -11,14 +11,6 @@ import type {
} from "./B06_Section_Api_Fetch";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
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;
}
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
@@ -60,7 +52,5 @@ export interface SectionViewController {
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 옛 계획선 편집 제공자 자리 — 받기만 한다. ▲/▼ 는 종단도 한 벌이 맡는다(계획서 24-2c). */
setGradeEdit: (provider: (() => LegacyGradeEdit | null) | null) => void;
dispose: () => void;
}
@@ -101,7 +101,7 @@ async function run() {
// ② 계획고 가게 한 벌 — 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);
const b06 = edit.createProfileEditStore(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();
@@ -64,3 +64,14 @@ def test_cross_refresh_preserves_user_fields():
"revet_follow_grade",
):
assert f'"{field}"' in REFRESH # preserveUserFields 의 보존 필드 목록
def test_cross_refresh_lays_structure_areas_once():
"""구조물 폐회로 면적은 재계산 창구 **한 자리**에서 얹는다(계획서 24-2a).
부르는 쪽(B05 프리뷰 · B06 설계 맞춤)마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의
유토 뱃지가 갈렸다(실측 B05 −14,144.5 · B06 −12,533.2㎥)."""
body = REFRESH[REFRESH.index("export async function refreshCrossDesigns") :]
body = body[: body.index("\n}\n")]
assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body
assert "applyStructureAreaRows(" not in B05_PREVIEW