refactor(B05·B06): 종단도 한 벌 마무리 — 배수유역도 판(createDrainageSide)을 두 보기에 끼움 · 켬끔 단추 · 흙덮기 강제 값 한 칸 · 계획고 초안 키를 등록부(alignment-draft)로
- 두 보기 같은 오른쪽 판 · 같은 켬끔 값(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
@@ -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` 와 함께 버린다. */
|
||||
|
||||
@@ -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<boolean>("profile-enforce-min-cover", projectId) === true;
|
||||
/** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */
|
||||
const takePipes = (pipes: ReadonlyArray<MinCoverPipe>): 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<MinCoverPipe>) {
|
||||
|
||||
@@ -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<T> {
|
||||
/** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */
|
||||
id: string | null;
|
||||
@@ -73,25 +85,19 @@ function slotOf<K extends DesignStoreKey>(
|
||||
): Slot<DesignStoreValues[K]> {
|
||||
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<AlignmentEdits>;
|
||||
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<Partial<AlignmentEdits>>("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),
|
||||
};
|
||||
}
|
||||
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean>("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<RouteLatestResponse>("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,
|
||||
|
||||
@@ -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));
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user