Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_8
This commit is contained in:
@@ -107,6 +107,8 @@ export const STATE_REGISTRY = {
|
||||
"input-prefs": { bucket: "pref", scope: "global" },
|
||||
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
|
||||
"b05-3d-projection": { bucket: "pref", scope: "global" },
|
||||
/** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */
|
||||
"drainage-mode": { bucket: "pref", scope: "global" },
|
||||
"section-panel-height": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_leave.ts
|
||||
* B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때
|
||||
* 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]).
|
||||
*
|
||||
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) ·
|
||||
* 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
|
||||
* 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄:
|
||||
* [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음.
|
||||
* B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음.
|
||||
* 프로젝트 바꿈은 이 페이지를 떠난 뒤라 이 물음이 먼저 · 들어갈 때 물음은 `b_project_switch`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
clearState,
|
||||
namesInBucket,
|
||||
readByKey,
|
||||
routeStateKeys,
|
||||
stateKey,
|
||||
type StateName,
|
||||
} from "../b_page_state";
|
||||
import { currentRoute, setLeaveGuard } from "../router";
|
||||
|
||||
/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */
|
||||
const NOT_EDITS = new Set<StateName>([
|
||||
"selection",
|
||||
"pipe-ids",
|
||||
"structure-pick",
|
||||
"structure-type-cache",
|
||||
"std-cross",
|
||||
]);
|
||||
const OTHER_PAGE = /^b0[89]-/;
|
||||
|
||||
export function editDraftNames(): StateName[] {
|
||||
return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name));
|
||||
}
|
||||
|
||||
const filled = (raw: string | null): raw is string =>
|
||||
raw !== null && raw !== "" && raw !== "{}" && raw !== "[]";
|
||||
|
||||
/** 고침 칸 지문 — 모든 노선 것 · 빈 칸 뺌 · 고침이 없으면 "". */
|
||||
export function editSignature(projectId: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const name of editDraftNames()) {
|
||||
const own = stateKey(name, projectId);
|
||||
const keys = own ? [own] : routeStateKeys(name, projectId);
|
||||
for (const key of keys.sort()) {
|
||||
const raw = readByKey(key);
|
||||
if (filled(raw)) parts.push(`${key}=${raw}`);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** 긴 지문을 들고 있지 않게 — FNV-1a 32비트 + 길이. */
|
||||
function digest(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash ^= text.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return `${text.length}:${hash >>> 0}`;
|
||||
}
|
||||
|
||||
// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음.
|
||||
const cleanMarks = new Map<string, string>();
|
||||
|
||||
/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */
|
||||
export function markDesignClean(projectId: string): void {
|
||||
if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId)));
|
||||
}
|
||||
|
||||
/** 저장 안 한 조작이 있나. */
|
||||
export function designDirty(projectId: string): boolean {
|
||||
if (!projectId) return false;
|
||||
const signature = editSignature(projectId);
|
||||
if (!signature) return false;
|
||||
return cleanMarks.get(projectId) !== digest(signature);
|
||||
}
|
||||
|
||||
/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */
|
||||
export function discardDesignEdits(projectId: string): void {
|
||||
editDraftNames().forEach((name) => clearState(name, projectId));
|
||||
}
|
||||
|
||||
const SAME_PAGE = new Set<string>([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]);
|
||||
|
||||
/**
|
||||
* 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂.
|
||||
* `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`).
|
||||
*/
|
||||
export function guardDesignLeave(
|
||||
projectId: string,
|
||||
root: HTMLElement,
|
||||
save: () => Promise<unknown>,
|
||||
): void {
|
||||
const unload = (event: BeforeUnloadEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("beforeunload", unload);
|
||||
if (!designDirty(projectId)) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", unload);
|
||||
const guard = async (): Promise<boolean> => {
|
||||
if (!root.isConnected) return true;
|
||||
if (SAME_PAGE.has(currentRoute())) {
|
||||
// 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺.
|
||||
window.setTimeout(() => root.isConnected && setLeaveGuard(guard), 0);
|
||||
return true;
|
||||
}
|
||||
if (!designDirty(projectId)) return true;
|
||||
const got = await leaveOrStay(L("B05_Design_Leave"), {
|
||||
save,
|
||||
stillDirty: () => designDirty(projectId),
|
||||
discard: () => discardDesignEdits(projectId),
|
||||
});
|
||||
return got !== "stay";
|
||||
};
|
||||
setLeaveGuard(guard);
|
||||
}
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
} from "../b_workflow_nav";
|
||||
import { currentRoute, navigateTo, type MountedPage } from "../router";
|
||||
import type { DesignView } from "./design_dock";
|
||||
import type { DesignPageShared } from "./design_shared";
|
||||
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
|
||||
import { guardDesignLeave } from "./design_leave";
|
||||
import { usePipeIds } from "./design_pipe_ids";
|
||||
import "./design_page.css";
|
||||
|
||||
@@ -138,6 +139,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
revealLeft: () => layout?.setOptionsOpen(true),
|
||||
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
|
||||
};
|
||||
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
|
||||
|
||||
/** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */
|
||||
const applyView = (view: DesignView): void => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { createDesignPanel, type DesignPanel } from "./design_panel";
|
||||
import { createDesignProfile, type DesignProfile } from "./design_profile";
|
||||
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
|
||||
import { saveDesign, type DesignSaveStep } from "./design_save";
|
||||
import { markDesignClean } from "./design_leave";
|
||||
import { resetDesign } from "./design_reset";
|
||||
import type { DesignView } from "./design_dock";
|
||||
|
||||
@@ -313,6 +314,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
|
||||
else await profileSave?.cross?.(isConfirm);
|
||||
},
|
||||
done: async (isConfirm) => {
|
||||
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
|
||||
if (isConfirm) {
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
structureAnchorM,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor";
|
||||
import {
|
||||
profileSaveParts,
|
||||
type PageActionContext,
|
||||
@@ -135,7 +134,7 @@ export async function mountProfileView(
|
||||
{
|
||||
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
|
||||
// (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다.
|
||||
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
|
||||
onCrossDesignsUpdated: () => sections.setCorridorPending(true),
|
||||
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의
|
||||
// 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫).
|
||||
onPipesChanged: (pipes) => {
|
||||
@@ -188,6 +187,7 @@ export async function mountProfileView(
|
||||
roadWidths: DEFAULT_ROAD_WIDTHS,
|
||||
detail: null,
|
||||
corridorVisible: true,
|
||||
corridorPending: false,
|
||||
routeReady: false,
|
||||
restoring: true,
|
||||
};
|
||||
@@ -404,10 +404,6 @@ export async function mountProfileView(
|
||||
}
|
||||
profilePick.paint();
|
||||
},
|
||||
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
onHide: () => {
|
||||
if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id);
|
||||
},
|
||||
});
|
||||
|
||||
/** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { refreshCorridor, saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor";
|
||||
import { refreshCorridor } from "../../B05_Profile/B05_Profile_UI_Corridor";
|
||||
import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
||||
import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
|
||||
import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View";
|
||||
@@ -48,27 +48,36 @@ export function createProfile3d(deps: Profile3dDeps) {
|
||||
onResetView: () => viewer.setView("top"),
|
||||
onProjection: viewer.setProjection,
|
||||
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
|
||||
onCorridorRefresh: async () => {
|
||||
const detail = state.detail;
|
||||
const latest = state.latest;
|
||||
if (!detail || !latest?.route?.id) return;
|
||||
const routePoints = latest.route_points ?? [];
|
||||
if (routePoints.length <= 1) return;
|
||||
await refreshCorridor(
|
||||
viewer,
|
||||
projectId,
|
||||
latest.route.id,
|
||||
detail,
|
||||
routePoints,
|
||||
deps.profilePanel().alignmentSamples() ?? undefined,
|
||||
);
|
||||
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
||||
deps.sections().renderStationLines(detail);
|
||||
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
|
||||
void saveCorridorIfDirty(projectId, latest.route.id);
|
||||
},
|
||||
onCorridorRefresh: () => refreshOnce(),
|
||||
};
|
||||
|
||||
/** 짓는 중이면 그 결과를 같이 기다린다 — 단추 · 모달 열기가 겹쳐도 한 번만 짓는다. */
|
||||
let refreshing: Promise<void> | null = null;
|
||||
function refreshOnce(): Promise<void> {
|
||||
refreshing ??= refreshCorridorNow().finally(() => (refreshing = null));
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
/** 낡은 3D 길 모형을 지금 노선 · 계획선으로 — 저장본이 맞으면 받기만 · 아니면 워커 미리보기. */
|
||||
async function refreshCorridorNow(): Promise<void> {
|
||||
const detail = state.detail;
|
||||
const latest = state.latest;
|
||||
if (!detail || !latest?.route?.id) return;
|
||||
const routePoints = latest.route_points ?? [];
|
||||
if (routePoints.length <= 1) return;
|
||||
await refreshCorridor(
|
||||
viewer,
|
||||
projectId,
|
||||
latest.route.id,
|
||||
detail,
|
||||
routePoints,
|
||||
deps.profilePanel().alignmentSamples() ?? undefined,
|
||||
);
|
||||
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
||||
deps.sections().renderStationLines(detail);
|
||||
deps.sections().setCorridorPending(false);
|
||||
}
|
||||
|
||||
async function applyContours(interval: number): Promise<void> {
|
||||
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
|
||||
try {
|
||||
@@ -122,6 +131,10 @@ export function createProfile3d(deps: Profile3dDeps) {
|
||||
viewer,
|
||||
loadTerrain,
|
||||
visible: () => deps.host.visible(),
|
||||
// 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다.
|
||||
onOpen: () => {
|
||||
if (state.corridorPending) void refreshOnce();
|
||||
},
|
||||
});
|
||||
|
||||
return { viewCallbacks, applyContours, modal };
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Viewer3dModalDeps {
|
||||
loadTerrain: () => Promise<void>;
|
||||
/** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */
|
||||
visible: () => boolean;
|
||||
/** 열 때마다(지형이 선 뒤) — 낡은 3D 길 모형 다시 짓기(345-4). */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export function createViewer3dModal(deps: Viewer3dModalDeps) {
|
||||
@@ -54,11 +56,13 @@ export function createViewer3dModal(deps: Viewer3dModalDeps) {
|
||||
},
|
||||
});
|
||||
viewer.setActive(true);
|
||||
if (terrain) return;
|
||||
const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true });
|
||||
progress.set(null);
|
||||
viewer.root.append(progress.root);
|
||||
terrain = deps.loadTerrain().finally(() => progress.remove());
|
||||
if (!terrain) {
|
||||
const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true });
|
||||
progress.set(null);
|
||||
viewer.root.append(progress.root);
|
||||
terrain = deps.loadTerrain().finally(() => progress.remove());
|
||||
}
|
||||
void terrain.then(() => modal && deps.onOpen?.()); // 그 사이 닫았으면 안 지음
|
||||
}
|
||||
|
||||
/** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface ProfileViewState {
|
||||
detail: SectionDetailResponse | null;
|
||||
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
|
||||
corridorVisible: boolean;
|
||||
/** 3D 길 모형이 지금 정본보다 낡았나 — [3D 업데이트] 표시와 한 값 · 모달을 열 때 다시 짓는 기준(345-4). */
|
||||
corridorPending: boolean;
|
||||
routeReady: boolean;
|
||||
restoring: boolean;
|
||||
}
|
||||
@@ -92,7 +94,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
|
||||
detail,
|
||||
routePoints,
|
||||
profilePanel.alignmentSamples() ?? undefined,
|
||||
).then((fresh) => panel.setCorridorPending(!fresh));
|
||||
).then((fresh) => setCorridorPending(!fresh));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +154,12 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */
|
||||
function setCorridorPending(pending: boolean): void {
|
||||
state.corridorPending = pending;
|
||||
deps.panel().setCorridorPending(pending);
|
||||
}
|
||||
|
||||
function renderLatest(next: RouteLatestResponse): void {
|
||||
state.latest = next;
|
||||
state.routeReady = Boolean(next.route && next.route_points.length > 1);
|
||||
@@ -175,5 +183,6 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
|
||||
renderSections,
|
||||
restoreSections,
|
||||
renderLatest,
|
||||
setCorridorPending,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user