From acd30ba32c759435cd6111af5dbb05959dacc74f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 20:47:40 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05=C2=B7B06):=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=ED=95=9C=20=EB=B2=8C=20=EB=A7=88=EB=AC=B4=EB=A6=AC?= =?UTF-8?q?=20=E2=80=94=20=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=EB=8F=84=20?= =?UTF-8?q?=ED=8C=90(createDrainageSide)=EC=9D=84=20=EB=91=90=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EB=81=BC=EC=9B=80=20=C2=B7=20=EC=BC=AC?= =?UTF-8?q?=EB=81=94=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=ED=9D=99=EB=8D=AE?= =?UTF-8?q?=EA=B8=B0=20=EA=B0=95=EC=A0=9C=20=EA=B0=92=20=ED=95=9C=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=ED=82=A4=EB=A5=BC=20=EB=93=B1=EB=A1=9D=EB=B6=80(al?= =?UTF-8?q?ignment-draft)=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 두 보기 같은 오른쪽 판 · 같은 켬끔 값(drainage-visible) · 단추는 도구줄 오른쪽(Profile_SideTool) - B06 배수유역도 노선 평면선 = 최신 노선 캐시(없으면 한 번 읽기) - 최소 흙덮기 강제 여부를 두 보기가 한 칸(profile-enforce-min-cover)에서 봄 — B06 막기 동작 - 계획고 초안 b05-profile-alignment-draft:{노선} → aislo:draft:alignment-draft:{프로젝트}:{노선}(옛 값 옮겨 받음) · 되돌리기 사진 · [초기화] 쓸기 포함 - 세로 창 고정을 _Profile_Drag 로(design_profile 684줄) · 시험 test_design_profile_single 6건 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- A00_Common/b_page_state.ts | 11 +++ A00_Common/design/design_profile.ts | 49 +++++------ A00_Common/design/design_store.ts | 48 ++++++----- B05_Profile/B05_Profile_UI_Profile_Drag.ts | 36 +++++++++ B05_Profile/B05_Profile_UI_Profile_Edit.ts | 9 ++- B05_Profile/B05_Profile_UI_Profile_History.ts | 2 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 5 +- .../B05_Profile_UI_Profile_SideTool.ts | 24 ++++++ B05_Profile/B05_Profile_UI_Style_Table.css | 14 ++++ .../B06_Section_UI_Section_View_Profile.ts | 36 ++++++++- .../tester/helper_design_profile_single.cjs | 79 ++++++++++++++++++ .../tester/test_design_profile_single.py | 81 +++++++++++++++++++ 12 files changed, 335 insertions(+), 59 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_SideTool.ts create mode 100644 resources/tester/helper_design_profile_single.cjs create mode 100644 resources/tester/test_design_profile_single.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 3482913b..cca13de9 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -57,6 +57,8 @@ export const STATE_REGISTRY = { legacy: () => "b05-route-drainage-collapsed", }, "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, + /** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */ + "drainage-visible": { bucket: "pref", scope: "global" }, /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, @@ -156,10 +158,19 @@ export const STATE_REGISTRY = { * 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고 * [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */ crossdesign: { bucket: "draft", scope: "route" }, + /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). + * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ + "alignment-draft": { + bucket: "draft", + scope: "route", + legacy: (_p, r) => `b05-profile-alignment-draft:${r}`, + }, /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ 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 587b9306..d390d4fc 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans"; +import { readState, writeState } from "../b_page_state"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; @@ -73,6 +74,7 @@ import { createProfileZoom } from "../../B05_Profile/B05_Profile_UI_Profile_Zoom import { adoptLegacyProfileKeys, bindProfileCollapse, + createElevationHold, createProfileDrag, } from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; import { @@ -105,6 +107,8 @@ export interface DesignProfileContext { view: DesignView; /** 오른쪽 판 — 없으면 판 없이 종단 열만. */ side?: DesignSideFactory; + /** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */ + sideTool?: (board: DesignSideBoard) => HTMLElement; /** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */ onSelectStation: (stationId: string | null) => void; /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */ @@ -194,8 +198,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { content.className = "b05-route-profile__content"; /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ let minCoverTargets: MinCoverPoint[] = []; - /** 최소고를 편집에서 강제할지 — 페이지 설정 체크박스를 따라온다(기본 해제). */ - let enforceMinCover = false; + /** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제). + * 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */ + let enforceMinCover = readState("profile-enforce-min-cover", projectId) === true; /** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */ const takePipes = (pipes: ReadonlyArray): void => { // 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다. @@ -282,7 +287,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { minCoverViolations, balanceBar, tools: tools.render(), - trailing: profileZoom.bar, + trailing, alignment, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), edited: store.edited(), @@ -299,32 +304,15 @@ export function createDesignProfile(ctx: DesignProfileContext) { 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ const profileZoom = createProfileZoom(() => draw()); - /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ - let elevationWindow: { min: number; max: number } | undefined; /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; - /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ - let heightEditing = false; - const holdElevationRange = ( - next: { min: number; max: number } | null, - ): { min: number; max: number } | undefined => { - if (heightEditing) return elevationWindow; - elevationWindow = next ?? undefined; - return elevationWindow; - }; - // 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다. - body.addEventListener("pointerdown", (event) => { - if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; - heightEditing = true; - const release = (): void => { - heightEditing = false; - window.removeEventListener("pointerup", release); - window.removeEventListener("pointercancel", release); - draw(); - }; - window.addEventListener("pointerup", release); - window.addEventListener("pointercancel", release); - }); + /** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */ + const elevationHold = createElevationHold(body, () => draw()); + // 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리. + const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null; + const trailing = document.createElement("div"); + trailing.className = "b05-route-profile__trailing"; + trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : [])); /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ const { tools, history, handleToolPick } = createPanelTools({ @@ -461,7 +449,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { applyEdits, handleToolPick, zoom: profileZoom.state, - holdElevationRange, + holdElevationRange: elevationHold.hold, toolActive: () => tools.mode() !== "none", selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, @@ -481,12 +469,12 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. body.addEventListener("scroll", () => { - if (heightEditing) return; + if (elevationHold.editing()) return; const fitted = updateElevationWindow?.() ?? null; window.clearTimeout(scrollSettleTimer); if (!needsFullRedraw(fitted)) return; scrollSettleTimer = window.setTimeout(() => { - if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; + if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; draw(); }, SCROLL_SETTLE_MS); @@ -592,6 +580,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ setEnforceMinCover(enforce: boolean) { enforceMinCover = enforce; + writeState("profile-enforce-min-cover", enforce, projectId); }, /** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */ setMinCoverPipes(pipes: ReadonlyArray) { diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index 4201d84e..909a3386 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -10,12 +10,13 @@ * 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다 * (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것). * - * ⚠ `alignmentDraft` 만 등록부 밖 옛 키(`b05-profile-alignment-draft:{노선}`)를 감싼다 — - * B05 되돌리기(`_Profile_History`)가 `b05-` 접두어 키만 사진 찍기 때문이다. 등록부 이관은 - * 24-2c 가 되돌리기와 함께 한다(브레인 판정 9-29). + * `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`) + * 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c). + * 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다. * ========================================================================== */ -import { readByKey, readState, writeByKey, writeState } from "../b_page_state"; +import { readState, writeState } 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 { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; @@ -56,9 +57,20 @@ export interface DesignStore { ): () => void; } -/** 옛 계획고 초안 키 — 위 머리 주석 ⚠ 참조. */ +/** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */ export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; +/** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 지금 연 프로젝트, 그것도 없으면 `-` + * (노선 번호는 프로젝트를 건너 겹치지 않는다). */ +export function draftProjectId(projectId: string | null = null): string { + if (projectId) return projectId; + try { + return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-"; + } catch { + return "-"; + } +} + interface Slot { /** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */ id: string | null; @@ -73,25 +85,19 @@ function slotOf( ): Slot { type V = DesignStoreValues[K]; if (key === "alignmentDraft") { - const raw = routeId === null ? null : `${ALIGNMENT_DRAFT_KEY_PREFIX}:${routeId}`; + const project = draftProjectId(projectId); + if (routeId === null) return { id: null, read: () => null, write: () => undefined }; return { - id: raw, + id: `alignment-draft:${project}:${routeId}`, read: () => { - const text = raw ? readByKey(raw) : null; - if (!text) return null; - try { - const parsed = JSON.parse(text) as Partial; - return { - station_offsets: parsed.station_offsets ?? {}, - curve_radii: parsed.curve_radii ?? {}, - } as V; - } catch { - return null; - } - }, - write: (value) => { - if (raw) writeByKey(raw, value === null ? null : JSON.stringify(value)); + const parsed = readState>("alignment-draft", project, routeId); + if (!parsed) return null; + return { + station_offsets: parsed.station_offsets ?? {}, + curve_radii: parsed.curve_radii ?? {}, + } as V; }, + write: (value) => writeState("alignment-draft", value, project, routeId), }; } // 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index cf4f144e..8339c28d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -57,6 +57,42 @@ export function bindProfileCollapse( setCollapsed(readStateRaw("profile-collapsed") === "true"); } +/** + * 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다 — 끌어 올리는 동안 축까지 따라 + * 움직이면 조작 감각이 깨진다. 손을 뗀 뒤 한 번만 다시 맞춘다(`redraw`). + */ +export function createElevationHold( + body: HTMLElement, + redraw: () => void, +): { + hold: (next: { min: number; max: number } | null) => { min: number; max: number } | undefined; + editing: () => boolean; +} { + /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ + let elevationWindow: { min: number; max: number } | undefined; + let heightEditing = false; + body.addEventListener("pointerdown", (event) => { + if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; + heightEditing = true; + const release = (): void => { + heightEditing = false; + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + redraw(); + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + }); + return { + hold(next) { + if (heightEditing) return elevationWindow; + elevationWindow = next ?? undefined; + return elevationWindow; + }, + editing: () => heightEditing, + }; +} + /** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */ interface SubPanel { overlay: HTMLElement; diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index ccf10047..474a7788 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -17,7 +17,12 @@ import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment"; -import { ALIGNMENT_DRAFT_KEY_PREFIX, useDesignStore } from "../A00_Common/design/design_store"; +import { + ALIGNMENT_DRAFT_KEY_PREFIX, + draftProjectId, + useDesignStore, +} from "../A00_Common/design/design_store"; +import { clearState } from "../A00_Common/b_page_state"; /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ const BUTTON_CLEARANCE_PX = 23; @@ -103,6 +108,8 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul */ 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) { diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index b1b41cd0..f5c35b33 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -13,7 +13,7 @@ * ========================================================================== */ /** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ -const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; +const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-", "aislo:draft:alignment-draft:"]; /** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ function isLayoutKey(key: string): boolean { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 7b261641..b420334d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -9,7 +9,7 @@ import { createDesignProfile } from "../A00_Common/design/design_profile"; import type { DesignSideBoard } from "../A00_Common/design/design_profile"; -import { createDrainageSide } from "./B05_Profile_UI_Drainage_Side"; +import { drainageSide, drainageSideTool } from "./B05_Profile_UI_Profile_SideTool"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; /** 콜백 타입은 `_Panel_Types` 에 있다 — 옛 임포트 경로가 그대로 동작하도록 다시 내보낸다. */ @@ -26,7 +26,8 @@ export function createRouteProfilePanel( const profile = createDesignProfile({ projectId, view: "profile", - side: createDrainageSide, + side: drainageSide, + sideTool: drainageSideTool, onSelectStation, onResetAll, callbacks, diff --git a/B05_Profile/B05_Profile_UI_Profile_SideTool.ts b/B05_Profile/B05_Profile_UI_Profile_SideTool.ts new file mode 100644 index 00000000..5565caea --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_SideTool.ts @@ -0,0 +1,24 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_SideTool.ts + * 종단도 오른쪽 판(배수유역도)을 두 보기에 **같은 모양 · 같은 켬끔 값**으로 끼운다 + * (계획서 24-2c · 24-2e). 판 본체는 `_Drainage_Side`(sub7), 켬 · 끔 값은 `drainage-visible`. + * ========================================================================== */ + +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import type { DesignSideBoard, DesignSideFactory } from "../A00_Common/design/design_profile"; +import { + createDrainageSide, + createDrainageToggleButton, + type DrainageSideBoard, +} from "./B05_Profile_UI_Drainage_Side"; + +/** 오른쪽 판 — 켬 · 끔은 두 보기가 같은 값을 쓴다(기본 켬). */ +export const drainageSide: DesignSideFactory = (host) => + createDrainageSide(host, { visible: readStateRaw("drainage-visible") !== "false" }); + +/** 켬 · 끔 단추 — 누를 때마다 값을 적는다. */ +export function drainageSideTool(board: DesignSideBoard): HTMLElement { + const side = board as DrainageSideBoard; + side.onVisibleChange((visible) => writeStateRaw("drainage-visible", String(visible))); + return createDrainageToggleButton(side); +} diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 5b5fec88..13ffd17e 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -492,6 +492,20 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } +/* 도구줄 오른쪽 묶음 — 가로 배율 조작구 + 오른쪽 판 켬끔 단추(두 보기 같은 자리, 계획서 24-2c). */ +.b05-route-profile__trailing { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--spacing-8); + margin-left: auto; +} + +.b05-route-profile__trailing > .b05-drainage__toggle { + padding: 1px var(--spacing-8); + font-size: var(--text-caption); +} + .b05-profile__zoom-btn { width: 22px; height: 22px; diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 4233ac5e..a7218998 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -9,6 +9,9 @@ * ========================================================================== */ import { createDesignProfile, type DesignProfile } from "../A00_Common/design/design_profile"; +import { readState } from "../A00_Common/b_page_state"; +import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { drainageSide, drainageSideTool } from "../B05_Profile/B05_Profile_UI_Profile_SideTool"; import type { CrossSection, EarthworkConversion, @@ -93,6 +96,31 @@ function pipeStations( }); } +/** + * 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고, + * 없으면 한 번 읽는다(읽기만 · 계산 없음). + */ +function loadRoute(projectId: string, profile: DesignProfile): void { + // 최소 흙덮기 강제 여부 — 종단 보기를 거치지 않고 바로 들어왔으면 저장된 노선 설정값. + const enforceKnown = readState("profile-enforce-min-cover", projectId) !== null; + const apply = (latest: RouteLatestResponse): void => { + profile.setRoutePolyline(latest.route_points ?? []); + if (!enforceKnown) { + profile.setEnforceMinCover(latest.route_params?.enforce_pipe_clearance === true); + } + }; + const cached = readState("latest", projectId); + if (cached?.route_points?.length) { + apply(cached); + return; + } + void fetchLatestRoute(projectId) + .then(apply) + .catch(() => { + /* 노선을 못 읽으면 판이 「노선 필요」 안내를 그대로 보인다. */ + }); +} + export function createSectionProfile(host: SectionProfileHost): SectionProfile { let profile: DesignProfile | null = null; let projectId: string | null = null; @@ -140,6 +168,9 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile { createDesignProfile({ projectId: id, view: "cross", + // 오른쪽 판(배수유역도) — 종단 보기와 같은 판 · 같은 켬끔 값(계획서 24-2e). + side: drainageSide, + sideTool: drainageSideTool, onSelectStation: (stationId) => host.selectCard(cardOf(stationId)), callbacks: { onCrossDesignsUpdated: (updated) => host.refreshCards(updated), @@ -184,6 +215,7 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile { if (!profile) { projectId = id; profile = build(id); + loadRoute(id, profile); syncMarks(); } detail = input.detail; @@ -194,10 +226,6 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile { input.stationInterval, Number.isFinite(routeId) ? routeId : undefined, ); - // 최소 흙덮기 강제 여부는 종단 보기 「페이지 설정」 값 — 계획선 정책에 실려 온다. - const policy = input.detail.longitudinal.profile_alignment as - { policy?: { enforce_pipe_clearance?: boolean } } | undefined; - profile.setEnforceMinCover(policy?.policy?.enforce_pipe_clearance === true); if (input.conversion) { profile.setEarthworkContext({ conversion: input.conversion, diff --git a/resources/tester/helper_design_profile_single.cjs b/resources/tester/helper_design_profile_single.cjs new file mode 100644 index 00000000..15eff0cb --- /dev/null +++ b/resources/tester/helper_design_profile_single.cjs @@ -0,0 +1,79 @@ +/* 종단도 한 벌(계획서 24-2c) 단위검증 헬퍼 — 계획고 초안이 등록부 칸(`alignment-draft`)으로 + * 옮겨졌는지 · 옛 키 값을 옮겨 받는지 · 되돌리기 사진이 새 칸을 찍는지를 Node 로 돌린다. + * `A00_Common/design/design_store.ts` · `B05_Profile_UI_Profile_History.ts` 를 그 자리에서 + * 트랜스파일한다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + keys: () => [...map.keys()], + }; +} +const session = memoryStorage(); +const local = memoryStorage(); +globalThis.window = { sessionStorage: session, localStorage: local }; +globalThis.sessionStorage = session; + +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 localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_History.ts")); + +const out = {}; +// ① 옛 키에 남은 초안 — 처음 읽을 때 새 칸으로 옮겨 받고 옛 키는 지운다. +local.setItem("frd_current_project_id", "p-1"); +session.setItem( + "b05-profile-alignment-draft:7", + JSON.stringify({ station_offsets: { "20.000": 0.3 }, curve_radii: {} }), +); +out.adopted = useDesignStore(null, 7).get("alignmentDraft"); +out.keysAfterAdopt = session.keys(); + +// ② 쓰기는 새 칸으로 — 프로젝트를 준 가게와 안 준 가게가 같은 칸. +useDesignStore("p-1", 7).set("alignmentDraft", { + station_offsets: { "40.000": 0.1 }, + curve_radii: {}, +}); +out.sameSlot = useDesignStore(null, 7).get("alignmentDraft"); + +// ③ 되돌리기가 새 칸을 사진 찍는다 — 편집 한 걸음을 쌓고 되돌리면 앞 값이 돌아온다. +const undoStack = history.createProfileHistory(() => undefined); +useDesignStore("p-1", 7).set("alignmentDraft", { + station_offsets: { "40.000": 0.2 }, + curve_radii: {}, +}); +undoStack.record(); +out.beforeUndo = useDesignStore("p-1", 7).get("alignmentDraft"); +out.undone = undoStack.undo(); +out.afterUndo = useDesignStore("p-1", 7).get("alignmentDraft"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_design_profile_single.py b/resources/tester/test_design_profile_single.py new file mode 100644 index 00000000..8c36d7b7 --- /dev/null +++ b/resources/tester/test_design_profile_single.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""종단도 한 벌(계획서 24-2c) 회귀검증. + +두 보기(B05 종단 · B06 횡단)가 **같은 종단도**(`A00_Common/design/design_profile.ts`)를 쓰고 +같은 오른쪽 판(배수유역도)을 끼우는지, 옛 B06 위쪽 붙박이 종단이 남지 않았는지, 계획고 초안이 +등록부 칸(`alignment-draft`)으로 옮겨져 되돌리기가 그 칸을 찍는지 본다. +Node 헬퍼(`helper_design_profile_single.cjs`)가 TS 를 트랜스파일해 돌린다. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_design_profile_single.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_두_보기가_같은_종단도와_같은_판(): + b05 = _read("B05_Profile/B05_Profile_UI_Profile_Panel.ts") + b06 = _read("B06_Section/B06_Section_UI_Section_View_Profile.ts") + for source in (b05, b06): + assert "createDesignProfile(" in source + assert "side: drainageSide" in source + assert "sideTool: drainageSideTool" in source + assert 'view: "profile"' in b05 and 'view: "cross"' in b06 + + +def test_옛_B06_위쪽_종단이_남지_않음(): + view = _read("B06_Section/B06_Section_UI_Section_View.ts") + assert "createSectionProfile(" in view + assert "drawLongitudinalPanel" not in view + assert "b06-section__panel" not in view + for gone in ("Draw", "Chart", "Panel"): + assert not (ROOT / f"B06_Section/B06_Section_UI_Section_View_{gone}.ts").exists() + + +def test_막기_규칙은_종단도_한_곳(): + profile = _read("A00_Common/design/design_profile.ts") + assert profile.count("blocksMinCover(") == 1 + # 판이 없는 보기도 관 목록을 넘겨 최소 흙덮기를 판정한다. + assert "setMinCoverPipes(" in profile + assert "setMinCoverPipes(" in _read("B06_Section/B06_Section_UI_Section_View_Profile.ts") + + +def test_크기_접기_키는_하나(): + drag = _read("B05_Profile/B05_Profile_UI_Profile_Drag.ts") + assert 'stateKey("profile-height")' in drag + assert '"profile-collapsed"' in drag + 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"] + assert results["sameSlot"]["station_offsets"] == {"40.000": 0.1} + + +def test_되돌리기가_새_칸을_찍음(results): + assert results["beforeUndo"]["station_offsets"] == {"40.000": 0.2} + assert results["undone"] is True + assert results["afterUndo"]["station_offsets"] == {"40.000": 0.1}