From 002d0e85acb21c966770003d526640bd9a5671ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 17:29:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(387-1):=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=84=B8=EC=85=98=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=E2=86=92=20=EB=93=B1=EB=A1=9D=EB=B6=80=20?= =?UTF-8?q?=C2=B7=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=97=B4=EC=87=A0=20=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 되돌리기 사진 = 연 프로젝트 초안 칸만(projectStateKeys) · 옛 b05/b06 접두 지움 - 관 자리 b05:pipes 걷음 → 가게 pipes 초안으로 되돌림 - 조정창 · 연동 · 세월교 · BOX 세션 맵 = readByKey · writeByKey - 노선 편집 횡단 반폭 = routeStateKeys · 계획고 옛 키(프로젝트 없음) 안 받음 - 새 test_384_1_session_keys · 사진첩 시험 헬퍼 = 진짜 등록부 부름 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/b_page_state.ts | 20 +++-- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 17 +--- B05_Profile/B05_Profile_UI_Profile_History.ts | 80 +++++++------------ .../B05_Profile_UI_Profile_Panel_Tools.ts | 30 +++---- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 16 ++-- .../B06_Section_UI_Page_Ford_Controls.ts | 16 ++-- .../B06_Section_UI_Page_Link_Session.ts | 9 ++- resources/tester/helper_346_4_one_undo.cjs | 2 + resources/tester/helper_356_center.cjs | 9 ++- .../tester/helper_design_profile_single.cjs | 2 +- resources/tester/helper_ts_cjs_load.cjs | 25 ++++++ .../tester/test_346_4_profile_history.py | 30 +++---- resources/tester/test_364_1_view_no_step.py | 22 +++-- resources/tester/test_384_1_session_keys.py | 77 ++++++++++++++++++ .../tester/test_design_profile_single.py | 7 +- 15 files changed, 210 insertions(+), 152 deletions(-) create mode 100644 resources/tester/helper_ts_cjs_load.cjs create mode 100644 resources/tester/test_384_1_session_keys.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 60ee6b9dd..ce0338fc0 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -206,12 +206,8 @@ export const STATE_REGISTRY = { /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). - * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ - "alignment-draft": { - bucket: "draft", - scope: "route", - legacy: (_p, r) => `b05-profile-alignment-draft:${r}`, - }, + * 옛 키(프로젝트 없는 `b05-profile-alignment-draft:{노선}`)는 안 받음 — 다른 프로젝트 초안이 섞임(387-1). */ + "alignment-draft": { bucket: "draft", scope: "route" }, /** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`). * 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */ "route-draft": { bucket: "draft", scope: "project" }, @@ -529,11 +525,21 @@ export function routeStateKeys(name: StateName, projectId: string): string[] { if (entry?.scope !== "route") return []; const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`; + return sessionKeys((key) => key.startsWith(prefix)); +} + +/** 한 통 · 한 프로젝트의 세션 키 전부(노선 칸 포함) — 되돌리기 사진(지금 프로젝트만 · 387-1). */ +export function projectStateKeys(bucket: StateBucket, projectId: string): string[] { + const head = `aislo:${bucket}:`; + return sessionKeys((key) => key.startsWith(head) && key.split(":")[3] === projectId); +} + +function sessionKeys(keep: (key: string) => boolean): string[] { const keys: string[] = []; try { for (let i = 0; i < window.sessionStorage.length; i += 1) { const key = window.sessionStorage.key(i); - if (key && key.startsWith(prefix)) keys.push(key); + if (key && keep(key)) keys.push(key); } } catch { /* 무시 — 저장소가 막힌 브라우저 */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 108c60135..4bbb56270 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -17,11 +17,7 @@ import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./logic/B05_Profile_UI_Profile_Alignment"; -import { - ALIGNMENT_DRAFT_KEY_PREFIX, - draftProjectId, - useDesignStore, -} from "../A00_Common/design/design_store"; +import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; import { clearState } from "../A00_Common/b_page_state"; import { viewOf } from "../common_util/common_util_view_window"; @@ -111,22 +107,11 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul * 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에 * 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: * 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남). - * 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다. */ export function clearAlignmentDrafts(): void { shared.clear(); // 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다. clearState("alignment-draft", draftProjectId()); - try { - const keys: string[] = []; - for (let index = 0; index < sessionStorage.length; index += 1) { - const key = sessionStorage.key(index); - if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key); - } - keys.forEach((key) => sessionStorage.removeItem(key)); - } catch { - // 세션 저장소를 못 쓰는 환경이면 남길 초안도 없다. - } } /** diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 39b770abd..a59c35753 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -11,7 +11,8 @@ * 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라 * 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`). * - * 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4). + * 대상 = 지금 프로젝트(사진첩을 세울 때 연 것)의 초안 통(`aislo:draft:` — 노선 초안 · 계획고 · 관 · 구조물 · 횡단 · 346-4). + * 다른 프로젝트 칸은 찍지도 지우지도 않음 · 세션은 등록부(`b_page_state`) 창구로만(387-1). * 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번. * 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남. * @@ -26,46 +27,30 @@ * 씨앗 값(364-1) — 비었을 때만 채우는 값(표준 횡단 · 횡단 보기 첫 열기)은 `seedPageHistory` 로 모든 사진에 넣음. * ========================================================================== */ -/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ -const DRAFT_PREFIX = "aislo:draft:"; -const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; +import { projectStateKeys, readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { draftProjectId } from "../A00_Common/design/design_store"; + /** 초안 통이지만 조작이 아닌 칸 · 다른 페이지 칸 — 사진 밖. */ const NOT_EDIT = /^aislo:draft:(selection|pipe-ids|structure-type-cache|structure-pick|b08-[^:]*|b09-[^:]*):/; /** 놓은 뒤 · 칸 바꾼 뒤 기다렸다 찍는 시간(ms) — 서버 다시 셈 뒤 초안 쓰기까지 한 걸음에. */ const SETTLE_MS = 400; -/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ -function isLayoutKey(key: string): boolean { - return ( - key.includes("collapsed") || - key.includes("width") || - key.includes("height") || - key.includes("open") || - key.includes("visible") - ); -} - -function isDataKey(key: string): boolean { - if (key.startsWith(DRAFT_PREFIX)) return !NOT_EDIT.test(key); - return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); -} +/** 한 프로젝트의 사진 칸 키 — B05·B06 은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ +const editKeys = (projectId: string): string[] => + projectStateKeys("draft", projectId).filter((key) => !NOT_EDIT.test(key)); +const isEditKey = (key: string, projectId: string): boolean => + key.startsWith("aislo:draft:") && key.split(":")[3] === projectId && !NOT_EDIT.test(key); /** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ export type HistorySnapshot = Record; -/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). */ -function takeSnapshot(previous?: HistorySnapshot): HistorySnapshot { +/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). 저장소가 막히면 빈 사진 — 되돌리기만 꺼짐. */ +function takeSnapshot(projectId: string, previous?: HistorySnapshot): HistorySnapshot { const snapshot: HistorySnapshot = {}; - try { - for (let index = 0; index < sessionStorage.length; index += 1) { - const key = sessionStorage.key(index); - if (!key || !isDataKey(key)) continue; - const value = sessionStorage.getItem(key); - if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value; - } - } catch { - // 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다. + for (const key of editKeys(projectId)) { + const value = readByKey(key); + if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value; } return snapshot; } @@ -163,21 +148,12 @@ function watchPointer(): void { ); } -/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */ -function restoreSnapshot(snapshot: HistorySnapshot): void { - try { - const existing: string[] = []; - for (let index = 0; index < sessionStorage.length; index += 1) { - const key = sessionStorage.key(index); - if (key && isDataKey(key)) existing.push(key); - } - existing.forEach((key) => { - if (!(key in snapshot)) sessionStorage.removeItem(key); - }); - Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value)); - } catch { - // 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다. - } +/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 그 프로젝트 조작 키는 지운다. */ +function restoreSnapshot(projectId: string, snapshot: HistorySnapshot): void { + editKeys(projectId).forEach((key) => { + if (!(key in snapshot)) writeByKey(key, null); + }); + Object.entries(snapshot).forEach(([key, value]) => writeByKey(key, value)); } export interface ProfileHistory { @@ -207,7 +183,9 @@ export function createProfileHistory( ): ProfileHistory { watchPointer(); sync(); - const stack: HistorySnapshot[] = [takeSnapshot()]; + /** 사진첩을 세울 때 연 프로젝트 — 그 칸만 찍고 되씀. */ + const projectId = draftProjectId(); + const stack: HistorySnapshot[] = [takeSnapshot(projectId)]; let cursor = 0; /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ let restoring = false; @@ -223,12 +201,12 @@ export function createProfileHistory( const kind: PageHistoryEvent = index < cursor ? "undo" : "redo"; cursor = index; restoring = replaying = true; - restoreSnapshot(stack[cursor]); + restoreSnapshot(projectId, stack[cursor]); try { onRestore(); hooks.forEach((entry) => entry.restore?.(kind)); // 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦. - stack[cursor] = takeSnapshot(stack[cursor]); + stack[cursor] = takeSnapshot(projectId, stack[cursor]); } finally { restoring = replaying = false; } @@ -246,7 +224,7 @@ export function createProfileHistory( } sync(); restAt = gestures; - const snapshot = takeSnapshot(stack[cursor]); + const snapshot = takeSnapshot(projectId, stack[cursor]); if (sameSnapshot(snapshot, stack[cursor])) return; if (gestures === openedAt) { stack[0] = snapshot; // 열 때 — 첫 사진만 다시 뜸 @@ -274,10 +252,10 @@ export function createProfileHistory( rebaseActive = () => { if (restoring || paused || pointerHeld || settling || gestures !== restAt) return; sync(); - stack[cursor] = takeSnapshot(stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습 + stack[cursor] = takeSnapshot(projectId, stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습 }; seedActive = (key) => { - const value = isDataKey(key) ? sessionStorage.getItem(key) : null; + const value = isEditKey(key, projectId) ? readByKey(key) : null; if (value !== null) stack.forEach((snapshot) => void (snapshot[key] ??= value)); }; active = history; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 6372ff738..132e87ed9 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -36,13 +36,11 @@ import { createFreeDraw } from "../A00_Common/design/logic/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; +import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; -/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ -const PIPE_KEY = "b05:pipes"; - export interface PanelToolsContext { /** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */ root: HTMLElement; @@ -87,31 +85,23 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { /** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */ let initialPipes: number[] | null = null; - /** 관 위치를 스냅샷이 볼 수 있는 세션 키로 옮겨 적는다(기록 직전에 부른다). */ + /** 관 자리는 가게 관 초안(`pipes` · 사진에 듦)이 정본 — 찍기 직전엔 처음 본 목록만 잡아 둔다. */ function syncPipes(): void { - const chainages = ctx.drainage.pipeChainages(); - if (initialPipes === null) initialPipes = chainages; - sessionStorage.setItem(PIPE_KEY, JSON.stringify(chainages)); + if (initialPipes === null) initialPipes = ctx.drainage.pipeChainages(); } - /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 값으로 맞춘다. 같은 목록이면 조용히 끝난다. */ + /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 초안으로 맞춘다(초안 없는 걸음 = 처음 목록). 같은 목록이면 조용히 끝난다. */ function restorePipes(): void { - let chainages = initialPipes; - const raw = sessionStorage.getItem(PIPE_KEY); - if (raw) { - try { - chainages = JSON.parse(raw) as number[]; - } catch { - // 손상된 값은 최초 목록으로 되돌린다. - } - } + const draft = useDesignStore(draftProjectId(), null).get("pipes"); + const chainages = draft + ? draft.map((pipe) => Math.round(pipe.chainage_m * 100) / 100) + : initialPipes; if (chainages) ctx.drainage.setPipeChainages(chainages); } // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). - /** 찍기 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다(세울 때 · 쉬는 동안 온 값 덮을 때도). - * 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전** - * 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */ + /** 찍기 직전(세울 때 · 쉬는 동안 온 값 덮을 때 · 연속 조작 멈출 때) 처음 본 관 목록을 잡는다 — + * 그래야 그 목록이 조작 **전** 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측). */ const inner = createProfileHistory( () => { ctx.restore(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 742d132f4..067e7a30c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -40,7 +40,7 @@ import { CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; -import { readByKey, stateKey } from "../A00_Common/b_page_state"; +import { readByKey, routeStateKeys, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; @@ -105,15 +105,11 @@ interface CrossPane { function appliedHalfWidth(projectId: string): number | undefined { const prefix = stateKey("cross-display", projectId, ""); if (!prefix) return undefined; - try { - const latest = Object.keys(window.sessionStorage) - .filter((key) => key.startsWith(prefix)) - .sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0]; - const value = Number(latest ? readByKey(latest) : NaN); - return Number.isFinite(value) && value > 0 ? value : undefined; - } catch { - return undefined; - } + const latest = routeStateKeys("cross-display", projectId).sort( + (a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)), + )[0]; + const value = Number(latest ? readByKey(latest) : NaN); + return Number.isFinite(value) && value > 0 ? value : undefined; } function createPane(title: string, empty: string): CrossPane { diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 27aa43bde..8b5c997b4 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -21,6 +21,7 @@ import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxPick, BoxSideAdjust } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ type WingPatch = Partial<{ @@ -136,10 +137,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { const key = fordSessionKey(); if (!key) return; try { - const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< - string, - FordAdjust - >; + const parsed = JSON.parse(readByKey(key) ?? "{}") as Record; Object.entries(parsed).forEach(([chainage, value]) => fordAdjustments.set(chainage, fordAdjustOf(value)), ); @@ -150,8 +148,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { function persistFordAdjustments(): void { const key = fordSessionKey(); - if (key) - window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments))); + if (key) writeByKey(key, JSON.stringify(Object.fromEntries(fordAdjustments))); } // 옛 저장(측벽마다 높이 · 대각)은 읽을 때 바닥판 내림 하나로 옮김(139-3 · `fordDropOf`) @@ -337,10 +334,7 @@ export function createBoxControls(deps: FordControlDeps): { const key = deps.sessionKey(); if (!key) return; try { - const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< - string, - BoxAdjust - >; + const parsed = JSON.parse(readByKey(key) ?? "{}") as Record; Object.entries(parsed).forEach(([chainage, value]) => adjustments.set(chainage, { left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left }, @@ -354,7 +348,7 @@ export function createBoxControls(deps: FordControlDeps): { function persist(): void { const key = deps.sessionKey(); - if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments))); + if (key) writeByKey(key, JSON.stringify(Object.fromEntries(adjustments))); } const adjustAt = (chainageM: number): BoxAdjust => { diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index 18fe380ad..0ddc38301 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -28,6 +28,7 @@ import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; import { onAnySwap } from "./B06_Section_Swap_Bus"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; /** 세션에 담기는 측점별 조작값 한 겹 맵. */ export interface SessionMap { @@ -81,7 +82,7 @@ export function createSessionMap( const key = sessionKey(); if (!key) return; try { - const raw = window.sessionStorage.getItem(key); + const raw = readByKey(key); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([entry, value]) => { @@ -96,7 +97,7 @@ export function createSessionMap( const key = sessionKey(); if (!key) return; try { - window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(values))); + writeByKey(key, JSON.stringify(Object.fromEntries(values))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -125,7 +126,7 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag const key = sessionKey(); if (!key) return; try { - const raw = window.sessionStorage.getItem(key); + const raw = readByKey(key); if (!raw) return; const parsed = JSON.parse(raw) as { detached?: Record; @@ -140,7 +141,7 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag persist(): void { const key = sessionKey(); if (!key) return; - window.sessionStorage.setItem( + writeByKey( key, JSON.stringify({ detached: Object.fromEntries(detached), diff --git a/resources/tester/helper_346_4_one_undo.cjs b/resources/tester/helper_346_4_one_undo.cjs index 963db4efd..198db7c34 100644 --- a/resources/tester/helper_346_4_one_undo.cjs +++ b/resources/tester/helper_346_4_one_undo.cjs @@ -20,6 +20,8 @@ globalThis.sessionStorage = { const win = new EventTarget(); win.setTimeout = setTimeout; win.clearTimeout = clearTimeout; +win.sessionStorage = globalThis.sessionStorage; +win.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; // 연 프로젝트(387-1) globalThis.window = win; const cache = new Map(); diff --git a/resources/tester/helper_356_center.cjs b/resources/tester/helper_356_center.cjs index 2e20a658d..4158debd7 100644 --- a/resources/tester/helper_356_center.cjs +++ b/resources/tester/helper_356_center.cjs @@ -20,6 +20,11 @@ globalThis.sessionStorage = { setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k), }; +// 사진첩은 등록부 창구 · 연 프로젝트(p) 칸만(387-1) +globalThis.window = { + sessionStorage: globalThis.sessionStorage, + localStorage: { getItem: (k) => (k === "frd_current_project_id" ? "p" : null) }, +}; const toasts = []; let pipe = null; @@ -168,13 +173,13 @@ pipe = { ); state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림 sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned)); - sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits)); + sessionStorage.setItem("aislo:draft:alignment-draft:p:0", JSON.stringify(state.edits)); generation += 1; emit(); }, grade(edits) { state.edits = edits; - sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(edits)); + sessionStorage.setItem("aislo:draft:alignment-draft:p:0", JSON.stringify(edits)); generation += 1; emit(); }, diff --git a/resources/tester/helper_design_profile_single.cjs b/resources/tester/helper_design_profile_single.cjs index 15eff0cb5..06f20767b 100644 --- a/resources/tester/helper_design_profile_single.cjs +++ b/resources/tester/helper_design_profile_single.cjs @@ -49,7 +49,7 @@ const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "desig const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_History.ts")); const out = {}; -// ① 옛 키에 남은 초안 — 처음 읽을 때 새 칸으로 옮겨 받고 옛 키는 지운다. +// ① 옛 키(프로젝트 없음)에 남은 초안 — 안 받음(387-1 · 다른 프로젝트 초안일 수 있음). local.setItem("frd_current_project_id", "p-1"); session.setItem( "b05-profile-alignment-draft:7", diff --git a/resources/tester/helper_ts_cjs_load.cjs b/resources/tester/helper_ts_cjs_load.cjs new file mode 100644 index 000000000..4f2228671 --- /dev/null +++ b/resources/tester/helper_ts_cjs_load.cjs @@ -0,0 +1,25 @@ +/* 진짜 TS 를 그 자리에서 CommonJS 로 트랜스파일해 부름 — 상대 경로는 진짜 파일(.ts)로 따라감. + * ESM 드라이버는 `createRequire(import.meta.url)(이 파일)` 로 받음(test_346_4_profile_history · test_364_1). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +module.exports = { loadTs }; diff --git a/resources/tester/test_346_4_profile_history.py b/resources/tester/test_346_4_profile_history.py index 9dbc09a8f..3a6d870ee 100644 --- a/resources/tester/test_346_4_profile_history.py +++ b/resources/tester/test_346_4_profile_history.py @@ -1,4 +1,4 @@ -"""346-4 되돌리기 앞일 — 사진 대상 = aislo:draft: 전부 · 누름 중간은 안 찍고 놓을 때 한 번 · 크기. +"""346-4 되돌리기 앞일 — 사진 대상 = 연 프로젝트 aislo:draft: 전부(387-1 · 옛 b05 키 · 다른 프로젝트 밖) · 누름 중간은 안 찍고 놓을 때 한 번 · 크기. 349 — 열기만(손길 없음) = 걸음 0 · 열 때 쓰는 초안은 첫 사진 · 조작 한 번 = 걸음 하나. @@ -15,10 +15,11 @@ import pytest ROOT = Path(__file__).resolve().parents[2] TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts" -TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" DRIVER = r""" import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; // 세션 흉내 — getItem 은 진짜처럼 매번 새 글을 돌려줌(사진끼리 글을 같이 쓰는지 재려고). const data = new Map(); globalThis.sessionStorage = { @@ -30,11 +31,14 @@ globalThis.sessionStorage = { }; globalThis.window = new EventTarget(); window.setTimeout = setTimeout; -const { createProfileHistory } = await import("./B05_Profile_UI_Profile_History.js"); +window.sessionStorage = sessionStorage; +window.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; +const { loadTs } = createRequire(import.meta.url)(process.argv[3]); +const { createProfileHistory } = loadTs(process.argv[4]); const tick = () => new Promise((r) => setTimeout(r, 5)); const out = {}; -// 1. 대상 — 초안 통 전부 + 옛 키 · 보기 설정 · pref · result 는 뺌 +// 1. 대상 — 연 프로젝트(p1) 초안 통 전부 · 옛 키 · 보기 설정 · pref · result · 다른 프로젝트는 뺌 const K = { route: "aislo:draft:route-draft:p1", cross: "aislo:draft:crossdesign:p1:7", @@ -43,6 +47,7 @@ const K = { layout: "b05-panel-collapsed", pref: "aislo:pref:table-open", result: "aislo:result:latest:p1", + other: "aislo:draft:crossdesign:p2:7", }; for (const k of Object.values(K)) sessionStorage.setItem(k, "0"); let restored = 0; @@ -124,15 +129,9 @@ writeFileSync(process.argv[2], JSON.stringify(out)); @pytest.fixture(scope="module") def result(tmp_path_factory: pytest.TempPathFactory) -> dict: out = tmp_path_factory.mktemp("ts_history") - subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", - "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", - "--noResolve"], - cwd=str(ROOT), check=True, capture_output=True, - ) # fmt: skip (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") - run = subprocess.run( # noqa: S603 - ["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json")], + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json"), str(LOADER), str(TS_FILE)], capture_output=True, text=True, cwd=str(out), ) # fmt: skip assert run.returncode == 0, run.stderr @@ -142,9 +141,10 @@ def result(tmp_path_factory: pytest.TempPathFactory) -> dict: def test_초안_통_전부를_되돌림(result) -> None: after = result["after"] assert result["undo"] and result["restored"] == 1 - assert after["route"] == after["cross"] == after["pipes"] == after["legacy"] == "0" - # 보기 설정 · pref · result 는 사진 밖 — 되돌리기가 안 건드림 - assert after["layout"] == after["pref"] == after["result"] == "1" + assert after["route"] == after["cross"] == after["pipes"] == "0" + # 옛 키 · 보기 설정 · pref · result · 다른 프로젝트는 사진 밖 — 되돌리기가 안 건드림 + assert after["legacy"] == after["layout"] == after["pref"] == after["result"] == "1" + assert after["other"] == "1" def test_누름_중간은_안_찍고_놓을_때_한_번(result) -> None: diff --git a/resources/tester/test_364_1_view_no_step.py b/resources/tester/test_364_1_view_no_step.py index d364f3145..3991e4290 100644 --- a/resources/tester/test_364_1_view_no_step.py +++ b/resources/tester/test_364_1_view_no_step.py @@ -17,10 +17,11 @@ import pytest ROOT = Path(__file__).resolve().parents[2] TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts" -TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" DRIVER = r""" import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; const data = new Map(); globalThis.sessionStorage = { get length() { return data.size; }, @@ -31,7 +32,10 @@ globalThis.sessionStorage = { }; globalThis.window = new EventTarget(); window.setTimeout = setTimeout; -const { createProfileHistory, seedPageHistory } = await import("./B05_Profile_UI_Profile_History.js"); +window.sessionStorage = sessionStorage; +window.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; +const { loadTs } = createRequire(import.meta.url)(process.argv[3]); +const { createProfileHistory, seedPageHistory } = loadTs(process.argv[4]); const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const fire = (type) => window.dispatchEvent(new Event(type)); /** 누르고 놓음 — 보기 손길(세션 안 씀) 또는 편집(누른 동안 write). */ @@ -47,11 +51,11 @@ const K = { std: "aislo:draft:std-cross:p1", structures: "aislo:draft:structures:p1", cross: "aislo:draft:crossdesign:p1:7", - pipes: "b05:pipes", + pipes: "aislo:draft:pipes:p1", }; const out = {}; -// 새 페이지 — 관 자리는 찍을 때만 옮겨 적음(Panel_Tools syncPipes 꼴) +// 새 페이지 — 관 초안이 손길 없이 뒤에서 옴(찍기 직전에 쓰는 흉내) let pipes = []; sessionStorage.setItem(K.route, "r0"); const h = createProfileHistory(() => {}, 100, () => sessionStorage.setItem(K.pipes, JSON.stringify(pipes))); @@ -129,15 +133,9 @@ writeFileSync(process.argv[2], JSON.stringify(out)); @pytest.fixture(scope="module") def result(tmp_path_factory: pytest.TempPathFactory) -> dict: out = tmp_path_factory.mktemp("ts_364_1") - subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", - "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", - "--noResolve"], - cwd=str(ROOT), check=True, capture_output=True, - ) # fmt: skip (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") - run = subprocess.run( # noqa: S603 - ["node", str(out / "driver.mjs"), str(out / "out.json")], + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "driver.mjs"), str(out / "out.json"), str(LOADER), str(TS_FILE)], capture_output=True, text=True, cwd=str(out), ) # fmt: skip assert run.returncode == 0, run.stderr diff --git a/resources/tester/test_384_1_session_keys.py b/resources/tester/test_384_1_session_keys.py new file mode 100644 index 000000000..c1ea1c7dd --- /dev/null +++ b/resources/tester/test_384_1_session_keys.py @@ -0,0 +1,77 @@ +"""387-1(384 단계 1) 세션 열쇠 — 설계 화면은 세션을 등록부(`b_page_state`)로만 · 프로젝트 없는 열쇠 0. + +· design · B05/B06 UI_* 파일 코드(주석 뺌)에 직접 sessionStorage 0 +· 등록부 초안 · 결과 칸 옛 키는 모두 프로젝트를 품음 · `b05:pipes` 글 0 +· `projectStateKeys` = 그 통 · 그 프로젝트 키만(노선 칸 포함 · 다른 프로젝트 · 다른 통 · 옛 키 밖) + +./venv/Scripts/python.exe -m pytest resources/tester/test_384_1_session_keys.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" + + +def _code(path: Path) -> str: + text = path.read_text(encoding="utf-8") + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"(?m)^\s*//.*$|\s//\s.*$", "", text) + + +def _screen_files() -> list[Path]: + files = list((ROOT / "A00_Common" / "design").rglob("*.ts")) + files += (ROOT / "B05_Profile").glob("B05_Profile_UI_*.ts") + files += (ROOT / "B06_Section").glob("B06_Section_UI_*.ts") + return sorted(files) + + +def test_화면_파일에_직접_세션_0() -> None: + files = _screen_files() + assert len(files) > 100 + hits = [str(p.relative_to(ROOT)) for p in files if "sessionStorage" in _code(p)] + assert hits == [] + + +def test_프로젝트_없는_열쇠_0() -> None: + hits = [str(p.relative_to(ROOT)) for p in _screen_files() if '"b05:pipes"' in _code(p)] + assert hits == [] + + +DRIVER = r""" +const data = new Map(); +const fake = { + get length() { return data.size; }, + key: (i) => [...data.keys()][i] ?? null, + getItem: (k) => (data.has(k) ? data.get(k) : null), + setItem: (k, v) => data.set(k, String(v)), + removeItem: (k) => data.delete(k), +}; +globalThis.window = { sessionStorage: fake, localStorage: fake }; +const { loadTs } = require(process.argv[1]); +const s = loadTs(process.argv[2]); +const legacy = Object.entries(s.STATE_REGISTRY) + .filter(([, e]) => e.bucket !== "pref" && e.legacy) + .map(([name, e]) => [name, e.legacy("PX", "RX")]); +for (const k of [ + "aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1", "aislo:draft:pipes:p10", + "aislo:draft:crossw:p2:7", "aislo:result:latest:p1", "b05:pipes", "b06:crossw:p1:7", +]) fake.setItem(k, "1"); +process.stdout.write(JSON.stringify({ legacy, keys: s.projectStateKeys("draft", "p1").sort() })); +""" + + +def test_등록부_옛_키는_프로젝트_품음_사진_칸은_그_프로젝트만() -> None: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", DRIVER, str(LOADER), str(ROOT / "A00_Common" / "b_page_state.ts")], + capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), + ) # fmt: skip + assert run.returncode == 0, run.stderr + out = json.loads(run.stdout) + assert out["legacy"] and all("PX" in key for _, key in out["legacy"]) + assert out["keys"] == ["aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1"] diff --git a/resources/tester/test_design_profile_single.py b/resources/tester/test_design_profile_single.py index 7d028e2ea..73d87d6b0 100644 --- a/resources/tester/test_design_profile_single.py +++ b/resources/tester/test_design_profile_single.py @@ -72,9 +72,10 @@ def test_크기_접기_키는_하나(): assert '["section-panel-height", "profile-height"]' in drag # 옛 값 옮겨 받음 -def test_초안은_등록부_칸으로_옮겨_받음(results): - assert results["adopted"] == {"station_offsets": {"20.000": 0.3}, "curve_radii": {}} - assert results["keysAfterAdopt"] == ["aislo:draft:alignment-draft:p-1:7"] +def test_초안은_등록부_칸_프로젝트_없는_옛_키는_안_받음(results): + # 387-1 — 옛 키(프로젝트 없음)는 다른 프로젝트 초안일 수 있어 안 받음 · 손대지 않음 + assert results["adopted"] is None + assert results["keysAfterAdopt"] == ["b05-profile-alignment-draft:7"] assert results["sameSlot"]["station_offsets"] == {"40.000": 0.1} From a3dd0a1cf1fddcb90eaeda1f614917e9bf8d0493 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 17:30:03 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(393-2):=20B05=20=EC=9A=B0=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=ED=9A=A1=EB=8B=A8=E3=80=8D=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=ED=9A=A1=EB=8B=A8=E3=80=8D=20=ED=8C=90=20?= =?UTF-8?q?=3D=20B06=20=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=E2=80=94=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=C2=B7=20=ED=8F=AC=EC=9E=A5=20=C2=B7=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=C2=B7=20=EC=B6=95=20=C2=B7=20=EB=8B=A8=EB=A9=B4=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8(B06=20=EA=B3=A0=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EC=A4=8C=20=EA=B8=B0=EC=96=B5=20=EC=95=88=20=EA=B1=B4=EB=93=9C?= =?UTF-8?q?=EB=A6=BC)=20=C2=B7=20=EC=A0=95=EB=B3=B8=EB=A7=8C=20=EC=9D=BD?= =?UTF-8?q?=EB=8A=94=20=EC=A0=9C=EC=96=B4=EA=B8=B0=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 66 +++++++-- .../B05_Profile_UI_RouteEdit_Cross_Card.ts | 131 ++++++++++++++++++ .../B05_Profile_UI_Style_RouteEdit.css | 8 ++ B06_Section/B06_Section_Section_Store.ts | 11 ++ B06_Section/B06_Section_Structure_Layouts.ts | 8 +- resources/tester/helper_365_page_pipeline.cjs | 5 + .../helper_b05_routeedit_cross_width.cjs | 18 ++- .../tester/test_b05_routeedit_cross_width.py | 10 ++ 8 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index eeef0e76d..d048aa3dc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -6,8 +6,8 @@ * · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간** * 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다. * - * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 · - * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. + * 그림 = B06 횡단 카드 읽기 전용(393-2 · `_Cross_Card` — 구조물 · 포장 · 면적 · 축) · 상세가 아직 없으면 + * 원지반 · 계획 횡단선 · 성토사면 길이만(`_Cross_Draw`). * * 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계 * (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 · @@ -44,6 +44,11 @@ import { readByKey, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; +import { + routeCrossCard, + routeCrossSource, + type RouteCrossSource, +} from "./B05_Profile_UI_RouteEdit_Cross_Card"; export interface CrossPreviewParams { projectId: string; @@ -90,8 +95,12 @@ export interface CrossPreviewWindow { interface CrossPane { root: HTMLElement; - /** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */ - show: (preview: CrossPreviewResponse | null, intervalM: number) => void; + /** 셈해 온 횡단을 그린다(단면 사본이 있으면 B06 카드 · 393-2). `null` 이면 빈 화면으로 되돌린다. */ + show: ( + preview: CrossPreviewResponse | null, + intervalM: number, + source?: RouteCrossSource | null, + ) => void; /** 기다리는 중임을 알린다. */ wait: (text: string) => void; } @@ -133,15 +142,40 @@ function createPane(title: string, empty: string): CrossPane { let drawn: CrossPreviewResponse | null = null; let size = { width: 0, height: 0 }; let zoom: ZoomPanState | undefined; + /** B06 카드 재료(393-2) · 카드 머리 · 발 높이(그림 높이 = 판 − 이것) · 측점 간격. */ + let source: RouteCrossSource | null = null; + let chrome = 0; + let interval = 20; + function drawCard(preview: CrossPreviewResponse, from: RouteCrossSource): void { + const plotHeight = (): number => Math.max(120, (size.height || 150) - chrome); + const build = (): HTMLElement => { + const card = routeCrossCard( + from, + interval, + preview.cross_half_width_m, + size.width || 240, + plotHeight(), + ); + box.replaceChildren(card); + return card; + }; + const card = build(); + const extra = card.offsetHeight - plotHeight(); + if (size.height && extra > 0 && extra !== chrome) { + chrome = extra; + build(); + } + } function draw(preview: CrossPreviewResponse): void { // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고 // 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고, // 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다. const rect = box.getBoundingClientRect(); size = { width: Math.round(rect.width), height: Math.round(rect.height) }; + observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. + if (source) return drawCard(preview, source); const plot = buildCrossSvg(preview, size.width || 240, size.height || 150); box.replaceChildren(plot.svg); - observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). attachSvgZoomPan({ @@ -166,9 +200,11 @@ function createPane(title: string, empty: string): CrossPane { }); return { root, - show(preview, intervalM) { + show(preview, intervalM, from = null) { box.replaceChildren(); drawn = preview; + source = preview ? from : null; + interval = intervalM; zoom = undefined; if (!preview) { station.textContent = ""; @@ -184,6 +220,7 @@ function createPane(title: string, empty: string): CrossPane { wait(text) { box.replaceChildren(); drawn = null; + source = null; foot.textContent = text; }, }; @@ -226,6 +263,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind let watching: { key: string | null; chainageM: number; structure: boolean } | null = null; /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; + let shownSource: RouteCrossSource | null = null; let drawnRoute: unknown = null; const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? []; @@ -245,11 +283,23 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind function redraw(): void { if (watching === null || watching.structure) return; const route = pipe?.input("route") ?? null; - if (route !== drawnRoute && shown) previous.show(shown, interval()); + if (route !== drawnRoute && shown) + previous.show( + shown, + interval(), + shownSource && { + ...shownSource, + section: { + ...shownSource.section, + station_id: shownSource.section.station_id.replace("b05-cur-", "b05-prev-"), + }, + }, + ); drawnRoute = route; const at = located()?.chainageM ?? watching.chainageM; shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId)); - if (shown) current.show(shown, interval()); + shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur"); + if (shown) current.show(shown, interval(), shownSource); else current.wait("계산 중…"); params.onShown?.(); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts new file mode 100644 index 000000000..fa9ba89c3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts @@ -0,0 +1,131 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cross_Card.ts + * 평면 「횡단」 · 「이전 횡단」 판 그림 = **B06 횡단 카드 읽기 전용**(393-2) — 구조물 · 포장 · 면적 · 축 · + * 종단 계획고 · 횡단 설계가 B06 카드와 같은 그림. + * + * · 자료 = 새 노선 상세(계산 묶음) · 없으면 받아 둔 저장 상세 · 노선 초안 중 새 상세가 아직 없으면 없음 + * (판은 옛 선 그림 `_Cross_Draw` 로 · 종단 임시 횡단 창도 그것 그대로) + * · 단면 사본(station_id 앞머리 · design 사본) — B06 고름 · 줌 기억 · 면적 원본을 안 건드림 + * · 구조물 자리 = 정본만 읽는 제어기(`Structure_Layouts` · 세월교 · 박스 조정값) · 누름 막음(줌 단추만) + * ========================================================================== */ + +import "../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; +import { pipelineDetail } from "../A00_Common/design/design_pipeline_page"; +import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import { nearestWithin } from "../A00_Common/design/design_selection"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { peekSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { + extraWalls, + inletStructure, + revetOffset, +} from "../B06_Section/B06_Section_Structure_Layouts"; +import { DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box_Geom"; +import type { BoxControl } from "../B06_Section/B06_Section_UI_Cross_Box_Panel"; +import { culvertLinkFor, type CulvertLink } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; +import { fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford_Geom"; +import type { FordControl } from "../B06_Section/B06_Section_UI_Cross_Ford_Panel"; +import { createCrossSectionCard } from "../B06_Section/B06_Section_UI_Cross_View"; +import { designElevationAt } from "../B06_Section/B06_Section_UI_Section_Common"; + +/** 판 하나가 그릴 측점 — 그때 사본(이전 판으로 내려도 그대로). */ +export interface RouteCrossSource { + section: CrossSection; + designZ: number | undefined; + link: CulvertLink | undefined; +} + +const none = (): void => undefined; + +/** 그 측점 단면 사본 — 상세가 없거나(초안 중 새 상세 전) 측점이 없으면 null. */ +export function routeCrossSource( + projectId: string, + pipe: DesignPipeline | null, + chainageM: number, + pane: string, +): RouteCrossSource | null { + const detail = + pipelineDetail(projectId) ?? (pipe?.input("route") ? null : peekSectionDetail(projectId)); + const sections = detail?.cross_sections ?? []; + const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m); + if (!detail || !found) return null; + const profiles = detail.longitudinal.design_profiles; + const planZ = designElevationAt(profiles, found.chainage_m); + return { + section: { + ...found, + station_id: `b05-${pane}-${found.station_id}`, + design: found.design && { ...found.design }, + }, + designZ: planZ === undefined ? undefined : planZ - (found.design?.surface_drop_m ?? 0), + link: found.culvert + ? undefined + : culvertLinkFor(found, sections, (at) => designElevationAt(profiles, at) ?? null), + }; +} + +/** B06 횡단 카드 읽기 전용 — 판 폭 · 그림 높이(px)로. */ +export function routeCrossCard( + source: RouteCrossSource, + intervalM: number, + halfWidthM: number | undefined, + widthPx: number, + plotHeightPx: number, +): HTMLElement { + const { section } = source; + const design = section.design; + const ford: FordControl = { + adjustFor: () => fordAdjustOf(design?.ford_adjust), + update: none, + reset: none, + setPipe: none, + setWing: none, + selectedFor: () => null, + select: none, + }; + const box: BoxControl = { + adjustFor: () => ({ + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design?.box_adjust?.left ?? {}) }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design?.box_adjust?.right ?? {}) }, + }), + update: none, + reset: none, + setBody: none, + setWing: none, + selectedFor: () => null, + select: none, + }; + const card = createCrossSectionCard( + section, + false, + 1, + none, + intervalM, + halfWidthM, + widthPx, + plotHeightPx, + source.designZ, + undefined, + undefined, + null, + undefined, + undefined, + revetOffset, + inletStructure, + extraWalls, + source.link, + undefined, + undefined, + ford, + box, + ); + // 읽기 전용 — 벽 · 선 · 면적 고름을 막음(줌 단추만 통과) + card.addEventListener( + "click", + (event) => { + if (!(event.target as Element).closest("button")) event.stopPropagation(); + }, + true, + ); + return card; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 9a436bcc2..2a8d4d5c5 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -254,6 +254,14 @@ background: var(--color-surface); } +/* B06 횡단 카드 읽기 전용(393-2) — 판 상자가 테두리 · 고름 강조 · 손가락 없음. */ +.b05-routeedit__cross-box > .b06-cross-card { + border: 0; + border-radius: 0; + box-shadow: none; + cursor: default; +} + .b05-routeedit__cross-svg { display: block; width: 100%; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index a5ac2ca76..ea766a1d2 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -258,6 +258,17 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo } } +/** 받아 둔 상세 중 노선 번호가 가장 큰 것(최신) — 노선 번호를 모르는 쪽(B05 평면 횡단 판 · 393-2). */ +export function peekSectionDetail(projectId: string): SectionDetailResponse | null { + const prefix = `${projectId}:`; + let best: { routeId: number; detail: SectionDetailResponse } | null = null; + for (const [key, detail] of cache) { + const routeId = Number(key.slice(prefix.length)); + if (key.startsWith(prefix) && (!best || routeId > best.routeId)) best = { routeId, detail }; + } + return best?.detail ?? null; +} + /** * 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름). * diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 0f0f47ef2..037a325b2 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -42,8 +42,8 @@ function storedWallAdjust(section: CrossSection, role: string): WallAdjust { return stored ? { ...ZERO_ADJUST, ...(stored as Partial) } : { ...ZERO_ADJUST }; } -/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다. */ -const revetOffset: RevetOffsetControl = { +/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다(B05 평면 횡단 판도 씀 · 393-2). */ +export const revetOffset: RevetOffsetControl = { adjustFor: (section, role) => storedWallAdjust(section, role), storedAdjustFor: (section, role) => section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null, @@ -55,7 +55,7 @@ const revetOffset: RevetOffsetControl = { reset: () => undefined, }; -const extraWalls: ExtraWallControl = { +export const extraWalls: ExtraWallControl = { countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0, setCount: () => undefined, equalize: () => undefined, @@ -63,7 +63,7 @@ const extraWalls: ExtraWallControl = { syncCount: () => undefined, }; -const inletStructure: InletStructureControl = { +export const inletStructure: InletStructureControl = { valueFor: (section) => section.design?.inlet_structure ?? "auto", adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }), set: () => undefined, diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index e49bd662a..13a052f20 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -181,6 +181,11 @@ const store = load("A00_Common", "design", "design_store.ts"); STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined }; STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" }; STUBS["B05_Profile_UI_RouteEdit_Chrome"] = { sidePanelsOf: new WeakMap() }; // 판 양식(382-1) — 화면 밖 +// B06 카드 그림(393-2) — 화면 밖 · 판 값 고르기는 진짜 +STUBS["B05_Profile_UI_RouteEdit_Cross_Card"] = { + routeCrossSource: () => null, + routeCrossCard: () => null, +}; const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"); const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs index b4e176a3e..b3e7ce55c 100644 --- a/resources/tester/helper_b05_routeedit_cross_width.cjs +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -67,7 +67,14 @@ let stations = [ let footprint = null; let route = { v: 1 }; // 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내 -const panels = { now: null, current: () => panels.now, open: (id) => (panels.now = id) }; +// 393-1 — 측점 눈금이 연 판은 기억 안 함(remember 거짓) +const panels = { + now: null, + current: () => panels.now, + open: (id, remember = true) => ((panels.now = id), (panels.remember = remember)), +}; +// 393-2 — 판 그림 = B06 카드(가짜) · 그린 단면 id 를 적음 +const cards = []; const side = { append() {} }; const STUBS = { @@ -79,6 +86,12 @@ const STUBS = { summarizeCross: () => "", }, "./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new WeakMap([[side, panels]]) }, + "./B05_Profile_UI_RouteEdit_Cross_Card": { + routeCrossSource: (projectId, pipe, chainageM, pane) => ({ + section: { station_id: `b05-${pane}-s${chainageM}` }, + }), + routeCrossCard: (source) => (cards.push(source.section.station_id), fakeEl()), + }, "../A00_Common/design/design_pipeline_page": { pagePipeline: () => ({ output: (name) => (name === "ground" ? { stations } : null), @@ -171,8 +184,10 @@ async function run() { ]; footprint = { records: [record("grid:5", 102, 9, -4, false, true)] }; route = { v: 2 }; + cards.length = 0; await cross.refresh(); out.afterEdit = cross.watched(); + out.cardsAfterEdit = cards.slice(); // 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄 const heard = []; @@ -185,6 +200,7 @@ async function run() { selection: pick.readSelection("p-1"), heard: heard.slice(), panel: panels.now, + remember: panels.remember, watched: cross.watched(), }; diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py index 37aee859c..8a99b6879 100644 --- a/resources/tester/test_b05_routeedit_cross_width.py +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -92,3 +92,13 @@ def test_관_구조물_고름은_마름모만(results): def test_고름을_풀면_법선도_없다(results): assert results["cleared"] == {"watched": None, "redrawn": True} + + +def test_393_판_그림은_B06_카드_사본_노선_고치면_전후(results): + # 노선 고친 차례 — 아래 판 = 고치기 전 측점(이전 사본 id) · 위 판 = 새 측점 · B06 카드 id 와 안 겹침 + assert results["cardsAfterEdit"] == ["b05-prev-s100", "b05-cur-s102"] + + +def test_393_1_눈금이_연_판은_기억_안_함(results): + assert results["planClick"]["panel"] == "cross" + assert results["planClick"]["remember"] is False From 50c40d564e83a61c8891b15a07d5da6eaeb621b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 17:45:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(B05=20=EC=A2=85=EB=8B=A8=EB=8F=84):=203?= =?UTF-8?q?92-2=20=EC=9C=A0=ED=86=A0=20=ED=8C=90=20[=EC=B0=BD=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0]=20=EC=B8=B5=20z6=20=E2=80=94=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?Y=EC=B6=95=20=EB=9D=A0=20=EB=B0=91=EC=97=90=20=EA=B0=80?= =?UTF-8?q?=EB=A0=A4=EC=A7=80=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 10-14 판 자리(판 좌상단)로 되살리며 축 띠(z5 · 불투명) 아래로 들어가 안 보이던 것 · 누름은 축이 통과시켜 시험에선 안 잡힘 - 화면 확인(밝음 · 어두움) · test_354_1 에 z6 판정 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- A00_Common/design/design_page.css | 3 ++- resources/tester/test_354_1_masshaul_own_panel.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index d4b2ff545..14c44dba2 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -66,7 +66,8 @@ position: absolute; top: 4px; left: var(--spacing-8); - z-index: 4; + /* 고정 Y축(z5 · 불투명) 위 — 아래면 축 띠에 가려짐 */ + z-index: 6; } /* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */ diff --git a/resources/tester/test_354_1_masshaul_own_panel.py b/resources/tester/test_354_1_masshaul_own_panel.py index 2fa426979..db6d58359 100644 --- a/resources/tester/test_354_1_masshaul_own_panel.py +++ b/resources/tester/test_354_1_masshaul_own_panel.py @@ -137,6 +137,6 @@ def test_창_분할_판_좌상단(): assert "massOverlay.append(button);" in popout assert "home: () => massHandle.before(massOverlay)," in popout page = read("A00_Common/design/design_page.css") - assert "position: absolute;" in css_block( - page, ".b05-profile__masshaul-overlay > .b05-subpanel__popout" - ) + button = css_block(page, ".b05-profile__masshaul-overlay > .b05-subpanel__popout") + # 고정 Y축(z5) 위 — 아래면 단추가 축 띠에 가려짐 + assert "position: absolute;" in button and "z-index: 6;" in button