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
-2
View File
@@ -218,8 +218,6 @@ export const STATE_REGISTRY = {
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
/** 종단도 최소 흙덮기 강제 여부 — 종단 보기 「페이지 설정」 값을 두 보기가 같이 본다(24-2c). */
"profile-enforce-min-cover": { bucket: "result", scope: "project" },
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
* 노선이 바뀌면 `latest` 와 함께 버린다. */
+20 -59
View File
@@ -12,9 +12,7 @@
* ========================================================================== */
import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans";
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
StructureInstance,
StructureType,
@@ -24,7 +22,6 @@ import {
readAlignment,
toDesignProfile,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
@@ -46,6 +43,9 @@ import {
toAlignmentBase,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { enforceMinCoverOf } from "../../B05_Profile/B05_Profile_Api_PageSettings";
import { applyPageSettings } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { useDesignStore } from "./design_store";
import {
endOffsetsOf,
withEndOffset,
@@ -86,11 +86,7 @@ import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import { CROSS_PREVIEW_DEBOUNCE_MS, type DesignProfileContext } from "./design_profile_context";
import { pageRouteId } from "./design_page_route";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
import { createProfileFrame } from "./design_profile_frame"; // 판 뼈대 · 종단 CSS(700줄 한계)
export type {
DesignOverlayBoard,
@@ -104,30 +100,8 @@ export type { DesignProfileContext } from "./design_profile_context";
export function createDesignProfile(ctx: DesignProfileContext) {
const { projectId, onSelectStation, onResetAll, callbacks } = ctx;
adoptLegacyProfileKeys();
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = ctx.view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
const { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing } =
createProfileFrame(ctx.view);
// 유토곡선 · 테이블 — 종단 칸 바닥 겹침 판(2026-08-04 · 392-2) · 위 경계 리사이저로 높이 조절.
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
@@ -144,13 +118,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
body,
onChange: subPanelChanged,
});
const content = document.createElement("div");
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제).
* 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */
let enforceMinCover = readState<boolean>("profile-enforce-min-cover", projectId) === true;
/** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */
const takePipes = (pipes: ReadonlyArray<MinCoverPipe>): void => {
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
@@ -204,8 +173,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 노선 0 칸(alignment-draft · 346-1). */
let preview: typeof store | null = null;
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
let savedEdits: AlignmentEdits = emptyEdits();
let redrawPending = false;
let lastWidth = 0;
let lastHeight = 0;
@@ -270,8 +237,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
@@ -294,14 +259,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
// 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다.
drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined },
restore: () => {
// 세션이 정본 — 편집 초안을 다시 읽음 · 노선 초안 동안은 노선 0 칸 · 계획고가 그대로면 다시 셈 예약 없음(349-0q)
const [was, draft] = [JSON.stringify(store.edits()), store === preview];
const id = draft ? pagePipeline.DRAFT_ROUTE_ID : routeId;
store = createProfileEditStore(id, draft ? store.edits() : savedEdits, () => rebuild());
if (draft) preview = store;
void (JSON.stringify(store.edits()) !== was ? rebuild() : draw());
},
// 세션이 정본 — 가게가 편집 초안을 다시 읽음(노선 초안 동안은 노선 0 칸) · 계획고가 그대로면 다시 셈 예약 없음(349-0q)
restore: () => void (store.resync() ? rebuild() : draw()),
restoreSaved: () => store.restoreSaved(),
canRestoreSaved: () => store.canRestoreSaved(),
refresh: () => renderBalance(),
@@ -323,7 +282,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
return false;
}
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;
// 최소고 강제 = 페이지 설정 한 칸(초안 ?? 저장 노선 · 472-5) — 보기마다 사본 없음
const enforce = enforceMinCoverOf(projectId);
if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;
store.replace(next);
history.record();
return true;
@@ -469,7 +430,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes));
}
return {
const profile = {
root,
/** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */
setView(next: DesignView) {
@@ -488,8 +449,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
if (routeChanged || (store !== preview && !store.dirty())) {
routeId = nextRouteId ?? routeId;
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
}
storedBase = stored ? toAlignmentBase(stored) : null;
if (store !== preview) base = listed(); // 노선 초안 그림은 그대로(갈아끼운 상세)
@@ -538,11 +498,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
draw();
revealPick();
},
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
enforceMinCover = enforce;
writeState("profile-enforce-min-cover", enforce, projectId);
},
/** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */
setMinCoverPipes(pipes: ReadonlyArray<MinCoverPipe>) {
takePipes(pipes);
@@ -630,7 +585,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
rebuild(),
);
if (draft) return void ((base = nextBase), rebuild());
if (store === preview) store = createProfileEditStore(routeId, savedEdits, () => rebuild());
if (store === preview) store = createProfileEditStore(routeId, undefined, () => rebuild());
[preview, base] = [null, listed()];
alignment = base && buildAlignment(base, store.edits()); // 그리기만 · 다시 셈 예약 없음(349-0l)
drawSoon();
@@ -683,8 +638,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
drag.dispose();
crossPreview.dispose();
unbindViewport();
unbindSettings();
},
};
// 페이지 설정 = 가게 한 칸 — ↶ ↷ · 다른 보기가 고친 기울기 한도 · 시작 측점을 다시 얹음(N6 · 472-5)
const unbindSettings = useDesignStore(projectId, null).subscribe("pageSettings", () =>
applyPageSettings(profile, projectId),
);
return profile;
}
/** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */
+45
View File
@@ -0,0 +1,45 @@
/* =============================================================================
* A00_Common/design/design_profile_frame.ts
* 종단도 한 벌의 **판 뼈대**(DOM 틀) — `design_profile.ts` 에서 갈라냄(700줄 한계 · 472-5).
* 그리기 · 편집 · 상태는 저쪽 몫 · 여기는 칸만 만들어 돌려줌.
* ========================================================================== */
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createProgressCircle } from "@ui/ui_template_progress";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
export function createProfileFrame(view: string) {
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
return { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing };
}
+23 -2
View File
@@ -12,7 +12,9 @@
*
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
* 값은 안 받는다(387-1). B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다. `alignmentSaved` = 그 노선 서버
* 저장분(메모리만 · 같은 프로젝트 규칙 · 472-5) — 지금 편집 = 초안 ?? 저장분(`_Profile_Edit`).
* `pageSettings` = 등록부 `pagesettings`(페이지 설정 초안 · 448) — 폼 · 기울기 한도 · 최소고 강제가 읽음.
*
* 계산 묶음 칸(344-4 · 모양 = `design_pipeline_types.ts`) — `routeDraft` 는 초안(세션 즉시) ·
* `stations` · `profile` · `footprint` 는 **메모리 우선**(결과가 커서 끌기마다 세션에 쓰지 않음 ·
@@ -24,6 +26,7 @@ import type { StateName } from "../b_page_state";
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PageSettingsBody } from "../../B05_Profile/B05_Profile_Api_Fetch";
import type { AlignmentEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import type { ProfileAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
@@ -48,6 +51,10 @@ export interface DesignStoreValues {
pipes: DetailPipeInput[] | null;
/** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */
alignmentDraft: AlignmentEdits | null;
/** 그 노선 서버 저장분 계획고 편집 — 초안이 없을 때의 지금 값 · [초기화] 기준점(메모리만). */
alignmentSaved: AlignmentEdits | null;
/** 미저장 페이지 설정(설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점). */
pageSettings: PageSettingsBody | null;
/** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */
sectionDetail: SectionDetailResponse | null;
/** 고른 측점 · 구조물. */
@@ -120,6 +127,16 @@ function slotOf<K extends DesignStoreKey>(
write: (value) => writeState("alignment-draft", value, project, routeId),
};
}
if (key === "alignmentSaved") {
if (routeId === null) return { id: null, read: () => null, write: () => undefined };
const id = `alignment-saved:${draftProjectId(projectId)}:${routeId}`;
return {
id,
read: () => (savedAlignments.get(id) as V | undefined) ?? null,
write: (value) =>
void (value === null ? savedAlignments.delete(id) : savedAlignments.set(id, value)),
};
}
if (key in MEMORY_SLOTS) return memorySlot<V>(key as MemoryKey, projectId);
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
const name = (
@@ -130,8 +147,9 @@ function slotOf<K extends DesignStoreKey>(
sectionDetail: "section-detail",
selection: "selection",
routeDraft: "route-draft",
pageSettings: "pagesettings",
} as const
)[key as Exclude<DesignStoreKey, "alignmentDraft" | MemoryKey>];
)[key as Exclude<DesignStoreKey, "alignmentDraft" | "alignmentSaved" | MemoryKey>];
const routeScoped = name === "section-detail";
if (!projectId || (routeScoped && routeId === null)) {
return { id: null, read: () => null, write: () => undefined };
@@ -144,6 +162,9 @@ function slotOf<K extends DesignStoreKey>(
};
}
/** 노선마다 서버 저장분 계획고 편집(`alignmentSaved`) — 종단도가 상세를 받을 때 채움. */
const savedAlignments = new Map<string, unknown>();
/** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */
const MEMORY_SLOTS = {
stations: "design-stations",
+9 -2
View File
@@ -68,6 +68,7 @@ import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { pipeIdAt } from "./design_pipe_ids";
import { useDesignStore } from "./design_store";
import { pageSettingsPanelValues } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { setPlusStations } from "../../common_util/common_util_structure_station";
import {
loadSavedPlusStations,
@@ -346,10 +347,16 @@ export async function mountProfileView(
function markStale(): void {
if (state.restoring || !state.routeReady) return;
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
// 횡단배수 최소고 강제 체크는 페이지 설정 한 칸(가게)을 편집 가드가 바로 읽음(2026-09-01 · 472-5).
sections.redrawStationLines();
}
// 페이지 설정 = 가게 한 칸 — ↶ ↷ 가 되쓴 값을 폼에 다시 얹음(N6 · 472-5)
host.onDispose(
designStore.subscribe("pageSettings", () => {
const values = pageSettingsPanelValues(activeProjectId);
if (values) panel.restore(values);
}),
);
viewer.markers.onChange(markStale);
viewer.markers.onStationSelectionChange((stationId) => {
+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),
};
}
+2 -2
View File
@@ -30,7 +30,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
```
사용자 조작(끌기 · 단추 · 칸)
▼
① 화면 메모리 — 편집기 · 종단 EditState · 배수판 · 보기 구조물 · B06 조정 맵 · 시설 줄
① 화면 메모리 — 편집기 · 배수판 · 보기 구조물 · B06 조정 맵 · 시설 줄
② 상세 가게 — Section_Store cache(페이지 수명) · 정본 사본 canon(세션)
③ 설계 가게 design_store — stations · profile · footprint 는 메모리 우선(멈춘 뒤 1 s 세션)
④ 세션 sessionStorage — aislo:{통}:{이름}:{프로젝트}[:{노선}] · draft · result · pref(localStorage)
@@ -47,7 +47,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 층 | 위치(파일:줄) | 역할 · 비우는 때 |
|---|---|---|
| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Profile_Edit.ts:137` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `B06_Section_UI_Page_Link_Session.ts:47` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 |
| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `B06_Section_UI_Page_Link_Session.ts:47` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 |
| ② 상세 가게 | `B06_Section_Section_Store.ts:68` cache · `:69` pending · `:117` loadSectionDetail · `:431` peekSectionDetail | 상세 횡단(살아 있는 객체) · 떠남 `forgetSectionMemory`(`:424`) · 정본 바뀜 `invalidateSectionDetail`(`:403`) |
| ② 정본 사본 | `B06_Section_Section_Canon.ts:36` keepCanon · `:59` sessionDetail · `:72` savedDesigns · `:84` forgetCanon | 서버 정본 설계 · 자리 · 표본 · invalidate 때 비움 |
| ③ 설계 가게 | `design_store.ts:226` useDesignStore · `:151` 메모리 칸 · `:160` 1 s | 읽고 쓰는 창구 · 쓰면 듣는 쪽 부름 |
@@ -35,10 +35,10 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 조작 | 처음 놓이는 곳 | 브라우저 즉시 셈(모듈) | 서버 뒤 셈(API) | [저장]·[확정] 때 | 사본 | 위치(파일:줄) |
|---|---|---|---|---|---|---|
| 계획고 ▲▼ | 종단 메모리 `EditState.current` → 세션 `alignment-draft` | 종단선 · 묶음 profile → cross(전 측점 횡단 · 면적) → 누가토량 · 알약 | 유토 배분(400 ms 뒤 · Node · `_derived/haul`) | `PUT /route/profile-alignment`(편집 델타) · 파이썬 종단 다시 셈 · 횡단 차례 Node 면적 · 누가토량 | 8 | `design_profile.ts:311-344` · `B05_Profile_UI_Profile_Edit.ts:124-137 · 389-400` · `logic/B05_Profile_UI_Profile_Alignment.ts:566-603` · `B05_Profile_Router.py:416-500` → `B05_Profile_Engine_Grade_Profile.py:537-575` |
| 계획고 ▲▼ | 가게 `alignmentDraft` → 세션 `alignment-draft`(지금 값 = 초안 ?? 가게 `alignmentSaved` · 모듈 사본 없음) | 종단선 · 묶음 profile → cross(전 측점 횡단 · 면적) → 누가토량 · 알약 | 유토 배분(400 ms 뒤 · Node · `_derived/haul`) | `PUT /route/profile-alignment`(편집 델타) · 파이썬 종단 다시 셈 · 횡단 차례 Node 면적 · 누가토량 | 8 | `design_profile.ts:311-344` · `B05_Profile_UI_Profile_Edit.ts:113-258` · `logic/B05_Profile_UI_Profile_Alignment.ts:566-603` · `B05_Profile_Router.py:416-500` → `B05_Profile_Engine_Grade_Profile.py:537-575` |
| 직선화 | ▲▼ 와 같음(R = L/Δ 로 적음) | ▲▼ 와 같음 · 실시간 띠 | 같음 | 같음 | 8 | `B05_Profile_UI_Profile_Panel_Tools.ts:133-158` · `logic/B05_Profile_UI_Profile_Straighten.ts` · `logic/B05_Profile_Profile_CurveLaw.ts:21` |
| 자유지정 | 그리기 자체 되돌리기 → 끝나면 ▲▼ 와 같음 | 같음 | 같음 | 같음 | 8 | `logic/design_profile_free.ts:127-170 · 224-313` |
| 쉬프트(직선 구간 올림 · 내림) | ▲▼ 와 같음 | 같음 · 붉은 강조 | 같음 | 같음 | 8 | `B05_Profile_UI_Profile_Tools.ts:111-114 · 216-217` |
| 종단 R(L 칸) | `curve_radii` → `alignment-draft` | 같음 | 같음 | 같음 · 서버는 R 로 받음 | 8 | `B05_Profile_UI_Profile_Table.ts:360-413` · `logic/B05_Profile_UI_Profile_Alignment.ts:685-695` |
| 시 · 종점 계획고 | `alignment-draft`(끝 측점 ▲▼) | 같음 | 같음 | 같음 | 8 | `B05_Profile_UI_Panel_Settings.ts:113-133` · `logic/B05_Profile_EndOffsets.ts:43` |
| 페이지 설정(설계속도 · 지형 · 기준 · 최소고 강제) | 세션 `pagesettings` + result `profile-enforce-min-cover` | 최대 기울기만 종단에 얹음 · 횡단 안 셈 | — | `PUT /route/page-settings`(설정 차례) · 서버는 경고만 다시(기하 그대로) | 4 | `B05_Profile_UI_Panel.ts:130-142` · `B05_Profile_Api_PageSettings.ts:47-67` · `B05_Profile_UI_Page_Latest.ts:94-111` · `B05_Profile_PageSettings.py:79-107` |
| 페이지 설정(설계속도 · 지형 · 기준 · 최소고 강제) | 가게 `pageSettings` → 세션 `pagesettings`(최소고 강제 = 편집 가드가 이 칸을 바로 읽음) | 최대 기울기만 종단에 얹음 · 횡단 안 셈 · ↶ ↷ 뒤 가게가 폼 · 기울기 한도 · 시작 측점을 다시 부름 | — | `PUT /route/page-settings`(설정 차례) · 서버는 경고만 다시(기하 그대로) | 4 | `B05_Profile_UI_Panel.ts:130-142` · `B05_Profile_Api_PageSettings.ts:47-85` · `B05_Profile_UI_Page_Latest.ts:92-128` · `design_profile.ts:645` · `design_view_profile.ts:355` · `B05_Profile_PageSettings.py:79-107` |
-1
View File
@@ -51,7 +51,6 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 |
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 |
| ↶ ↷ 가 세션을 직접 되씀 — B06 조정 메모리 맵 · B05 상단측 맵이 옛 값일 수 있음 | `design_store.ts:231-235` | 추정 |
| 페이지 설정 ↶ = 세션만 · 폼 · 기울기 한도 안 얹음 · `profile-enforce-min-cover` = result 통 | `b_page_state.ts:226` | 추정 |
| 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 |
| `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 |
| 「[]」 — 되돌리기 = 값 · 떠남 확인 = 빈 칸 | `B05_Profile_UI_Profile_History.ts:50` · `design_leave.ts:41-42` | 코드 확인 |
@@ -40,7 +40,6 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common
| `section-detail` | `:232` | 상세 서버 정본 사본(브라우저 셈 안 실음) | `invalidateSectionDetail` |
| `routeedit-initial` | `:247` | 추천 노선 | 추천 다시 셈 |
| `design-stations` · `design-profile` · `design-footprint` | `:249-251` | 가게 메모리 칸의 세션 사본(멈춘 뒤 1 s) | 가게 칸 지움 |
| `profile-enforce-min-cover` | `:226` | 최소고 강제(사용자 값인데 result 통) | 결과 비우기 |
## pref(localStorage)
+2 -2
View File
@@ -17,7 +17,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 측점 목록 · 열쇠 | DB `cross_sections` 행 · 종단 파일 stations · 서버 `generate_sections` | 묶음 stations · 가게 `stations`(메모리) → 세션 `design-stations` · 상세 cache · 세션 `section-detail` · canon · `design_pipeline_page.ts` savedStations · `B06_Section_Station_Keys.ts:92` 목록 | TS `logic/design_pipeline_stations.ts` · `logic/B05_Profile_Sections_Stations.ts:276` ↔ 파이썬 `B05_Profile_Engine_Sections_Core.py:449` · 열쇠 `common_util_station_key.ts:32` ↔ `.py:32` · `test_344_3_pipeline_stations_mirror` · `test_344_3_station_key_mirror` | 가게 `stations` 알림 → 갈아끼움(구독 둘 · 같은 목록이면 안 함) · 조용한 차례 끝 = 상세 측점(`design_pipeline_page_publish.ts` shown) · [저장] 뒤 비움 |
| 측점 자리 · 축 | 서버 측점 틀(저장 경로 폴리라인) | 브라우저 측점 틀(`/route/plan` planned 선) · 상세 cache frame | TS `logic/B05_Profile_UI_RouteEdit_Frame.ts:61` ↔ 파이썬 `B05_Profile_Engine_Sections_Core.py:449-519`(같은 식 · 입력 선 다름 · mm 차) · `test_b05_routeedit_frame_mirror` | 노선 초안 없는 차례 = 정본 자리 · 축 유지(`logic/B06_Section_Station_Swap.ts:238-247`) |
| 지반 표본 | 서버 `sample_xy`(npz · 저장 노선 선) → `cross_*.json` samples | 지형 격자(`B05_Profile_Api_TerrainGrid.ts:29-31` · IndexedDB) · 묶음 ground · 상세 cache samples | TS `common_util_surface_sampler.ts:65` ↔ 파이썬 `common_util_surface_sampler.py:210` · `test_344_1_surface_sampler_mirror` | 갈아끼움 — 노선 초안 없음 = 같은 자리(0.01 m · 축 1e-3) 앞 횡단 → 그 자리 옛 횡단 → 저장 정본 표본 · 노선 초안 = 새 · 옮긴 측점만 브라우저 표본 |
| 계획고 편집(▲▼ · 직선화 · 자유 · 쉬프트 · 시종점) | 종단 파일 `profile_alignment.edits` | `EditState` current · saved(`B05_Profile_UI_Profile_Edit.ts:137`) · 세션 `alignment-draft`(노선 · 초안 노선 0) · 묶음 입력 · 가게 `profile` → `design-profile` · 상세 `longitudinal` | TS `logic/B05_Profile_UI_Profile_Alignment.ts:401` ↔ 파이썬 `B05_Profile_Engine_Grade_Alignment.py:494` · `test_426_profile_straight` · `test_432_1_curve_law` | 커밋마다 세션 · [저장] = 서버 다시 셈 |
| 계획고 편집(▲▼ · 직선화 · 자유 · 쉬프트 · 시종점) | 종단 파일 `profile_alignment.edits` | ★ 가게 `alignmentDraft`(세션 `alignment-draft`) · 가게 `alignmentSaved`(서버 저장분 · 메모리 · `B05_Profile_UI_Profile_Edit.ts:127 · 235`)(노선 · 초안 노선 0) · 묶음 입력 · 가게 `profile` → `design-profile` · 상세 `longitudinal` | TS `logic/B05_Profile_UI_Profile_Alignment.ts:401` ↔ 파이썬 `B05_Profile_Engine_Grade_Alignment.py:494` · `test_426_profile_straight` · `test_432_1_curve_law` | 커밋마다 세션 · [저장] = 서버 다시 셈 |
| 종단 R | 같은 `edits.curve_radii` | 위와 같음 · 칸은 L · 저장은 R | TS `logic/B05_Profile_Profile_CurveLaw.ts` ↔ `B05_Profile_Engine_Grade_CurveLaw.py` · `test_432_1_curve_law` | 위와 같음 · R = L/Δ 로 적어 Δ 바뀌면 L 흐름 |
| 계획선 꼭짓점(시설 측점) | 종단 파일 base PVI | 묶음 planline · 종단도 | TS `common_util_structure_station.ts:356` followMovedNodes ↔ 파이썬 `B05_Profile_Router_Confirm.py:306` `_follow_moved_stations` · `:220` `_seat_born_nodes` · `test_427_1_follow_seat` · `test_459_route_seat_mirror` | 짝 있는 옮긴 측점 = 꼭짓점 따라감 · 짝 없는 새 측점 = 안 세움 |
| 횡단 입력(카드 · 조정) | DB `cross_sections.design` 패치 칸 | 세션 칸(`crossdesign` · 조정 맵 등) · B06 조정 메모리 맵(`B06_Section_UI_Page_Link_Session.ts:47`) | 입력 도구(셈 없음) | 조작마다 세션 · [저장] 횡단 패치 |
@@ -28,7 +28,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 관 | `pipe_points.json` | 배수판 둘(`B05_Profile_UI_Drainage_Panel.ts:123 · 142`) · `B06_Section_UI_Page_Structures_Panel.ts:176` · 시설 줄 `:101` · `design_view_profile.ts:116` · savedPipes · `design_pipe_ids.ts:24` · 세션 `pipes` · `pipe-ids` · `culvertopt` · `kindreset` · `pipe-option-queue` · cache culvert · 유역 pkl | 관 길이 TS `design_structure_pipe_calc.ts` · `B06_Section_Structure_Layouts.ts:95`(Node 한 벌) · `test_b06_pipe_length` | 시설 줄 알림 · 배수 다시 나눔 응답 · [저장] 뒤 다시 받음 |
| 추가 측점 | `plus_stations.json` | `B05_Profile_Api_PlusStations.ts:14 · 74 · 76` · 세션 `plus-stations` | 따라감 TS `followPlusDraft` ↔ 파이썬 `B05_Profile_Plus_Stations.py:82` · `test_459_route_seat_mirror` | 노선 초안 follow 뒤 · [저장] |
| 소단 구간 · 깊이 | 구간 = `structures.json` · 깊이 = DB 패치 | 세션 `berm`(시설 줄만 씀) · `bermdepth` · cache `design.berm` · 초안 노선 `bermAt`(`design_shared.ts:248`) | TS `B06_Section_UI_Page_Persist.ts:157` bermSpansFromStructures ↔ 파이썬 `B06_Section_Engine_Structures_Wall.py:124` load_berm_spans · `test_272_berm_source` | 구조물 바뀜 → 시설 줄 `syncBerm`(같으면 안 씀) → cross 한 번 · 사본 없으면 저장분 |
| 페이지 설정 | DB 단계 2 설정 · 종단 정책 | 세션 `pagesettings` · result `profile-enforce-min-cover` · 종단도 기울기 한도 · 폼 | 정책 파이썬 기준표 ↔ TS `B05_Profile_UI_Panel_Fields.ts` · `test_442_2_grade_limit_mirror` | 열 때 두 보기가 얹음 · [저장] 설정 차례 |
| 페이지 설정 | DB 단계 2 설정 · 종단 정책 | ★ 가게 `pageSettings`(세션 `pagesettings`) — 폼 · 종단도 기울기 한도 · 시작 측점이 듣고 최소고 강제는 바로 읽음 | 정책 파이썬 기준표 ↔ TS `B05_Profile_UI_Panel_Fields.ts` · `test_442_2_grade_limit_mirror` | 열 때 두 보기가 얹음 · [저장] 설정 차례 |
| 표준 횡단 | 종단 행 `standard_cross_section` | 세션 `std-cross` · `std-cross-default` · `section-context` | 서버 설계선 다시 | [전체 반영] · [저장] |
| 3D | corridor 파일(Node) | `B05_Profile_UI_Corridor.ts:45` cache | Node `B05_Profile_Corridor_Prebuild.py` | 노선 초안 동안 낡음 표시 · [저장] 뒤 |
| 되돌리기 사진첩 | 없음(메모리 · 탭 수명) | 사진 n 장 = 세션 draft 칸 글 | — | 조작 뒤 `record()` · 같은 사진이면 걸음 아님 |
@@ -24,6 +24,7 @@ globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStora
const REAL = new Set(
[
"A00_Common/b_page_state.ts",
"A00_Common/design/design_store.ts", // 페이지 설정 값 주인 = 가게 칸(472-5)
"B05_Profile/B05_Profile_Api_Fetch.ts",
"B05_Profile/B05_Profile_Api_PageSettings.ts",
"B05_Profile/B05_Profile_UI_Page_Latest.ts",
@@ -56,18 +57,20 @@ const settings = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettin
const state = load(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const P = "P463";
/** 가짜 종단도 — 받은 기준을 적음. */
/** 가짜 종단도 — 받은 기준을 적음. 최소고 강제는 편집 가드가 페이지 설정 한 칸을 바로 읽음(472-5) —
* 화면은 늘 캐시에 둔 노선을 넘기므로(`writeRouteLatestCache` 뒤) 받은 노선을 캐시에 두고 읽음. */
function apply(latest) {
const got = {};
if (latest) state.writeState("latest", latest, P);
latestApi.applyPageSettings(
{
setGradeLimit: (pct) => (got.grade = pct),
setEnforceMinCover: (on) => (got.enforce = on),
setStationDisplay: (next) => (got.display = next),
},
P,
latest,
);
if (Object.keys(got).length) got.enforce = settings.enforceMinCoverOf(P);
return got;
}
const latestOf = (params) => ({ project_id: P, surface_params: {}, route_params: params });
@@ -78,6 +78,18 @@ async function run() {
route_params: { options: { grade_class: "trunk", design_speed_kph: 20 }, max_grade_pct: 15 },
};
const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"), {
// 값 주인 = 설계 가게 칸(472-5) — 같은 세션 흉내 위에
"../A00_Common/design/design_store": {
useDesignStore: (p) => ({
get: () => (session.has(`pagesettings:${p}`) ? session.get(`pagesettings:${p}`) : null),
set: (_key, value) =>
value === null
? session.delete(`pagesettings:${p}`)
: session.set(`pagesettings:${p}`, value),
}),
draftProjectId: () => "p1",
},
"./B05_Profile_UI_Profile_History": { onPageHistory: () => () => undefined },
"../A00_Common/b_page_state": {
readState: (name, p) => (session.has(`${name}:${p}`) ? session.get(`${name}:${p}`) : null),
writeState: (name, value, p) => session.set(`${name}:${p}`, value),
@@ -0,0 +1,119 @@
/* 472-5 5-C 헬퍼 — 종단 편집 · 페이지 설정 값 주인 = 설계 가게 칸 하나. 진짜 `b_page_state` · `design_store` ·
* `_Profile_Edit` · `_Api_PageSettings` 를 Node 로(세션 = 메모리 · 되돌리기 = 세션 칸 되쓰기 + 훅 부름 흉내).
* node helper_472_5_value_owner.cjs (짝 = `test_472_5_value_owner.py`)
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
function memoryStorage() {
const map = new Map();
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() };
const REAL = new Set(
[
"A00_Common/b_page_state.ts",
"A00_Common/design/design_store.ts",
"B05_Profile/B05_Profile_UI_Profile_Edit.ts",
"B05_Profile/B05_Profile_Api_PageSettings.ts",
"B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts",
].map((rel) => path.join(ROOT, rel)),
);
const historyHooks = [];
const STUBS = {
B05_Profile_UI_Profile_History: { onPageHistory: (entry) => historyHooks.push(entry) },
B05_Profile_Api_Fetch: {
readRouteLatestCache: (p) => state().readState("latest", p),
writeRouteLatestCache: (p, value) => state().writeState("latest", value, p),
savePageSettings: async () => ({}),
},
};
const anything = () =>
new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) });
const cache = new Map();
function load(file) {
if (cache.has(file)) return cache.get(file);
const box = { exports: {} };
cache.set(file, box.exports);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const req = (request) => {
const stub = STUBS[path.basename(request)];
if (stub) return stub;
if (request === "@ui/ui_template_locale") return { ui_locales: {}, currentLanguageIndex: 0 };
if (!request.startsWith(".")) return anything();
const target = path.resolve(path.dirname(file), request);
const full = fs.existsSync(`${target}.ts`) ? `${target}.ts` : target;
return REAL.has(full) ? load(full) : anything();
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const state = () => load(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const P = "P4725";
const ROUTE = 7;
const store = load(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
store.pinDraftProject(P);
const edit = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"));
const settings = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"));
const out = {};
// ① 두 보기 = 창구 둘 · 같은 노선 — ↶ 가 세션 칸을 되쓰면 둘 다 그 값(모듈 사본이 옛 값을 쥐지 않음)
const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } };
const a = edit.createProfileEditStore(ROUTE, saved, () => undefined);
const b = edit.createProfileEditStore(ROUTE, saved, () => undefined);
const x = { station_offsets: { "20.000": 0.5 }, curve_radii: { "40.000": 30 } };
const y = { station_offsets: { "20.000": 0.2 }, curve_radii: { "40.000": 30 } };
a.replace(x);
state().writeState("alignment-draft", y, P, ROUTE); // 되돌리기 = 세션 되쓰기
out.undo = {
a: a.edits(),
b: b.edits(),
aDirty: a.dirty(),
resync: a.resync?.() ?? "없음",
again: a.resync?.() ?? "없음",
};
// ② [저장] 뒤 ↶ 로 초안 칸이 비면 = 저장한 값(저장 전 서버 값으로 안 돌아감)
a.replace(x);
a.markSaved();
state().writeState("alignment-draft", null, P, ROUTE);
out.afterSave = { edits: b.edits(), dirty: b.dirty(), canRestore: b.canRestoreSaved() };
// ③ 페이지 설정 — ↶ 훅이 가게로 다시 알림(폼 · 기울기 한도 · 시작 측점이 들음 · N6)
const heard = [];
store.useDesignStore(P, null).subscribe("pageSettings", (value) => heard.push(value));
const draft = {
design_speed_kph: 30,
terrain_type: "normal",
max_grade_pct: 12,
min_vertical_radius_m: null,
min_tangent_length_m: null,
enforce_pipe_clearance: true,
station_display: { station: 0, cumulative: 0 },
};
settings.writePageSettings(P, draft);
state().writeState("pagesettings", null, P); // 되돌리기 = 세션 되쓰기
historyHooks.forEach((entry) => entry.restore?.("undo"));
out.settingsHeard = [...heard];
// ④ 최소고 강제 = 페이지 설정 한 칸(초안 ?? 저장 노선) · 사본 세션 칸 없음(N7)
state().writeState("latest", { project_id: P, route_params: { enforce_pipe_clearance: true } }, P);
out.enforceSaved = settings.enforceMinCoverOf?.(P) ?? "없음"; // 옛 코드 = 창구 없음
settings.writePageSettings(P, { ...draft, enforce_pipe_clearance: false });
out.enforceDraft = settings.enforceMinCoverOf?.(P) ?? "없음";
// 등록부에 없으면 열쇠가 안 섬(null) — 옛 코드는 결과 통 열쇠가 섬
out.copyKey = state().stateKey("profile-enforce-min-cover", P) ?? "none";
console.log(JSON.stringify(out));
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""472-5 5-C — 종단 편집 · 페이지 설정 값 주인 = 설계 가게 칸 하나.
옛 흠: 종단 편집은 모듈 사본(EditState)이 지금 값을 쥐어 ↶ 뒤 다른 창구가 옛 값 · [저장] 뒤 ↶ 가 저장 전
서버 값으로 돌아감 · 페이지 설정 ↶ 는 세션 칸만 돌아오고 폼 · 기울기 한도는 안 들음(N6) · 최소고 강제는
결과 통 사본 칸(N7).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = ROOT / "resources" / "tester" / "helper_472_5_value_owner.cjs"
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run(
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=120
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
Y = {"station_offsets": {"20.000": 0.2}, "curve_radii": {"40.000": 30}}
X = {"station_offsets": {"20.000": 0.5}, "curve_radii": {"40.000": 30}}
def test_되돌리면_두_창구가_같은_값(got):
assert got["undo"]["a"] == Y
assert got["undo"]["b"] == Y
assert got["undo"]["aDirty"] is True
assert got["undo"]["resync"] is True # 다시 셈
assert got["undo"]["again"] is False # 같은 값 = 다시 셈 예약 없음
def test_저장_뒤_되돌려도_저장한_값(got):
assert got["afterSave"] == {"edits": X, "dirty": False, "canRestore": False}
def test_페이지_설정_되돌리면_가게가_알림(got):
assert got["settingsHeard"][-1] is None # ↶ 로 초안이 빔 → 듣는 쪽이 저장 설정으로 다시 얹음
def test_최소고_강제는_페이지_설정_한_칸(got):
assert got["enforceSaved"] is True
assert got["enforceDraft"] is False
assert got["copyKey"] == "none"
+3 -4
View File
@@ -76,10 +76,9 @@ def test_source_guard_sits_on_the_single_apply_path():
방향키가 그냥 지나갔다. 판정 로직을 `_Profile_MinCover.blocksMinCover` 로 옮기고
`_Profile_Panel.applyEdits` 한 곳에서만 부른다 — 화면 쪽에는 사본을 두지 않는다.
"""
assert (
"if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;"
in PANEL
)
# 강제 여부 = 페이지 설정 한 칸을 그 자리에서 읽음(472-5 · 보기마다 사본 없음)
assert "const enforce = enforceMinCoverOf(projectId);" in PANEL
assert "if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;" in PANEL
assert PANEL.count("blocksMinCover(") == 1 # 부르는 자리는 한 곳뿐.
assert "export function blocksMinCover(" in GUARD
assert "blocksMinCover" not in RENDER # 화면 모듈에 사본이 남지 않았다.