Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-19 09:40:17 +09:00
18 changed files with 420 additions and 166 deletions
-2
View File
@@ -218,8 +218,6 @@ export const STATE_REGISTRY = {
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
/** 종단도 최소 흙덮기 강제 여부 — 종단 보기 「페이지 설정」 값을 두 보기가 같이 본다(24-2c). */
"profile-enforce-min-cover": { bucket: "result", scope: "project" },
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
* 노선이 바뀌면 `latest` 와 함께 버린다. */
+20 -59
View File
@@ -12,9 +12,7 @@
* ========================================================================== */
import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans";
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
StructureInstance,
StructureType,
@@ -24,7 +22,6 @@ import {
readAlignment,
toDesignProfile,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
@@ -46,6 +43,9 @@ import {
toAlignmentBase,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { enforceMinCoverOf } from "../../B05_Profile/B05_Profile_Api_PageSettings";
import { applyPageSettings } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { useDesignStore } from "./design_store";
import {
endOffsetsOf,
withEndOffset,
@@ -86,11 +86,7 @@ import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import { CROSS_PREVIEW_DEBOUNCE_MS, type DesignProfileContext } from "./design_profile_context";
import { pageRouteId } from "./design_page_route";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
import { createProfileFrame } from "./design_profile_frame"; // 판 뼈대 · 종단 CSS(700줄 한계)
export type {
DesignOverlayBoard,
@@ -104,30 +100,8 @@ export type { DesignProfileContext } from "./design_profile_context";
export function createDesignProfile(ctx: DesignProfileContext) {
const { projectId, onSelectStation, onResetAll, callbacks } = ctx;
adoptLegacyProfileKeys();
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = ctx.view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
const { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing } =
createProfileFrame(ctx.view);
// 유토곡선 · 테이블 — 종단 칸 바닥 겹침 판(2026-08-04 · 392-2) · 위 경계 리사이저로 높이 조절.
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
@@ -144,13 +118,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
body,
onChange: subPanelChanged,
});
const content = document.createElement("div");
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제).
* 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */
let enforceMinCover = readState<boolean>("profile-enforce-min-cover", projectId) === true;
/** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */
const takePipes = (pipes: ReadonlyArray<MinCoverPipe>): void => {
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
@@ -204,8 +173,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 노선 0 칸(alignment-draft · 346-1). */
let preview: typeof store | null = null;
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
let savedEdits: AlignmentEdits = emptyEdits();
let redrawPending = false;
let lastWidth = 0;
let lastHeight = 0;
@@ -270,8 +237,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
@@ -294,14 +259,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
// 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다.
drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined },
restore: () => {
// 세션이 정본 — 편집 초안을 다시 읽음 · 노선 초안 동안은 노선 0 칸 · 계획고가 그대로면 다시 셈 예약 없음(349-0q)
const [was, draft] = [JSON.stringify(store.edits()), store === preview];
const id = draft ? pagePipeline.DRAFT_ROUTE_ID : routeId;
store = createProfileEditStore(id, draft ? store.edits() : savedEdits, () => rebuild());
if (draft) preview = store;
void (JSON.stringify(store.edits()) !== was ? rebuild() : draw());
},
// 세션이 정본 — 가게가 편집 초안을 다시 읽음(노선 초안 동안은 노선 0 칸) · 계획고가 그대로면 다시 셈 예약 없음(349-0q)
restore: () => void (store.resync() ? rebuild() : draw()),
restoreSaved: () => store.restoreSaved(),
canRestoreSaved: () => store.canRestoreSaved(),
refresh: () => renderBalance(),
@@ -323,7 +282,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
return false;
}
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;
// 최소고 강제 = 페이지 설정 한 칸(초안 ?? 저장 노선 · 472-5) — 보기마다 사본 없음
const enforce = enforceMinCoverOf(projectId);
if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;
store.replace(next);
history.record();
return true;
@@ -469,7 +430,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes));
}
return {
const profile = {
root,
/** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */
setView(next: DesignView) {
@@ -488,8 +449,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
if (routeChanged || (store !== preview && !store.dirty())) {
routeId = nextRouteId ?? routeId;
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
}
storedBase = stored ? toAlignmentBase(stored) : null;
if (store !== preview) base = listed(); // 노선 초안 그림은 그대로(갈아끼운 상세)
@@ -538,11 +498,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
draw();
revealPick();
},
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
enforceMinCover = enforce;
writeState("profile-enforce-min-cover", enforce, projectId);
},
/** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */
setMinCoverPipes(pipes: ReadonlyArray<MinCoverPipe>) {
takePipes(pipes);
@@ -630,7 +585,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
rebuild(),
);
if (draft) return void ((base = nextBase), rebuild());
if (store === preview) store = createProfileEditStore(routeId, savedEdits, () => rebuild());
if (store === preview) store = createProfileEditStore(routeId, undefined, () => rebuild());
[preview, base] = [null, listed()];
alignment = base && buildAlignment(base, store.edits()); // 그리기만 · 다시 셈 예약 없음(349-0l)
drawSoon();
@@ -683,8 +638,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
drag.dispose();
crossPreview.dispose();
unbindViewport();
unbindSettings();
},
};
// 페이지 설정 = 가게 한 칸 — ↶ ↷ · 다른 보기가 고친 기울기 한도 · 시작 측점을 다시 얹음(N6 · 472-5)
const unbindSettings = useDesignStore(projectId, null).subscribe("pageSettings", () =>
applyPageSettings(profile, projectId),
);
return profile;
}
/** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */
+45
View File
@@ -0,0 +1,45 @@
/* =============================================================================
* A00_Common/design/design_profile_frame.ts
* 종단도 한 벌의 **판 뼈대**(DOM 틀) — `design_profile.ts` 에서 갈라냄(700줄 한계 · 472-5).
* 그리기 · 편집 · 상태는 저쪽 몫 · 여기는 칸만 만들어 돌려줌.
* ========================================================================== */
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createProgressCircle } from "@ui/ui_template_progress";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
export function createProfileFrame(view: string) {
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
return { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing };
}
+23 -2
View File
@@ -12,7 +12,9 @@
*
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
* 값은 안 받는다(387-1). B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다. `alignmentSaved` = 그 노선 서버
* 저장분(메모리만 · 같은 프로젝트 규칙 · 472-5) — 지금 편집 = 초안 ?? 저장분(`_Profile_Edit`).
* `pageSettings` = 등록부 `pagesettings`(페이지 설정 초안 · 448) — 폼 · 기울기 한도 · 최소고 강제가 읽음.
*
* 계산 묶음 칸(344-4 · 모양 = `design_pipeline_types.ts`) — `routeDraft` 는 초안(세션 즉시) ·
* `stations` · `profile` · `footprint` 는 **메모리 우선**(결과가 커서 끌기마다 세션에 쓰지 않음 ·
@@ -24,6 +26,7 @@ import type { StateName } from "../b_page_state";
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PageSettingsBody } from "../../B05_Profile/B05_Profile_Api_Fetch";
import type { AlignmentEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import type { ProfileAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
@@ -48,6 +51,10 @@ export interface DesignStoreValues {
pipes: DetailPipeInput[] | null;
/** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */
alignmentDraft: AlignmentEdits | null;
/** 그 노선 서버 저장분 계획고 편집 — 초안이 없을 때의 지금 값 · [초기화] 기준점(메모리만). */
alignmentSaved: AlignmentEdits | null;
/** 미저장 페이지 설정(설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점). */
pageSettings: PageSettingsBody | null;
/** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */
sectionDetail: SectionDetailResponse | null;
/** 고른 측점 · 구조물. */
@@ -120,6 +127,16 @@ function slotOf<K extends DesignStoreKey>(
write: (value) => writeState("alignment-draft", value, project, routeId),
};
}
if (key === "alignmentSaved") {
if (routeId === null) return { id: null, read: () => null, write: () => undefined };
const id = `alignment-saved:${draftProjectId(projectId)}:${routeId}`;
return {
id,
read: () => (savedAlignments.get(id) as V | undefined) ?? null,
write: (value) =>
void (value === null ? savedAlignments.delete(id) : savedAlignments.set(id, value)),
};
}
if (key in MEMORY_SLOTS) return memorySlot<V>(key as MemoryKey, projectId);
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
const name = (
@@ -130,8 +147,9 @@ function slotOf<K extends DesignStoreKey>(
sectionDetail: "section-detail",
selection: "selection",
routeDraft: "route-draft",
pageSettings: "pagesettings",
} as const
)[key as Exclude<DesignStoreKey, "alignmentDraft" | MemoryKey>];
)[key as Exclude<DesignStoreKey, "alignmentDraft" | "alignmentSaved" | MemoryKey>];
const routeScoped = name === "section-detail";
if (!projectId || (routeScoped && routeId === null)) {
return { id: null, read: () => null, write: () => undefined };
@@ -144,6 +162,9 @@ function slotOf<K extends DesignStoreKey>(
};
}
/** 노선마다 서버 저장분 계획고 편집(`alignmentSaved`) — 종단도가 상세를 받을 때 채움. */
const savedAlignments = new Map<string, unknown>();
/** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */
const MEMORY_SLOTS = {
stations: "design-stations",
+9 -2
View File
@@ -68,6 +68,7 @@ import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { pipeIdAt } from "./design_pipe_ids";
import { useDesignStore } from "./design_store";
import { pageSettingsPanelValues } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { setPlusStations } from "../../common_util/common_util_structure_station";
import {
loadSavedPlusStations,
@@ -346,10 +347,16 @@ export async function mountProfileView(
function markStale(): void {
if (state.restoring || !state.routeReady) return;
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
// 횡단배수 최소고 강제 체크는 페이지 설정 한 칸(가게)을 편집 가드가 바로 읽음(2026-09-01 · 472-5).
sections.redrawStationLines();
}
// 페이지 설정 = 가게 한 칸 — ↶ ↷ 가 되쓴 값을 폼에 다시 얹음(N6 · 472-5)
host.onDispose(
designStore.subscribe("pageSettings", () => {
const values = pageSettingsPanelValues(activeProjectId);
if (values) panel.restore(values);
}),
);
viewer.markers.onChange(markStale);
viewer.markers.onStationSelectionChange((stationId) => {