Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-19 09:40:17 +09:00
18 changed files with 420 additions and 166 deletions
+23 -5
View File
@@ -6,9 +6,12 @@
* 서버로(자동저장 없음). 서버가 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로).
* 시작 측점은 B05 표시 전용 — 측점 열쇠 · 다른 페이지 라벨에 안 씀.
* 시 · 종점 계획고 조정은 여기 없음 — 첫 · 끝 측점 계획고 편집(`alignment` 차례)이 정본.
* 값 주인 = 설계 가게 `pageSettings` 칸(472-5) — 폼 · 기울기 한도 · 최소고 강제가 가게를 듣고 읽음 ·
* ↶ ↷ 가 세션을 되쓰면 가게가 듣는 쪽을 다시 부름(N6) · 최소고 강제 사본 칸 없음(N7).
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
import {
readRouteLatestCache,
savePageSettings,
@@ -42,10 +45,25 @@ export const panelValuesOfDraft = (draft: PageSettingsDraft): Partial<RoutePanel
});
export const readPageSettings = (projectId: string): PageSettingsDraft | null =>
readState<PageSettingsDraft>("pagesettings", projectId);
useDesignStore(projectId, null).get("pageSettings");
export const writePageSettings = (projectId: string, value: PageSettingsDraft): void =>
writeState("pagesettings", value, projectId);
export const writePageSettings = (projectId: string, value: PageSettingsDraft | null): void =>
useDesignStore(projectId, null).set("pageSettings", value);
/** 횡단배수 최소고를 편집에서 강제할지 — 초안이 있으면 초안 · 없으면 저장 노선 설정(기본 해제). */
export function enforceMinCoverOf(projectId: string): boolean {
const draft = readPageSettings(projectId);
if (draft) return draft.enforce_pipe_clearance === true;
return readRouteLatestCache(projectId)?.route_params?.enforce_pipe_clearance === true;
}
// 되돌리기 · 다시하기는 세션을 바로 되씀 — 가게로 다시 알려 폼 · 기울기 한도 · 시작 측점이 따라옴(N6)
onPageHistory({
restore: () => {
const projectId = draftProjectId();
writePageSettings(projectId, readPageSettings(projectId));
},
});
/** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움
* (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */
@@ -63,5 +81,5 @@ export async function flushPageSettings(projectId: string): Promise<void> {
};
writeRouteLatestCache(projectId, latest);
}
clearState("pagesettings", projectId);
writePageSettings(projectId, null);
}
+19 -3
View File
@@ -89,10 +89,27 @@ export function panelValuesOf(next: RouteLatestResponse): Partial<RoutePanelValu
};
}
/** 페이지 설정 한 칸(463 · 요구 271-5) — 종단도 기울기 상한 · 최소고 강제 · 시작 측점 = 세션 초안(448) ?? 저장 노선
/** 폼의 페이지 설정 칸만 — 초안 ?? 저장 노선(빈 기준값 = 빈 칸 · 강제 = 해제) · ↶ ↷ 뒤 폼에 다시 얹음(N6 · 472-5). */
export function pageSettingsPanelValues(projectId: string): Partial<RoutePanelValues> | null {
const latest = readRouteLatestCache(projectId);
const draft = readPageSettings(projectId);
if (!latest) return draft ? panelValuesOfDraft(draft) : null;
const all = panelValuesOf(latest);
return {
designSpeed: all.designSpeed,
terrainType: all.terrainType,
maxGradePct: all.maxGradePct ?? null,
minVerticalRadius: all.minVerticalRadius ?? null,
minTangentLength: all.minTangentLength ?? null,
enforcePipeClearance: all.enforcePipeClearance === true,
stationDisplay: all.stationDisplay ?? { station: 0, cumulative: 0 },
};
}
/** 페이지 설정 한 칸(463 · 요구 271-5) — 종단도 기울기 상한 · 시작 측점 = 세션 초안(448) ?? 저장 노선
* 설정(빈 칸 = 좌측 패널 기본). 어느 보기로 서도 종단도를 그린 뒤 얹음 · B05 패널은 세션에 쓰고 고친 즉시 얹음. */
export function applyPageSettings(
profile: Pick<DesignProfile, "setGradeLimit" | "setEnforceMinCover" | "setStationDisplay">,
profile: Pick<DesignProfile, "setGradeLimit" | "setStationDisplay">,
projectId: string,
latest: RouteLatestResponse | null = readRouteLatestCache(projectId),
): void {
@@ -106,6 +123,5 @@ export function applyPageSettings(
profile.setGradeLimit(
gradeLimitOf(speed, values.terrainType ?? "special", values.maxGradePct ?? null),
);
profile.setEnforceMinCover(values.enforcePipeClearance === true);
profile.setStationDisplay(values.stationDisplay ?? { station: 0, cumulative: 0 });
}
+78 -79
View File
@@ -73,7 +73,7 @@ export interface ProfileEditStore {
/** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */
savedRadii(): Readonly<Record<string, number>>;
replace(next: AlignmentEdits): void;
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다.
/** 서버 저장이 끝났음을 표시한다 — 초안을 저장분으로 옮기고 초안을 지운다.
* 이때의 값이 [초기화]의 기준점이 된다. */
markSaved(): void;
/** 마지막 [저장] 시점으로 되돌린다 — 그 뒤의 편집만 버린다(2026-09-03 사용자 지시). */
@@ -87,6 +87,8 @@ export interface ProfileEditStore {
dirty(): boolean;
/** 자동 선형 대비 편집 델타가 하나라도 있는가. */
edited(): boolean;
/** ↶ ↷ 가 세션을 되쓴 뒤 — 이 창구가 마지막으로 본 편집과 다른가(다시 셈 여부). */
resync(): boolean;
}
/** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */
@@ -111,124 +113,84 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul
* 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남).
*/
export function clearAlignmentDrafts(): void {
shared.clear();
// 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다.
clearState("alignment-draft", draftProjectId());
}
/**
* 노선마다 **한 벌**인 편집 상태 — 두 보기의 종단도(`design_profile`)가 같은 벌을
* 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 —
* 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다.
* 노선마다 편집 값 주인 = 설계 가게 두 칸(472-5) — 두 보기의 종단도(`design_profile`)가 같은 칸을 읽는다.
* `alignmentDraft` = 미저장 편집(세션 · 되돌리기가 사진 찍음) · 있으면 미저장
* `alignmentSaved` = 서버 저장분(메모리 · 종단도가 상세를 받을 때 · [저장]이 옮김) — [초기화]의 기준점 ·
* 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준(2026-08-23 · 2026-09-03 사용자 지시)
* 지금 편집 = 초안 ?? 저장분 — 모듈 사본이 없어 ↶ ↷ 가 세션을 되써도 두 보기가 같은 값을 본다.
*/
interface EditState {
current: AlignmentEdits;
unsaved: boolean;
/**
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
*/
savedBaseline: AlignmentEdits;
/** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */
saved: AlignmentEdits;
}
const shared = new Map<number, EditState>();
/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */
function freshState(routeId: number | null, saved: AlignmentEdits): EditState {
const draft = readAlignmentDraft(routeId);
return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved };
}
const savedOf = (routeId: number | null): AlignmentEdits =>
(routeId === null ? null : draftOf(routeId).get("alignmentSaved")) ?? emptyEdits();
/** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */
export function pendingAlignment(routeId: number): AlignmentEdits | null {
const state = shared.get(routeId);
if (state) return state.unsaved ? state.current : null;
return readAlignmentDraft(routeId);
}
/** 초안을 다른 노선 칸에 둔다 — 노선 저장 뒤 다른 차례가 실패(433-12 · 다음 [저장]이 새 노선으로 보냄). */
export function writeAlignmentDraft(routeId: number, edits: AlignmentEdits): void {
draftOf(routeId).set("alignmentDraft", edits);
const state = shared.get(routeId);
if (state) Object.assign(state, { current: edits, unsaved: true });
}
/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */
/** 계획고가 정본에 나갔다 — 그 노선 초안을 저장분으로 옮기고 초안을 지운다. */
export function markAlignmentSaved(routeId: number): void {
const state = shared.get(routeId);
if (state) {
state.unsaved = false;
state.savedBaseline = state.current;
}
draftOf(routeId).set("alignmentDraft", null);
const store = draftOf(routeId);
const draft = store.get("alignmentDraft");
if (draft) store.set("alignmentSaved", draft);
store.set("alignmentDraft", null);
}
/**
* 편집 델타 보관소.
* 편집 창구.
*
* 초기값은 **서버에 저장된 편집분**이고, 세션 초안이 남아 있으면(= 확정 없이
* 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면
* 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
*
* 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤
* 다시 읽는 길). 같은 노선이면 어느 화면이 불러도 같은 한 벌이다.
* `saved` = 서버에 저장된 편집분(종단도가 상세를 받을 때) — 주면 그 노선 저장분 칸을 갈아 끼움 · 안 주면
* 칸 그대로. 세션 초안이 남아 있으면(= 확정 없이 새로고침한 경우) 초안이 이기고 미저장이다. routeId가
* 바뀌면 칸도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
*
* routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에
* 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다.
*/
export function createProfileEditStore(
routeId: number | null,
saved: AlignmentEdits,
saved: AlignmentEdits | undefined,
onChange: () => void,
): ProfileEditStore {
const fresh = freshState(routeId, saved);
// 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다.
const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh);
if (routeId !== null) shared.set(routeId, state);
return handleOf(routeId, state, onChange);
}
function handleOf(
routeId: number | null,
state: EditState,
onChange: () => void,
): ProfileEditStore {
const draft = routeId === null ? null : draftOf(routeId);
function dropDraft(): void {
draft?.set("alignmentDraft", null);
}
const cells = routeId === null ? localCells(saved) : storeCells(routeId, saved);
const editsNow = (): AlignmentEdits => cells.draft() ?? cells.saved();
/** 이 창구가 마지막으로 본 편집 — ↶ ↷ 가 세션을 되쓴 뒤 바뀌었는지(`resync`). */
let seen = JSON.stringify(editsNow());
function commit(next: AlignmentEdits): void {
state.current = next;
state.unsaved = true;
draft?.set("alignmentDraft", state.current);
seen = JSON.stringify(next);
cells.setDraft(next);
onChange();
}
return {
edits: () => state.current,
savedRadii: () => state.saved.curve_radii ?? {},
edits: editsNow,
savedRadii: () => cells.saved().curve_radii ?? {},
replace: commit,
markSaved() {
state.unsaved = false;
state.savedBaseline = state.current;
dropDraft();
const draft = cells.draft();
if (draft) cells.setSaved(draft);
cells.setDraft(null);
},
restoreSaved() {
state.current = state.savedBaseline;
state.unsaved = false;
dropDraft();
seen = JSON.stringify(cells.saved());
cells.setDraft(null);
onChange();
},
canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline),
canRestoreSaved: () => JSON.stringify(editsNow()) !== JSON.stringify(cells.saved()),
resetStation(chainageM, moveTo) {
const current = editsNow();
const key = chainageKey(chainageM);
const stationOffsets = { ...state.current.station_offsets };
const curveRadii = { ...state.current.curve_radii };
const stationOffsets = { ...current.station_offsets };
const curveRadii = { ...current.curve_radii };
if (moveTo !== undefined) {
const to = chainageKey(moveTo);
if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key];
@@ -240,7 +202,7 @@ function handleOf(
delete stationOffsets[key];
// 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면
// 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23).
const savedRadius = state.saved.curve_radii?.[key];
const savedRadius = cells.saved().curve_radii?.[key];
if (savedRadius === undefined) delete curveRadii[key];
else curveRadii[key] = savedRadius;
commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
@@ -249,10 +211,47 @@ function handleOf(
// 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경
// (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 —
// 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단).
commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } });
commit({ ...emptyEdits(), curve_radii: { ...(cells.saved().curve_radii ?? {}) } });
},
dirty: () => state.unsaved,
edited: () => hasEdits(state.current),
dirty: () => cells.draft() !== null,
edited: () => hasEdits(editsNow()),
resync() {
const now = JSON.stringify(editsNow());
const changed = now !== seen;
seen = now;
return changed;
},
};
}
interface EditCells {
draft(): AlignmentEdits | null;
setDraft(value: AlignmentEdits | null): void;
saved(): AlignmentEdits;
setSaved(value: AlignmentEdits): void;
}
/** 노선 칸 = 설계 가게 두 칸. */
function storeCells(routeId: number, saved: AlignmentEdits | undefined): EditCells {
const store = draftOf(routeId);
if (saved) store.set("alignmentSaved", saved);
return {
draft: () => store.get("alignmentDraft"),
setDraft: (value) => store.set("alignmentDraft", value),
saved: () => savedOf(routeId),
setSaved: (value) => store.set("alignmentSaved", value),
};
}
/** 노선이 아직 없음 — 초안을 세션에 두지 않음(창구 안 메모리만 · 2026-09-04). */
function localCells(saved: AlignmentEdits | undefined): EditCells {
let draft: AlignmentEdits | null = null;
let base = saved ?? emptyEdits();
return {
draft: () => draft,
setDraft: (value) => void (draft = value),
saved: () => base,
setSaved: (value) => void (base = value),
};
}