diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index f3df0b73c..b3f25c209 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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` 와 함께 버린다. */ diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 4937ff064..ffb4a246b 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -12,7 +12,6 @@ * ========================================================================== */ 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 type { StructureInstance, @@ -44,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, @@ -118,9 +120,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ let minCoverTargets: MinCoverPoint[] = []; - /** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제). - * 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */ - let enforceMinCover = readState("profile-enforce-min-cover", projectId) === true; /** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */ const takePipes = (pipes: ReadonlyArray): void => { // 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다. @@ -174,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; @@ -262,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(), @@ -291,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; @@ -437,7 +430,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes)); } - return { + const profile = { root, /** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */ setView(next: DesignView) { @@ -456,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(); // 노선 초안 그림은 그대로(갈아끼운 상세) @@ -506,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) { takePipes(pipes); @@ -598,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(); @@ -651,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` 이 돌려주는 것. */ diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index 210e9ae2d..9d5b2c616 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -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( 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(key as MemoryKey, projectId); // 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다. const name = ( @@ -130,8 +147,9 @@ function slotOf( sectionDetail: "section-detail", selection: "selection", routeDraft: "route-draft", + pageSettings: "pagesettings", } as const - )[key as Exclude]; + )[key as Exclude]; const routeScoped = name === "section-detail"; if (!projectId || (routeScoped && routeId === null)) { return { id: null, read: () => null, write: () => undefined }; @@ -144,6 +162,9 @@ function slotOf( }; } +/** 노선마다 서버 저장분 계획고 편집(`alignmentSaved`) — 종단도가 상세를 받을 때 채움. */ +const savedAlignments = new Map(); + /** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */ const MEMORY_SLOTS = { stations: "design-stations", diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 68def71e5..41b74610c 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -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) => { diff --git a/B05_Profile/B05_Profile_Api_PageSettings.ts b/B05_Profile/B05_Profile_Api_PageSettings.ts index 561554ba7..92ff97392 100644 --- a/B05_Profile/B05_Profile_Api_PageSettings.ts +++ b/B05_Profile/B05_Profile_Api_PageSettings.ts @@ -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 - readState("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 { }; writeRouteLatestCache(projectId, latest); } - clearState("pagesettings", projectId); + writePageSettings(projectId, null); } diff --git a/B05_Profile/B05_Profile_UI_Page_Latest.ts b/B05_Profile/B05_Profile_UI_Page_Latest.ts index 348e18adb..35bec4580 100644 --- a/B05_Profile/B05_Profile_UI_Page_Latest.ts +++ b/B05_Profile/B05_Profile_UI_Page_Latest.ts @@ -89,10 +89,27 @@ export function panelValuesOf(next: RouteLatestResponse): Partial | 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, + profile: Pick, 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 }); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index c943b4944..ef4cc08be 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -73,7 +73,7 @@ export interface ProfileEditStore { /** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */ savedRadii(): Readonly>; 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(); - -/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */ -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), }; } diff --git a/docs/wiki/concepts/design_page/index.md b/docs/wiki/concepts/design_page/index.md index dc113a292..29cdc4c89 100644 --- a/docs/wiki/concepts/design_page/index.md +++ b/docs/wiki/concepts/design_page/index.md @@ -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 | 읽고 쓰는 창구 · 쓰면 듣는 쪽 부름 | diff --git a/docs/wiki/concepts/design_page/operations_b05.md b/docs/wiki/concepts/design_page/operations_b05.md index 73132b23d..a5b2c7764 100644 --- a/docs/wiki/concepts/design_page/operations_b05.md +++ b/docs/wiki/concepts/design_page/operations_b05.md @@ -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` | diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index b28eefb62..d3cf77eab 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -50,7 +50,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` | 코드 확인 | diff --git a/docs/wiki/concepts/design_page/session_keys.md b/docs/wiki/concepts/design_page/session_keys.md index e003563ef..f8f142074 100644 --- a/docs/wiki/concepts/design_page/session_keys.md +++ b/docs/wiki/concepts/design_page/session_keys.md @@ -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) diff --git a/docs/wiki/concepts/design_page/values.md b/docs/wiki/concepts/design_page/values.md index ef62b6e8b..bd1e19324 100644 --- a/docs/wiki/concepts/design_page/values.md +++ b/docs/wiki/concepts/design_page/values.md @@ -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()` · 같은 사진이면 걸음 아님 | diff --git a/resources/tester/helper_271_profile_settings.cjs b/resources/tester/helper_271_profile_settings.cjs index 048e38ee6..53cdfe39b 100644 --- a/resources/tester/helper_271_profile_settings.cjs +++ b/resources/tester/helper_271_profile_settings.cjs @@ -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 }); diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs index 18e064ea8..cb53502db 100644 --- a/resources/tester/helper_442_2_page_settings.cjs +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -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), diff --git a/resources/tester/helper_472_5_value_owner.cjs b/resources/tester/helper_472_5_value_owner.cjs new file mode 100644 index 000000000..3674a6c3c --- /dev/null +++ b/resources/tester/helper_472_5_value_owner.cjs @@ -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)); diff --git a/resources/tester/test_472_5_value_owner.py b/resources/tester/test_472_5_value_owner.py new file mode 100644 index 000000000..4cff1b7ad --- /dev/null +++ b/resources/tester/test_472_5_value_owner.py @@ -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" diff --git a/resources/tester/test_b05_min_cover_guard.py b/resources/tester/test_b05_min_cover_guard.py index 926d876a8..d594affeb 100644 --- a/resources/tester/test_b05_min_cover_guard.py +++ b/resources/tester/test_b05_min_cover_guard.py @@ -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 # 화면 모듈에 사본이 남지 않았다.