Merge remote-tracking branch 'origin/안티그래비티' into 안티그래비티
This commit is contained in:
@@ -326,14 +326,23 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심
|
||||
/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다.
|
||||
*
|
||||
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다.
|
||||
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */
|
||||
export async function fetchCachedSheetLayer<T>(projectId: string, layer: string): Promise<T> {
|
||||
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다.
|
||||
* `scope` = 그 화면이 보는 범위만 자른 사본(route = 노선 띠 · drainage = 유역 + 여유 · 509-1 · 509-6)
|
||||
* — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다. */
|
||||
export async function fetchCachedSheetLayer<T>(
|
||||
projectId: string,
|
||||
layer: string,
|
||||
scope?: SheetScope,
|
||||
): Promise<T> {
|
||||
const range = scope ? `&scope=${scope}` : "";
|
||||
return fetchCachedJson<T>(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`,
|
||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${range}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type SheetScope = "route" | "drainage";
|
||||
|
||||
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
|
||||
* 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
|
||||
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시).
|
||||
@@ -439,7 +448,11 @@ export async function preloadSurfaceAssets(
|
||||
// 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다.
|
||||
report("배경 지도를 준비하는 중…", null);
|
||||
await Promise.all(
|
||||
DRAINAGE_SHEET_LAYERS.map((layer) => fetchCachedSheetLayer(projectId, layer).catch(() => null)),
|
||||
DRAINAGE_SHEET_LAYERS.flatMap((layer) =>
|
||||
(["route", "drainage"] as const).map((scope) =>
|
||||
fetchCachedSheetLayer(projectId, layer, scope).catch(() => null),
|
||||
),
|
||||
),
|
||||
);
|
||||
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
|
||||
await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, {
|
||||
|
||||
@@ -72,8 +72,6 @@ export const STATE_REGISTRY = {
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
"routeedit-locks": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */
|
||||
"routeedit-terrain": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 측점 스냅 간격(10 · station · 280-4) */
|
||||
"routeedit-snap-step": { bucket: "pref", scope: "project" },
|
||||
"masshaul-open": {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* design_calc_sites.ts
|
||||
* 계산 자리 판(PLAN 504-2 · 요구 309-2) — B05 · B06 페이지 열 때 한 번 받아 페이지 수명 동안 씀.
|
||||
* 판 = 서버 `resources/master_template/calc_sites.json`(`common_util_calc_sites.py`) · 쓰기 = 시스템 관리자
|
||||
* 「계산 자리」 창(`B01_Dashboard_UI_CalcSites.ts`). 열린 페이지는 다음 열기부터 · 못 받으면 전부 both.
|
||||
* ground_sample(G1) server = ground 단계가 격자 대신 서버 표본(`B05_Profile_Api_TerrainGrid.ts`)
|
||||
* corridor_mesh(D3) server = 3D 미리보기 워커 안 띄움 · 서버 모형만(`B05_Profile_UI_Corridor_Worker_Client.ts`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
export type CalcSiteValue = "both" | "server";
|
||||
|
||||
export interface CalcSiteGroup {
|
||||
key: string;
|
||||
nos: string[];
|
||||
switchable: boolean;
|
||||
fixed: "both" | "server" | "browser" | null;
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
export interface CalcSitesBoard {
|
||||
version: number;
|
||||
sites: Record<string, CalcSiteValue>;
|
||||
groups: CalcSiteGroup[];
|
||||
}
|
||||
|
||||
async function request(init: RequestInit = {}): Promise<CalcSitesBoard> {
|
||||
const path = init.method === "PUT" ? "/dashboard/admin/calc-sites" : "/dashboard/calc-sites";
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const data = (await response.json()) as CalcSitesBoard & { detail?: string };
|
||||
if (!response.ok) throw new Error(data.detail ?? `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const fetchCalcSites = (): Promise<CalcSitesBoard> => request();
|
||||
|
||||
export const saveCalcSites = (sites: Record<string, CalcSiteValue>): Promise<CalcSitesBoard> =>
|
||||
request({ method: "PUT", body: JSON.stringify({ sites }) });
|
||||
|
||||
let opened: Promise<Record<string, CalcSiteValue>> | null = null;
|
||||
|
||||
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */
|
||||
export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
|
||||
opened = fetchCalcSites().then(
|
||||
(board) => board.sites,
|
||||
() => ({}),
|
||||
);
|
||||
return opened;
|
||||
}
|
||||
|
||||
/** 이 묶음을 조작 중에도 서버가 셈? — 받기 전이면 받기를 기다림. */
|
||||
export async function calcOnServer(key: string): Promise<boolean> {
|
||||
return (await (opened ?? openCalcSites()))[key] === "server";
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* design_dock.ts
|
||||
* B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b).
|
||||
*
|
||||
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 셋은 두 보기가 **같다**:
|
||||
* [초기화] [저장] [확정](보기 오가기는 공용 [<] [>] · 요구 280-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
|
||||
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 둘은 두 보기가 **같다**:
|
||||
* [저장] [초기화](앞은 공용 [?] [<] [>] · B06 [>] = 확정 뒤 B07 · 요구 280-2 · 298-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
|
||||
* 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -24,12 +24,11 @@ export interface DesignDockAction {
|
||||
export interface DesignDockActions {
|
||||
reset: DesignDockAction;
|
||||
save: DesignDockAction;
|
||||
confirm: DesignDockAction;
|
||||
}
|
||||
|
||||
export interface DesignDock {
|
||||
root: HTMLElement;
|
||||
/** 단추 셋의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
|
||||
/** 단추 둘의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
|
||||
setActions: (actions: Partial<DesignDockActions>) => void;
|
||||
/** 보기가 바뀌면 단추 줄 class 가 따라 바뀐다. */
|
||||
setView: (view: DesignView) => void;
|
||||
@@ -72,7 +71,7 @@ export function createDesignDock(
|
||||
};
|
||||
const resetButton = createButton({
|
||||
label: label("Common_Btn_Reset"),
|
||||
variant: "danger",
|
||||
variant: "danger-ghost",
|
||||
onClick: press("reset"),
|
||||
});
|
||||
const saveButton = createButton({
|
||||
@@ -80,18 +79,11 @@ export function createDesignDock(
|
||||
variant: "ghost",
|
||||
onClick: press("save"),
|
||||
});
|
||||
const confirmButton = createButton({
|
||||
label: label("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: press("confirm"),
|
||||
});
|
||||
resetButton.dataset.dockAction = "reset";
|
||||
saveButton.dataset.dockAction = "save";
|
||||
confirmButton.dataset.dockAction = "confirm";
|
||||
const buttons = {
|
||||
reset: resetButton,
|
||||
save: saveButton,
|
||||
confirm: confirmButton,
|
||||
};
|
||||
|
||||
const sync = (): void => {
|
||||
@@ -104,7 +96,7 @@ export function createDesignDock(
|
||||
row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
};
|
||||
|
||||
row.append(resetButton, saveButton, confirmButton);
|
||||
row.append(saveButton, resetButton);
|
||||
root.append(...above, divider, row, busy);
|
||||
sync();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||||
import {
|
||||
clearState,
|
||||
namesInBucket,
|
||||
@@ -69,6 +70,11 @@ function digest(text: string): string {
|
||||
// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음.
|
||||
const cleanMarks = new Map<string, string>();
|
||||
|
||||
/** 지금 고침 지문(짧게) — 저장 본문을 낸 때와 끝난 때를 견줌(498-3). */
|
||||
export function designSignature(projectId: string): string {
|
||||
return digest(editSignature(projectId));
|
||||
}
|
||||
|
||||
/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */
|
||||
export function markDesignClean(projectId: string): void {
|
||||
if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId)));
|
||||
@@ -99,12 +105,11 @@ export function guardDesignLeave(
|
||||
save: () => Promise<unknown>,
|
||||
): void {
|
||||
const unload = (event: BeforeUnloadEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("beforeunload", unload);
|
||||
if (!designDirty(projectId)) return;
|
||||
if (!root.isConnected || !designDirty(projectId)) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", unload);
|
||||
listenWhileAlive(window, "beforeunload", root, unload); // 다시 세운 옛 페이지를 안 붙듦(298-3)
|
||||
const guard = async (): Promise<boolean> => {
|
||||
if (!root.isConnected) return true;
|
||||
if (SAME_PAGE.has(currentRoute())) {
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 횡단 보기 평면 겹침 기준 = main 전체(위 칸 + 종단 판) — 종단 판 크기와 무관 · 넘침 없음(499-1) */
|
||||
.design-page__main[data-view="cross"] > .design-page__center {
|
||||
position: static;
|
||||
}
|
||||
|
||||
/* 새 창으로 뺀 종단 하단 패널(계획서 54-35) — 창 전체를 채움 · 접기 손잡이 · 높이 끌기는 감춤. */
|
||||
.b05-route-profile.is-popout {
|
||||
position: relative;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout";
|
||||
import { workflowSteps } from "../b_page_scaffold";
|
||||
import {
|
||||
clearWorkflowStateCache,
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
@@ -28,12 +29,18 @@ import {
|
||||
} from "../b_workflow_nav";
|
||||
import { currentRoute, navigateTo, type MountedPage } from "../router";
|
||||
import type { DesignView } from "./design_dock";
|
||||
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
|
||||
import {
|
||||
confirmAndGo,
|
||||
saveSharedDesign,
|
||||
scrollToPick,
|
||||
type DesignPageShared,
|
||||
} from "./design_shared";
|
||||
import { guardDesignLeave } from "./design_leave";
|
||||
import { usePipeIds } from "./design_pipe_ids";
|
||||
import { createFacilityLane } from "./design_facility_lane";
|
||||
import { pinDraftProject } from "./design_store";
|
||||
import { pipelineDetail, reconcilePagePipeline } from "./design_pipeline_page";
|
||||
import { openCalcSites } from "./design_calc_sites";
|
||||
import { forgetPageData, onPageBusy } from "./design_page_data";
|
||||
import { pageRouteId } from "./design_page_route";
|
||||
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
|
||||
@@ -113,6 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
root.replaceChildren(page);
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
|
||||
void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
|
||||
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
|
||||
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) ·
|
||||
// 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428)
|
||||
@@ -150,8 +158,13 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
structures: null,
|
||||
// 시설 줄 한 벌(462) — 보기가 숨어도 · 아직 안 섰어도 듣고 셈 · 종단도는 서면 받음
|
||||
lane: createFacilityLane(projectId, () => shared.profile),
|
||||
revealLeft: () => layout?.setOptionsOpen(true),
|
||||
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
|
||||
refreshStages: () => {
|
||||
clearWorkflowStateCache();
|
||||
void fetchWorkflowState(projectId)
|
||||
.then((state) => layout?.setStages(state.stages))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
};
|
||||
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
|
||||
let busy = false; // 「계산 중」 점 — 좌측 패널이 늦게 서도 지금 상태로(386-2)
|
||||
@@ -177,12 +190,15 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
leftPanel: left,
|
||||
mainContent: main,
|
||||
stages: state?.stages,
|
||||
currentStage: state?.current_stage,
|
||||
// 종단 · 횡단 · 상세설계 칸은 늘 켬 — B07 로 가는 길이 곧 확정(496-1 · 운영 잠금 흠)
|
||||
currentStage: [2, 3, 4],
|
||||
onOptionsOpenChange: (isOpen) => isOpen && scrollToPick(shared.structures), // 499-1
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
// 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다.
|
||||
// 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다. B07~B09 = 확정 뒤 이동(496-1).
|
||||
onStepClick: (stepIndex) => {
|
||||
const route = WORKFLOW_STEP_ROUTES[stepIndex];
|
||||
if (designViewOf(route)) navigateTo(route);
|
||||
else if (stepIndex >= 4) confirmAndGo(shared, route);
|
||||
else goToWorkflowStage(projectId, route);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,12 +69,17 @@ const notify = (projectId: string): void => {
|
||||
busyListeners.get(projectId)?.forEach((fn) => fn(now));
|
||||
};
|
||||
|
||||
/** 묶음 차례가 떠 있나 — 그 사이 폼이 다시 실려도 좌측 패널은 안 폄(`design_user_pick` · 398-1). */
|
||||
export const pageBusy = (projectId: string): boolean => (busy.get(projectId) ?? 0) > 0;
|
||||
const launchedBy = new Map<string, Event>();
|
||||
|
||||
/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */
|
||||
/** 이 누름이 묶음 차례를 띄웠나 — 그 누름 안 폼 다시 실음은 좌측 패널 안 폄(`design_user_pick` · 398-1 · 505-2). */
|
||||
export const pageLaunchedBy = (projectId: string, event: Event): boolean =>
|
||||
launchedBy.get(projectId) === event;
|
||||
|
||||
/** 묶음 차례 하나가 뜸 — 띄운 누름을 적음 · 끝나면(밀림 · 실패 포함) 내림. */
|
||||
export function trackPageBusy(projectId: string, run: Promise<unknown> | null): void {
|
||||
if (!run) return;
|
||||
const event = (globalThis as { event?: Event }).event;
|
||||
if (event) launchedBy.set(projectId, event);
|
||||
busy.set(projectId, (busy.get(projectId) ?? 0) + 1);
|
||||
notify(projectId);
|
||||
const done = (): void => {
|
||||
|
||||
@@ -22,13 +22,20 @@ import {
|
||||
effectiveStandardCross,
|
||||
readRockBoundaryDefault,
|
||||
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import type {
|
||||
CrossInput,
|
||||
SeatedFields,
|
||||
SectionSpecs,
|
||||
import {
|
||||
crossAtSeat,
|
||||
type CrossInput,
|
||||
type SeatedFields,
|
||||
type SectionSpecs,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import { SEAT_HINT } from "../../B06_Section/logic/B06_Section_Route_Carry";
|
||||
import type { PipelineStation } from "./logic/design_pipeline_types";
|
||||
import {
|
||||
SOURCE_INPUT,
|
||||
type ChangeSource,
|
||||
type PipelineInputs,
|
||||
type PipelineStation,
|
||||
} from "./logic/design_pipeline_types";
|
||||
import type { DesignPipeline } from "./logic/design_pipeline";
|
||||
import type { StationSeat } from "../../common_util/common_util_station_key";
|
||||
|
||||
export interface PipelineCrossSource {
|
||||
@@ -252,3 +259,20 @@ export function pipelineCrossSource(
|
||||
specs,
|
||||
};
|
||||
}
|
||||
|
||||
/** 맞춤이 다시 먹일 입력 — 지금 입력이 묶음 입력 · 넘긴 입력(follow 전) 둘 다와 다른 것. cross 는 지금 측점 자리로
|
||||
* 자리 칸을 가른 뒤 견줌(`crossAtSeat` — 노선 초안 넘김이 새 자리 자리 칸만 걷은 것 = 같은 값 · 508-1c). */
|
||||
export function staleInputs(
|
||||
now: PipelineInputs,
|
||||
given: Partial<PipelineInputs>,
|
||||
pipe: DesignPipeline,
|
||||
): Array<[ChangeSource, unknown]> {
|
||||
const stations = pipe.output("ground")?.stations ?? [];
|
||||
const seen = (key: keyof PipelineInputs, value: unknown): string =>
|
||||
JSON.stringify(key === "cross" ? crossAtSeat(value as CrossInput | null, stations) : value);
|
||||
return (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
|
||||
.filter(([, key]) =>
|
||||
[pipe.input(key), given[key]].every((was) => seen(key, was) !== seen(key, now[key])),
|
||||
)
|
||||
.map(([source, key]) => [source, now[key]]);
|
||||
}
|
||||
|
||||
@@ -61,15 +61,12 @@ import {
|
||||
savedPlusRows,
|
||||
} from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchRoutePlan,
|
||||
type RoutePlanResponse,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import { fetchRoutePlan, type RoutePlanResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
|
||||
import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
|
||||
import { createPageSaved } from "./design_pipeline_page_saved";
|
||||
import { staleInputs } from "./design_pipeline_cross";
|
||||
import {
|
||||
draftDetailOf,
|
||||
heldSectionDetail,
|
||||
@@ -375,8 +372,10 @@ function createPagePipeline(
|
||||
const finishDrop = async (record: PipelineRunRecord): Promise<string> => {
|
||||
// 뒤 차례(B06 cross 등)에 밀림 · 초안은 그대로 버려짐 — 마지막 차례 끝에서 되돌림(B05 와 같게)
|
||||
const pushed = record.outcome === "dropped" && record.generation !== pipe.generation;
|
||||
if (pushed && dropPending && !pipe.input("route"))
|
||||
return (lastRun as Promise<PipelineRunRecord>).then(finishDrop);
|
||||
if (pushed && dropPending && !pipe.input("route")) {
|
||||
const next = await (lastRun as Promise<PipelineRunRecord>);
|
||||
if (next !== record) return finishDrop(next); // 같은 기록 = 끝난 차례 · 되받으면 끝없는 고리(298-3)
|
||||
}
|
||||
dropPending = false;
|
||||
const routeId = pageSaved.canonRouteId();
|
||||
const stations = pipe.output("ground")?.stations ?? [];
|
||||
@@ -561,10 +560,7 @@ function createPagePipeline(
|
||||
await pipe.settled();
|
||||
if (disposed || pageSaved.canonRouteId() !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
|
||||
if (facilities.saving()) return (facilities.afterSave(), round); // 저장 도중 = 옛 저장분 · 끝나면 다시
|
||||
const now = inputsNow();
|
||||
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
|
||||
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
|
||||
.map(([source, key]): [ChangeSource, unknown] => [source, now[key]]);
|
||||
const stale = staleInputs(inputsNow(), given, pipe); // cross = 지금 측점 자리로 견줌(508-1c)
|
||||
if (!stale.length) return round;
|
||||
if (round >= RECONCILE_ROUNDS) {
|
||||
options.onUnstable?.();
|
||||
@@ -637,7 +633,7 @@ function createPagePipeline(
|
||||
const wasDraft = pipe.input("route") !== null;
|
||||
let draft: RouteDraft | null = null;
|
||||
if (edit) {
|
||||
draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: analysisTerrain(projectId) };
|
||||
draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: null }; // 설계 지형(509-5)
|
||||
if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored;
|
||||
}
|
||||
if (draft && !wasDraft && !held) held = holdNow();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* `crossWithUphill` · `stationSection`)를 그 자리 측점 하나로 다시 부름 — [측점 추가] 뒤 묶음이 낼 측점과
|
||||
* 같은 틀 · 반경 · 확폭(기존 측점 + 그 자리로 셈). 파생 저장 · 캐시 · 묶음 상태는 안 건드림.
|
||||
* 지형 격자가 없거나 분석 지형 ≠ 설계 지형이면 null(부르는 쪽이 서버 미리 뜨기로).
|
||||
* 단면(`section`) = 그 측점에 지금 현황 형상 · 이름을 얹은 것 — 창이 「횡단」 판과 같은 B06 카드로 그림(505-3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pagePipeline } from "./design_pipeline_page";
|
||||
@@ -19,27 +20,31 @@ import { probeStation } from "./logic/design_pipeline_probe_station";
|
||||
import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||
import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||
import {
|
||||
attachStatus,
|
||||
crossWithShapes,
|
||||
crossAtSeat,
|
||||
crossWithUphill,
|
||||
shapeStatus,
|
||||
stationSection,
|
||||
type CrossInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
|
||||
import { pipelineCrossAt } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Cross";
|
||||
import type { CrossPreviewResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
const SAME_M = 1e-6;
|
||||
|
||||
/**
|
||||
* 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단 — 묶음이 아직 안 셌거나 격자 없음 · 분석 지형 ≠ 설계 지형이면 null.
|
||||
* 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단(선 그림 재료 · 형상 얹은 단면) — 묶음이 아직 안 셌거나
|
||||
* 격자 없음 · 분석 지형 ≠ 설계 지형이면 null.
|
||||
*/
|
||||
export async function pipelineCrossAtChainage(
|
||||
projectId: string,
|
||||
chainageM: number,
|
||||
intervalM: number,
|
||||
): Promise<CrossPreviewResponse | null> {
|
||||
): Promise<{ preview: CrossPreviewResponse; section: CrossSection } | null> {
|
||||
const pipe = pagePipeline(projectId);
|
||||
const route = pipe?.output("route");
|
||||
const profile = pipe?.output("profile");
|
||||
@@ -105,5 +110,9 @@ export async function pipelineCrossAtChainage(
|
||||
output: (name: StageName) =>
|
||||
name === "ground" ? { stations: [station] } : name === "cross" ? cross : null,
|
||||
} as unknown as Parameters<typeof pipelineCrossAt>[0];
|
||||
return pipelineCrossAt(one, chainageM, intervalM, undefined);
|
||||
const preview = pipelineCrossAt(one, chainageM, intervalM, undefined);
|
||||
if (!preview) return null;
|
||||
const section = stationSection(station, cross, {}, intervalM);
|
||||
attachStatus([section], status);
|
||||
return { preview, section };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { clearResults, clearState, namesInBucket } from "./../b_page_state";
|
||||
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
import { designSaveBusy, SAVE_BUSY_TEXT } from "./design_save";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -32,7 +33,13 @@ export async function resetDesign(
|
||||
_routeId: number | null,
|
||||
onCleared?: () => void,
|
||||
): Promise<void> {
|
||||
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
|
||||
// 저장 중 = [저장]처럼 알림 + 막음(505-2) — 확인 창 동안 뒤 저장이 떴을 수 있어 뒤에도 봄
|
||||
const saveBusy = (): boolean => {
|
||||
if (designSaveBusy(projectId)) showToast(SAVE_BUSY_TEXT, "warning");
|
||||
return designSaveBusy(projectId);
|
||||
};
|
||||
if (saveBusy() || !(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
|
||||
if (saveBusy()) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await resetRouteDesign(projectId);
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { pipesPart } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { structuresPart } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { outsideNotice, plusStationsPart } from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import { plusStationsPart } from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import { routePartsBody } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import type { RouteReplanResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import {
|
||||
@@ -45,9 +45,9 @@ import {
|
||||
rebuildAfterRouteSave,
|
||||
routeCarryPart,
|
||||
routeSaved,
|
||||
routeSaveSummary,
|
||||
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply";
|
||||
import type { RouteSaveBody } from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import { designSignature, markDesignClean } from "./design_leave";
|
||||
|
||||
export interface DesignSaveContext {
|
||||
projectId: string;
|
||||
@@ -61,10 +61,13 @@ export interface DesignSaveContext {
|
||||
parts?: Partial<Record<SavePartKey, PartOverride>>;
|
||||
/** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */
|
||||
crossPart: (confirm: boolean) => Promise<SavePart | null>;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
|
||||
done?: (confirm: boolean) => Promise<void> | void;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. `reload` 거짓 = 알림만(노선 바뀐 저장 =
|
||||
* 곧 다시 세움 · 옛 노선 id 로 다시 읽기는 헛일 · 498-3). */
|
||||
done?: (confirm: boolean, reload?: boolean) => Promise<void> | void;
|
||||
/** 멈춘 저장을 알린다. */
|
||||
failed: (error: unknown, confirm: boolean) => void;
|
||||
/** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */
|
||||
routeMoved?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type PartOverride = (
|
||||
@@ -73,6 +76,47 @@ export type PartOverride = (
|
||||
) => Promise<SavePart | null> | SavePart | null;
|
||||
|
||||
const saving = new Set<string>();
|
||||
/** 프로젝트마다 지금 도는 저장 한 번(498-3 D1) — 확정인가 · 끝 약속. */
|
||||
const inflight = new Map<string, { confirm: boolean; done: Promise<unknown> }>();
|
||||
/** 저장 중 막음 알림 — [저장] · [초기화] 같은 글(505-2). */
|
||||
export const SAVE_BUSY_TEXT = "이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요.";
|
||||
|
||||
/** 저장이 도는 중인가(498-3) — 뒤에서 도는 [저장] 포함. */
|
||||
export function designSaveBusy(projectId: string): boolean {
|
||||
return inflight.has(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 한 번을 줄 세움(요구 302 D1) — 저장 중 [저장] = 막음 + 알림 · 저장 중 확정 = 잠그고 앞 저장 끝을 기다린 뒤
|
||||
* 지금 화면으로(서버 한 본문 저장은 도중에 안 끊음) · 확정 중 또 확정 = 막음. 막으면 false.
|
||||
*/
|
||||
export async function queueDesignSave<T>(
|
||||
projectId: string,
|
||||
confirm: boolean,
|
||||
job: () => Promise<T>,
|
||||
): Promise<T | false> {
|
||||
const before = inflight.get(projectId);
|
||||
if (before && (!confirm || before.confirm)) {
|
||||
showToast(SAVE_BUSY_TEXT, "warning");
|
||||
return false;
|
||||
}
|
||||
if (before) showLoadingOverlay();
|
||||
const run = (async () => {
|
||||
try {
|
||||
await before?.done.catch(() => undefined);
|
||||
return await job();
|
||||
} finally {
|
||||
if (before) hideLoadingOverlay();
|
||||
}
|
||||
})();
|
||||
const entry = { confirm, done: run };
|
||||
inflight.set(projectId, entry);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (inflight.get(projectId) === entry) inflight.delete(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
|
||||
export function savingDesign(projectId: string | null): boolean {
|
||||
@@ -164,21 +208,22 @@ function oneBodyError(error: unknown): Error {
|
||||
return new Error(`${where}실패 — 아무것도 저장하지 않았습니다(모두 그대로). ${error.message}`);
|
||||
}
|
||||
|
||||
/** 노선 저장 결과 알림 — 노선 밖 관 · 구조물 · 따라감 실패 · 결과 한 줄(관 지킴 · 노선 밖 고침 …). */
|
||||
function noticeRouteSaved(saved: RouteReplanResponse, carryOutside: number): void {
|
||||
const outside = (saved.pipes?.outside ?? 0) + (saved.structures?.outside ?? 0);
|
||||
if (outside) showToast(outsideNotice(outside), "warning");
|
||||
/** 노선 저장 결과 알림 — 따라감 실패 · 화면에 없던 새 교차 관만(요구 298-3 · 결과 한 줄 · 노선 밖 되풀이 지움). */
|
||||
function noticeRouteSaved(saved: RouteReplanResponse): void {
|
||||
if (saved.follow_failed?.length)
|
||||
showToast(
|
||||
`${saved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
|
||||
"error",
|
||||
);
|
||||
showToast(routeSaveSummary(saved, carryOutside), "info");
|
||||
const crossing = saved.pipes?.crossing ?? 0;
|
||||
if (crossing) showToast(`새 교차 관 ${crossing}개 — 새 세류선 교차점에 더했습니다.`, "info");
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] — 세션에 쌓인 조작을 서버 한 창구에 한 번(472-6). 노선이 없으면 아무 일도 하지 않고 false ·
|
||||
* 다 저장하면 true. 서버가 다 저장한 뒤에만 조각 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로.
|
||||
* 잠금(요구 302 D1) = 확정 · 노선 모양 바뀐 [저장]만 — 그 밖 [저장]은 뒤에서 · 저장 도중 고쳤으면 조각 정리 ·
|
||||
* 깨끗한 표를 건너뜀(초안이 그대로 남아 다음 [저장] · [>]에서 다시 나감 · 화면 다시 읽기는 함).
|
||||
*/
|
||||
export async function saveDesign(
|
||||
ctx: DesignSaveContext,
|
||||
@@ -187,10 +232,15 @@ export async function saveDesign(
|
||||
const confirm = options.confirm ?? false;
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return false;
|
||||
showLoadingOverlay();
|
||||
saving.add(ctx.projectId);
|
||||
let locked = confirm;
|
||||
if (locked) showLoadingOverlay();
|
||||
try {
|
||||
const route: RouteSaveBody | null = await pendingRouteSave(ctx.projectId);
|
||||
if (route && !locked) {
|
||||
showLoadingOverlay(); // 노선 모양 바뀐 [저장] = 잠금 · 다시 세움(D1)
|
||||
locked = true;
|
||||
}
|
||||
await ctx.prepare?.();
|
||||
const parts: Partial<Record<SavePartKey, SavePart | null>> = {};
|
||||
// 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀
|
||||
@@ -204,6 +254,7 @@ export async function saveDesign(
|
||||
const own = route ? undefined : ctx.parts?.[key];
|
||||
parts[key] = own ? await own(base, confirm) : base;
|
||||
}
|
||||
const sent = designSignature(ctx.projectId); // 본문을 낸 때 고침 — 끝에 견줌(498-3)
|
||||
const result = await postDesignSave(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
@@ -212,19 +263,33 @@ export async function saveDesign(
|
||||
route ? { route, carry: carried?.carry } : {},
|
||||
);
|
||||
if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info");
|
||||
// 저장 도중 고침(가림막 동안 키 · 되돌리기 포함 · 505-2) — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸
|
||||
// 본문을 저장함) · 노선 저장은 다시 세움이 맡음
|
||||
// ponytail: 고침 하나라도 있으면 조각 정리를 통째 건너뜀 · 다음 저장이 초안 전부를 다시 냄
|
||||
const touched = !route && designSignature(ctx.projectId) !== sent;
|
||||
if (touched && locked)
|
||||
showToast("저장 중 고친 값은 아직 저장 안 됨 — 다시 [저장]해 주세요.", "warning");
|
||||
if (route) routeSaved(ctx.projectId); // 가게 노선 초안 · 조회 결과 캐시 · 편집기 「저장됨」
|
||||
for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]);
|
||||
if (!touched) for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]);
|
||||
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
|
||||
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
|
||||
if (!route) {
|
||||
if (!touched) markDesignClean(ctx.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
}
|
||||
const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse;
|
||||
noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0));
|
||||
// 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움.
|
||||
if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info");
|
||||
else await ctx.done?.(false);
|
||||
noticeRouteSaved(saved);
|
||||
// 새 노선 — 확정은 새 횡단 본문이 있어야 함 → 물음(머묾 / 새 노선으로 확정 뒤 이동) · 페이지 다시 세움.
|
||||
if (confirm) {
|
||||
hideLoadingOverlay(); // 물음 동안 가림막을 걷음
|
||||
await ctx.routeMoved?.();
|
||||
showLoadingOverlay();
|
||||
} else {
|
||||
// 옛 페이지 두 보기 다시 읽기는 헛일(곧 다시 세움 · 옛 노선 id) — 떠남 표 · 알림만(298-3)
|
||||
markDesignClean(ctx.projectId);
|
||||
await ctx.done?.(false, false);
|
||||
}
|
||||
await rebuildAfterRouteSave(ctx.projectId);
|
||||
// 옛 노선 칸 · 초안 전 hold = 서버가 새 노선에 넘겼음 — 쓰레기만 비움(넘김 규칙 그대로 · 세션 넘침 388-3)
|
||||
if (saved.route_id !== routeId) clearRouteDrafts(ctx.projectId, routeId);
|
||||
@@ -235,6 +300,6 @@ export async function saveDesign(
|
||||
return false;
|
||||
} finally {
|
||||
saving.delete(ctx.projectId);
|
||||
hideLoadingOverlay();
|
||||
if (locked) hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,20 @@
|
||||
* `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도
|
||||
* 받는다 — 다시 보일 때 같은 값을 들고 있게.
|
||||
* 시설 줄(알약 · 비정규 측점 · 관 id 장부)은 페이지 한 벌(`design_facility_lane`) — 관 알림 · 노선 따라감을 여기서 넘김.
|
||||
* [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의
|
||||
* [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
|
||||
* [저장] · 확정(B06 [>] · 단계 판 B07~B09 · 496-1)은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로
|
||||
* 보낸다. 종단 보기의 확정은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { RoutePath } from "@config/config_frontend";
|
||||
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { splitSidebarActions } from "@ui/ui_template_overlay";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
|
||||
import {
|
||||
clearWorkflowStateCache,
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
} from "../b_workflow_nav";
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
import { currentRoute, remountRoute } from "../router";
|
||||
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
@@ -26,9 +32,10 @@ import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"
|
||||
import { createDesignPanel, type DesignPanel } from "./design_panel";
|
||||
import { createDesignProfile, type DesignProfile } from "./design_profile";
|
||||
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
|
||||
import { saveDesign, type PartOverride } from "./design_save";
|
||||
import { designSaveBusy, queueDesignSave, saveDesign, type PartOverride } from "./design_save";
|
||||
import type { SavePart, SavePartKey } from "./design_save_api";
|
||||
import { markDesignClean } from "./design_leave";
|
||||
import { designDirty } from "./design_leave";
|
||||
import { pendingRouteSave } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply";
|
||||
import { resetDesign } from "./design_reset";
|
||||
import type { DesignView } from "./design_dock";
|
||||
import type { FacilityLane } from "./design_facility_lane";
|
||||
@@ -112,10 +119,12 @@ export interface DesignPageShared {
|
||||
lastPipes?: Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
|
||||
/** 시설 줄 한 벌(462) — 알약 · 비정규 측점 · 흙덮기 관 · 관 id 장부 · 평면 마커 원천. 보기는 읽기만. */
|
||||
lane: FacilityLane;
|
||||
/** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */
|
||||
revealLeft: () => void;
|
||||
/** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */
|
||||
ensureView: (view: DesignView) => Promise<void>;
|
||||
/** 자료가 섰다(`designReady`) — 그 전 확정 이동은 막는다. */
|
||||
ready?: boolean;
|
||||
/** [저장] 뒤 진행 단계 판 색을 서버 단계 상태로 다시 맞춘다(496-1). */
|
||||
refreshStages?: () => void;
|
||||
}
|
||||
|
||||
const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {};
|
||||
@@ -313,7 +322,9 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM),
|
||||
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
|
||||
onListRelease: () => on().onListRelease?.(),
|
||||
onReveal: () => isUserPick(shared.projectId) && shared.revealLeft(), // 사용자 고름만(398-1)
|
||||
// 사용자 고름 = 「구조물 배치」 카드 폄 — 좌측 패널 열림 · 닫힘은 그대로(398-1 · 499-1)
|
||||
onReveal: () =>
|
||||
isUserPick(shared.projectId) && shared.structures?.root.classList.remove("is-collapsed"),
|
||||
},
|
||||
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
|
||||
);
|
||||
@@ -321,6 +332,12 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
return shared.structures;
|
||||
}
|
||||
|
||||
/** 닫힌 동안 고른 것 — 패널을 열 때 고른 줄 · 강조 상자로 한 번 밂(닫힌 몸통 = 스크롤 무효 · 499-1). */
|
||||
export function scrollToPick(section: StructuresSection | null): void {
|
||||
section?.listRoot.querySelector(".is-selected")?.scrollIntoView({ block: "nearest" });
|
||||
section?.root.querySelector("fieldset.is-active")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
/** 지금 두 보기가 아는 노선 — 횡단 보기가 먼저(확정 본문이 그쪽이다). */
|
||||
function routeIdOf(shared: DesignPageShared): number | null {
|
||||
return shared.bindings.cross?.save?.routeId() ?? shared.bindings.profile?.save?.routeId() ?? null;
|
||||
@@ -342,7 +359,6 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
|
||||
},
|
||||
// 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다.
|
||||
save: { run: () => saveSharedDesign(shared, false), enabled: false },
|
||||
confirm: { run: () => saveSharedDesign(shared, true), enabled: false },
|
||||
},
|
||||
},
|
||||
shared.view(),
|
||||
@@ -355,16 +371,41 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */
|
||||
/** 노선 바뀐 채 확정 → [B07 로] 고름 — 다시 세운 페이지가 자료를 세우면 새 노선으로 확정 뒤 그 주소(요구 300 B2). */
|
||||
const confirmOnReady = new Map<string, RoutePath>();
|
||||
|
||||
/** 자료가 섰다 — [저장] · 확정 이동을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */
|
||||
export function designReady(shared: DesignPageShared): void {
|
||||
shared.ready = true;
|
||||
shared.panel?.dock.setActions({
|
||||
save: {
|
||||
run: () => saveSharedDesign(shared, false),
|
||||
enabled: true,
|
||||
title: L("B06_Profile_Btn_Save_Tip"),
|
||||
},
|
||||
confirm: { run: () => saveSharedDesign(shared, true), enabled: true },
|
||||
});
|
||||
const target = confirmOnReady.get(shared.projectId);
|
||||
if (!target) return;
|
||||
confirmOnReady.delete(shared.projectId);
|
||||
void saveSharedDesign(shared, true, target);
|
||||
}
|
||||
|
||||
/** 고친 것 없고 단계 2 · 3 이미 완료 — [>]가 저장 없이 바로 감(요구 302 D1 · 그 밖은 496 확정 그대로). */
|
||||
async function alreadyConfirmed(projectId: string): Promise<boolean> {
|
||||
if (designSaveBusy(projectId) || designDirty(projectId)) return false;
|
||||
if (await pendingRouteSave(projectId)) return false;
|
||||
const state = await fetchWorkflowState(projectId).catch(() => null);
|
||||
const done = (no: number) =>
|
||||
state?.stages.some((s) => s.stage_no === no && s.state === "COMPLETE");
|
||||
return !!done(2) && !!done(3);
|
||||
}
|
||||
|
||||
/** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */
|
||||
export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void {
|
||||
if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info");
|
||||
void alreadyConfirmed(shared.projectId).then((skip) =>
|
||||
skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,10 +425,22 @@ async function routeChanged(shared: DesignPageShared): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
|
||||
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
|
||||
* [저장] · 확정 한 번(확정 = `target` 으로 감 · 기본 B07) — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
|
||||
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳 · 줄 세움은 `queueDesignSave`(498-3).
|
||||
*/
|
||||
export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise<void> {
|
||||
export async function saveSharedDesign(
|
||||
shared: DesignPageShared,
|
||||
confirm: boolean,
|
||||
target: RoutePath = WORKFLOW_STEP_ROUTES[4],
|
||||
): Promise<void> {
|
||||
await queueDesignSave(shared.projectId, confirm, () => saveOnce(shared, confirm, target));
|
||||
}
|
||||
|
||||
async function saveOnce(
|
||||
shared: DesignPageShared,
|
||||
confirm: boolean,
|
||||
target: RoutePath,
|
||||
): Promise<void> {
|
||||
if (await routeChanged(shared)) return;
|
||||
// [저장] · [확정] 본문 = 횡단 보기 한 벌(표준 횡단면 · 전 측점 면적 · 카드 고침) — 들어온 화면 무관 · 숨긴 채라도
|
||||
// 세움(B05 만 연 캐시 패치 길 지움 · 386-6)
|
||||
@@ -426,13 +479,17 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
|
||||
...crossSave?.parts,
|
||||
},
|
||||
crossPart,
|
||||
done: async (isConfirm) => {
|
||||
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
|
||||
done: async (isConfirm, reload = true) => {
|
||||
if (!reload) {
|
||||
// 노선 바뀐 저장 — 곧 다시 세움 · 다시 읽지 않음(498-3)
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
return;
|
||||
}
|
||||
if (isConfirm) {
|
||||
// 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1)
|
||||
invalidateSectionDetail(shared.projectId);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
goToWorkflowStage(shared.projectId, target);
|
||||
return;
|
||||
}
|
||||
// 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를
|
||||
@@ -441,6 +498,17 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
|
||||
await profileSave?.done?.(false);
|
||||
await crossSave?.done?.(false);
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
shared.refreshStages?.();
|
||||
},
|
||||
// 노선 모양 바뀐 채 확정 — 새 노선은 저장됨 · 머묾 / 새 노선으로 확정 뒤 이동을 고름(요구 300 B2)
|
||||
routeMoved: async () => {
|
||||
clearWorkflowStateCache();
|
||||
const go = await showConfirmDialog(
|
||||
L("Design_Route_Moved"),
|
||||
L("Design_Route_Moved_Go"),
|
||||
L("Design_Route_Moved_Stay"),
|
||||
);
|
||||
if (go) confirmOnReady.set(shared.projectId, target);
|
||||
},
|
||||
failed: (error, isConfirm) => {
|
||||
const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed";
|
||||
|
||||
@@ -124,6 +124,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
return slot;
|
||||
};
|
||||
|
||||
/** 관경 빈칸 = 빈 보기 그대로 — 기본값 1000 을 몰래 채우지 않음(505-4 · 295). */
|
||||
const blankKept = (c: SpecContainer, item: SpecItem): boolean =>
|
||||
spec?.typeId === "pipe" && c.id === "pipe" && item.id === "diameter";
|
||||
|
||||
const control = (
|
||||
c: SpecContainer,
|
||||
item: SpecItem,
|
||||
@@ -140,7 +144,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
const blank = !tag ? "" : name ? `${name} (${tag})` : tag;
|
||||
// 필수 상자 = 빈 보기 없음(54-43) · 선택 상자 빈 보기 = 빈 글
|
||||
select.replaceChildren(
|
||||
...(c.required && !tag ? [] : [new Option(blank, "")]),
|
||||
...(c.required && !tag && !blankKept(c, item) ? [] : [new Option(blank, "")]),
|
||||
...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)),
|
||||
);
|
||||
// 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음)
|
||||
@@ -223,6 +227,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
for (const item of c.items) {
|
||||
if (item.kind !== "select" || values[item.id] || (item.by && !branchOf(item, values)))
|
||||
continue;
|
||||
if (blankKept(c, item)) continue;
|
||||
const choices = choicesOf(item, values);
|
||||
const key = branchOf(item, values)?.key;
|
||||
const wanted = String((key ? defaults[key] : undefined) ?? item.default ?? "");
|
||||
|
||||
@@ -24,20 +24,32 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu
|
||||
/** 횡단 측점 목록 읽기 — 노면공 확폭 구간 상자(87-3)가 확폭을 읽음 · 자료 없으면 null. */
|
||||
export const pipeSections = (): readonly CrossSection[] | null => sectionsSource?.() ?? null;
|
||||
|
||||
/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */
|
||||
/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch.
|
||||
* set 마다 풀개를 돌려줌(아직 제 값일 때만 비움) — 보기 onDispose 에 붙여 떠난 페이지를 놓음(505-2). */
|
||||
let extraCountSource: ((section: CrossSection) => number) | null = null;
|
||||
export function setPipeExtraCountSource(source: (section: CrossSection) => number): void {
|
||||
export function setPipeExtraCountSource(source: (section: CrossSection) => number): () => void {
|
||||
extraCountSource = source;
|
||||
return () => {
|
||||
if (extraCountSource === source) extraCountSource = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 유출 단 수를 쓰는 곳 — 횡단 보기의 단 수 제어(54-27) · 종단 보기는 횡단 보기를 숨긴 채 세워 씀. */
|
||||
let extraCountSink: ((section: CrossSection, count: number) => void) | null = null;
|
||||
let crossOpener: (() => Promise<void>) | null = null;
|
||||
export function setPipeExtraCountSink(sink: (section: CrossSection, count: number) => void): void {
|
||||
export function setPipeExtraCountSink(
|
||||
sink: (section: CrossSection, count: number) => void,
|
||||
): () => void {
|
||||
extraCountSink = sink;
|
||||
return () => {
|
||||
if (extraCountSink === sink) extraCountSink = null;
|
||||
};
|
||||
}
|
||||
export function setPipeCrossOpener(opener: () => Promise<void>): void {
|
||||
export function setPipeCrossOpener(opener: () => Promise<void>): () => void {
|
||||
crossOpener = opener;
|
||||
return () => {
|
||||
if (crossOpener === opener) crossOpener = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 좌측 「추가 흙막이(단)」 → 주인 측점 유출 단 수(B06 횡단 조정과 같은 저장 자리 · 54-27). */
|
||||
@@ -50,8 +62,11 @@ export async function setPipeExtraCount(chainageM: number | null, count: number)
|
||||
/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */
|
||||
type InletChoice = NonNullable<CrossSection["design"]>["inlet_structure"];
|
||||
let inletSource: ((section: CrossSection) => InletChoice) | null = null;
|
||||
export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void {
|
||||
export function setPipeInletSource(source: (section: CrossSection) => InletChoice): () => void {
|
||||
inletSource = source;
|
||||
return () => {
|
||||
if (inletSource === source) inletSource = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */
|
||||
@@ -137,8 +152,11 @@ interface RevetChoiceBridge {
|
||||
) => void;
|
||||
}
|
||||
let revetChoice: RevetChoiceBridge | null = null;
|
||||
export function setPipeRevetChoiceBridge(bridge: RevetChoiceBridge): void {
|
||||
export function setPipeRevetChoiceBridge(bridge: RevetChoiceBridge): () => void {
|
||||
revetChoice = bridge;
|
||||
return () => {
|
||||
if (revetChoice === bridge) revetChoice = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 관의 [연동] · [경사] 지금 값 — 횡단 자료가 없으면 null(모름). */
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
*
|
||||
* 사용자 고름 = 지금 브라우저가 진짜 누름(포인터 · 마우스 · 우클릭)을 처리하는 중 — 눈금 · 마름모 · 3D ·
|
||||
* 종단 알약 · 배수유역도 · 횡단 카드 · 우클릭 추가가 다 그 누름 안에서 폼을 엶.
|
||||
* 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음이 도는 동안
|
||||
* 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음을 띄운 그 누름
|
||||
* (교점 끌기 · 곡선 지우기 · 되돌리기가 띄운 차례의 관 · 구조물 따라가기 · 다시 칠하기).
|
||||
* 「계산 중」 동안 새로 누른 고름은 폄(505-2).
|
||||
* 폼이 고른 것을 다시 실을 때마다 부르는 쪽을 막지 않고 펴기 한 곳(`design_shared` onReveal)이 여기만 봄.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pageBusy } from "./design_page_data";
|
||||
import { pageLaunchedBy } from "./design_page_data";
|
||||
|
||||
const PICK_EVENTS = new Set([
|
||||
"pointerdown",
|
||||
@@ -22,9 +23,9 @@ const PICK_EVENTS = new Set([
|
||||
"contextmenu",
|
||||
]);
|
||||
|
||||
/** 지금 사용자 누름 안이고 묶음이 안 도는가 — 좌측 패널을 펴도 되는가. */
|
||||
/** 지금 사용자 누름 안이고 그 누름이 묶음을 안 띄웠나 — 좌측 패널을 펴도 되는가. */
|
||||
export function isUserPick(projectId: string | null): boolean {
|
||||
const event = (globalThis as { event?: Event }).event; // 처리 중인 이벤트(그 마이크로태스크까지)
|
||||
if (!event?.isTrusted || !PICK_EVENTS.has(event.type)) return false;
|
||||
return !projectId || !pageBusy(projectId);
|
||||
return !projectId || !pageLaunchedBy(projectId, event);
|
||||
}
|
||||
|
||||
@@ -298,18 +298,21 @@ export async function mountCrossView(
|
||||
},
|
||||
});
|
||||
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
|
||||
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
|
||||
host.onDispose(
|
||||
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")),
|
||||
);
|
||||
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
|
||||
setPipeExtraCountSink((owner, count) => {
|
||||
const releaseSink = setPipeExtraCountSink((owner, count) => {
|
||||
const picked = stationControls.revetOffset.highlightFor(owner) ?? "";
|
||||
if (/^extra\d+$/.test(picked) && Number(picked.slice(5)) >= count)
|
||||
stationControls.revetOffset.select(owner.chainage_m, "outlet");
|
||||
stationControls.extraWalls.setCount(owner.chainage_m, count, "outlet");
|
||||
});
|
||||
host.onDispose(releaseSink); // set 넷 = 떠난 페이지를 놓음(505-2)
|
||||
// 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선
|
||||
setPipeInletSource((section) => stationControls.inletStructure.valueFor(section));
|
||||
host.onDispose(setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)));
|
||||
// 좌측 관 보호공 [연동] · [경사] = 옛 연동 제어 그대로(54-30)
|
||||
setPipeRevetChoiceBridge(pipeRevetChoiceBridge(stationControls.revetLink));
|
||||
host.onDispose(setPipeRevetChoiceBridge(pipeRevetChoiceBridge(stationControls.revetLink)));
|
||||
|
||||
const sectionView = createSectionView(
|
||||
(chainageM, change) => {
|
||||
|
||||
@@ -222,7 +222,7 @@ export async function mountProfileView(
|
||||
restoring: true,
|
||||
};
|
||||
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
|
||||
setPipeCrossOpener(() => shared.ensureView("cross"));
|
||||
host.onDispose(setPipeCrossOpener(() => shared.ensureView("cross"))); // 떠나면 놓음(505-2)
|
||||
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
||||
let selectionSyncing = false;
|
||||
|
||||
|
||||
@@ -48,14 +48,17 @@ export interface SavedSnap {
|
||||
station_snap_step?: number | null;
|
||||
}
|
||||
|
||||
/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남). */
|
||||
/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남).
|
||||
* 간격 = 10 만 그 값 · 옛 초안 0.1 · 1 m 등 = null — 짝 `B05_Profile_Engine_Sections.py` load_station_snap_step(505-2). */
|
||||
export function snapOptions(
|
||||
draft: RouteDraft | null | undefined,
|
||||
saved: SavedSnap | null | undefined,
|
||||
): Pick<StationsOptions, "stationSnap" | "stationSnapStep"> {
|
||||
return draft
|
||||
? { stationSnap: draft.stationSnap, stationSnapStep: draft.stationSnapStep }
|
||||
: { stationSnap: saved?.station_snap ?? false, stationSnapStep: saved?.station_snap_step };
|
||||
const step = draft ? draft.stationSnapStep : saved?.station_snap_step;
|
||||
return {
|
||||
stationSnap: draft ? draft.stationSnap : (saved?.station_snap ?? false),
|
||||
stationSnapStep: step === 10 ? 10 : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */
|
||||
|
||||
@@ -16,7 +16,7 @@ interface SettingUser {
|
||||
company_id?: number | null;
|
||||
}
|
||||
|
||||
/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */
|
||||
/** key = M01 은 「m01」 · 계산 자리 창은 「calcSites」(대시보드 카드만 · 504-2) · M02 는 진입 인자 `open` */
|
||||
export interface SettingLink {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -57,6 +57,12 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul
|
||||
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
|
||||
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
|
||||
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
|
||||
{
|
||||
key: "calcSites",
|
||||
title: L("B01_CalcSites_Title"),
|
||||
desc: L("B01_CalcSites_Desc"),
|
||||
route: ROUTES.B01_ACCOUNT,
|
||||
},
|
||||
];
|
||||
}
|
||||
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
|
||||
@@ -76,7 +82,8 @@ export function mountSettingsDrawer(
|
||||
activeKey: string,
|
||||
companyId: number | string | null,
|
||||
): void {
|
||||
const links = settingLinks(user, companyId);
|
||||
// 계산 자리 = 대시보드 창(504-2) — 페이지가 아니라 ☰ 에 안 둠
|
||||
const links = settingLinks(user, companyId).filter((link) => link.key !== "calcSites");
|
||||
const bar = createStepBar(
|
||||
links.map((link) => link.title),
|
||||
links.findIndex((link) => link.key === activeKey),
|
||||
|
||||
@@ -29,8 +29,11 @@ async function post(path: string, body?: object): Promise<ApiResult> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function requestLogin(email: string, password: string): Promise<ApiResult> {
|
||||
return post("/auth/login/request", { email, password });
|
||||
export async function requestLogin(email: string, password: string): Promise<ApiResult> {
|
||||
const result = await post("/auth/login/request", { email, password });
|
||||
// 화면이 뜰 때 기억한 「로그인 안 됨」(5 s)을 응답 뒤에 버림 — 안 버리면 대시보드 가드가 되튕김(309-1)
|
||||
clearSessionCache();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function verifyLogin(email: string, otpCode: string): Promise<ApiResult> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""로그인, 재인증, 세션 및 비밀번호 API."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -7,7 +8,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from common_util.common_util_auth import (
|
||||
create_session,
|
||||
delete_session_cookie,
|
||||
generate_device_token,
|
||||
generate_otp,
|
||||
@@ -22,16 +22,15 @@ from common_util.common_util_auth import (
|
||||
)
|
||||
from common_util.common_util_auth_repository import (
|
||||
change_password,
|
||||
clear_login_failures,
|
||||
consume_otp,
|
||||
delete_session,
|
||||
finish_login,
|
||||
get_active_otp,
|
||||
get_user_by_email,
|
||||
has_trusted_device,
|
||||
record_failed_login,
|
||||
record_login,
|
||||
replace_otp,
|
||||
trust_device,
|
||||
)
|
||||
from common_util.common_util_email import send_email_background
|
||||
from common_util.common_util_email_templates import otp_email, security_alert_email
|
||||
@@ -66,7 +65,8 @@ async def request_login(payload: LoginRequest, request: Request):
|
||||
if user["account_locked_until"] and user["account_locked_until"] > datetime.utcnow():
|
||||
await record_login(user["id"], email, "FAILURE", "ACCOUNT_LOCKED", agent)
|
||||
raise HTTPException(status_code=423, detail="로그인 실패 누적으로 계정이 잠겼습니다.")
|
||||
if not verify_password(payload.password, user["password_hash"]):
|
||||
# bcrypt(cost 12 · 약 0.2 s)는 스레드에서 — 이벤트 루프(다른 요청)를 막지 않게
|
||||
if not await asyncio.to_thread(verify_password, payload.password, user["password_hash"]):
|
||||
failures = await record_failed_login(user["id"])
|
||||
await record_login(user["id"], email, "FAILURE", "INVALID_PASSWORD", agent)
|
||||
if failures >= 5 and ADMIN_EMAIL:
|
||||
@@ -108,25 +108,15 @@ async def request_login(payload: LoginRequest, request: Request):
|
||||
async def _finish_login(
|
||||
user: dict, agent: str, previous_device_token_hash: str | None = None
|
||||
) -> JSONResponse:
|
||||
await clear_login_failures(user["id"])
|
||||
device_token = generate_device_token()
|
||||
await trust_device(
|
||||
session_id = await finish_login(
|
||||
user["id"],
|
||||
hash_device_token(device_token),
|
||||
user["email"],
|
||||
agent,
|
||||
hash_user_agent(agent),
|
||||
hash_device_token(device_token),
|
||||
previous_device_token_hash,
|
||||
)
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(user["id"],),
|
||||
)
|
||||
await connection.commit()
|
||||
session_id = await create_session(user["id"], agent)
|
||||
await record_login(user["id"], user["email"], "SUCCESS", None, agent)
|
||||
response = JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
@@ -146,7 +136,7 @@ async def verify_login(payload: OtpVerifyRequest, request: Request):
|
||||
not user
|
||||
or not otp
|
||||
or otp["expires_at"] < datetime.utcnow()
|
||||
or not verify_password(payload.otp_code, otp["otp_hash"])
|
||||
or not await asyncio.to_thread(verify_password, payload.otp_code, otp["otp_hash"])
|
||||
):
|
||||
if user:
|
||||
failures = await record_failed_login(user["id"])
|
||||
|
||||
@@ -7,9 +7,12 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response
|
||||
from pymysql.err import IntegrityError
|
||||
|
||||
from common_util import common_util_calc_sites as calc_sites
|
||||
from common_util.common_util_audit import record_audit
|
||||
from common_util.common_util_auth import require_company, require_system_admin, verify_session
|
||||
from common_util.common_util_email import send_email
|
||||
from common_util.common_util_project_delete import hard_delete_project
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
|
||||
|
||||
from .B01_Dashboard_Knowledge_Chat import router as knowledge_chat_router
|
||||
@@ -59,6 +62,7 @@ from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AdminUpdateUserRequest,
|
||||
AssignCompanyRequest,
|
||||
CalcSitesRequest,
|
||||
ChangeUserRoleRequest,
|
||||
CreateCompanyRequest,
|
||||
InviteMemberRequest,
|
||||
@@ -579,5 +583,37 @@ async def system_projects(session: dict[str, Any] = Depends(require_system_admin
|
||||
return {"status": "success", "projects": await list_all_projects()}
|
||||
|
||||
|
||||
# ── 계산 자리 판(504-2) — 읽기 = 로그인 · 쓰기 = 시스템 관리자 · 바뀐 줄마다 시스템 로그 한 줄 ──
|
||||
@router.get("/calc-sites")
|
||||
async def read_calc_sites(session: dict[str, Any] = Depends(verify_session)):
|
||||
_ = session
|
||||
return {"status": "success", **calc_sites.read_sites(), "groups": calc_sites.GROUPS}
|
||||
|
||||
|
||||
@router.put("/admin/calc-sites")
|
||||
async def write_calc_sites(
|
||||
payload: CalcSitesRequest,
|
||||
request: Request,
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
try:
|
||||
board, changed = calc_sites.write_sites(payload.sites)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if changed:
|
||||
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
||||
for key in changed:
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=int(session["user_id"]),
|
||||
action="CALC_SITE_CHANGE",
|
||||
resource_type="calc_site",
|
||||
resource_ref=f"{key}={board['sites'][key]}",
|
||||
request=request,
|
||||
)
|
||||
await connection.commit()
|
||||
return {"status": "success", **board, "groups": calc_sites.GROUPS, "changed": changed}
|
||||
|
||||
|
||||
router.include_router(assets_router)
|
||||
router.include_router(knowledge_chat_router)
|
||||
|
||||
@@ -22,6 +22,12 @@ class UiPrefsRequest(BaseModel):
|
||||
prefs: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalcSitesRequest(BaseModel):
|
||||
"""계산 자리 판 고침(504-2) — 바꿀 줄만 · 줄 · 값 검사는 `common_util_calc_sites`."""
|
||||
|
||||
sites: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateCompanyRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
business_registration_number: str = Field(min_length=1, max_length=20)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_CalcSites.ts
|
||||
* 「계산 자리」 창(PLAN 504-2 · 504-3 · 요구 309-2 · 312) — 시스템 관리자만(카드 = `settingLinks` · 쓰기 = 서버
|
||||
* `require_system_admin`). 묶음 8 머리 + 그 아래 계산 45 줄(번호 · 이름 = 등록부 `label`) · 칸 둘(「조작 중
|
||||
* 브라우저 셈」 · 「[저장] 서버 정본」 늘 켬 · 잠금). 묶음 머리 체크 = 그 묶음 전부.
|
||||
* 바꿀 수 있는 줄 = 서버 판 `groups[].switchable`(판 열쇠 = 묶음) · 나머지는 잠긴 체크 + 까닭 글.
|
||||
* 고침은 창 안 사본에만 · [저장] 때만 바뀐 줄을 PUT(선례 `app_settings.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { table } from "@ui/ui_template_general_blocks";
|
||||
import { askLeave } from "@ui/ui_template_leave_dialog";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
fetchCalcSites,
|
||||
saveCalcSites,
|
||||
type CalcSiteGroup,
|
||||
type CalcSiteValue,
|
||||
type CalcSitesBoard,
|
||||
} from "../A00_Common/design/design_calc_sites";
|
||||
import { CALC_REGISTRY } from "../A00_Common/design/logic/design_calc_registry";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
type LocaleKey = Parameters<typeof L>[0];
|
||||
const K = (key: string): string => L(`B01_CalcSites_${key}` as LocaleKey);
|
||||
|
||||
function check(checked: boolean, locked: boolean): HTMLInputElement {
|
||||
const input = el("input", { attrs: { type: "checkbox" } });
|
||||
input.checked = checked;
|
||||
input.disabled = locked;
|
||||
return input;
|
||||
}
|
||||
|
||||
const LABELS = new Map(CALC_REGISTRY.map((entry) => [entry.no, entry.label]));
|
||||
|
||||
/** 묶음 머리 줄 + 계산 한 줄씩. 바꿀 수 있는 묶음은 머리 · 줄 체크가 같은 값(서로 따라감). */
|
||||
function groupRows(
|
||||
group: CalcSiteGroup,
|
||||
draft: Record<string, CalcSiteValue>,
|
||||
changed: () => void,
|
||||
): HTMLElement[][] {
|
||||
const server = (): HTMLInputElement => check(group.fixed !== "browser", true);
|
||||
const why = K(`Why_${group.why ?? group.key}`);
|
||||
const head = el("span", {
|
||||
children: [el("strong", { text: K(group.key) }), ` (${group.nos.length})`],
|
||||
});
|
||||
if (!group.switchable) {
|
||||
const browser = (): HTMLInputElement => check(group.fixed !== "server", true);
|
||||
return [
|
||||
[head, browser(), server(), el("span")],
|
||||
...group.nos.map((no) => [calcName(no), browser(), server(), el("span", { text: why })]),
|
||||
];
|
||||
}
|
||||
const boxes = [group.key, ...group.nos].map((id, index) => {
|
||||
const box = check(draft[group.key] !== "server", false);
|
||||
if (index === 0) box.dataset.calcSite = group.key;
|
||||
else box.dataset.calcNo = id;
|
||||
return box;
|
||||
});
|
||||
for (const box of boxes) {
|
||||
box.addEventListener("change", () => {
|
||||
draft[group.key] = box.checked ? "both" : "server";
|
||||
for (const other of boxes) other.checked = box.checked;
|
||||
changed();
|
||||
});
|
||||
}
|
||||
return [
|
||||
[head, boxes[0], server(), el("span")],
|
||||
...group.nos.map((no, index) => [
|
||||
calcName(no),
|
||||
boxes[index + 1],
|
||||
server(),
|
||||
el("span", { text: why }),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
const calcName = (no: string): HTMLElement =>
|
||||
el("span", { className: "b01-dashboard__calc-name", text: `${no} · ${LABELS.get(no) ?? ""}` });
|
||||
|
||||
function grid(
|
||||
groups: CalcSiteGroup[],
|
||||
draft: Record<string, CalcSiteValue>,
|
||||
changed: () => void,
|
||||
): HTMLElement {
|
||||
const rows = groups.flatMap((group) => groupRows(group, draft, changed));
|
||||
const wrap = table([K("Col_Group"), K("Col_Browser"), K("Col_Server"), K("Col_Why")], rows, 14);
|
||||
const heads = new Set<number>();
|
||||
let at = 0;
|
||||
for (const group of groups) {
|
||||
heads.add(at);
|
||||
at += 1 + group.nos.length;
|
||||
}
|
||||
wrap.querySelectorAll("tbody tr").forEach((row, index) => {
|
||||
if (heads.has(index)) row.classList.add("b01-dashboard__calc-group");
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 시스템 설정 「계산 자리」 카드 — 판을 받아 창을 엶. */
|
||||
export async function openCalcSitesDialog(): Promise<void> {
|
||||
let board: CalcSitesBoard;
|
||||
try {
|
||||
board = await fetchCalcSites();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : K("LoadFailed"), "error");
|
||||
return;
|
||||
}
|
||||
const draft = { ...board.sites };
|
||||
const dirty = (): Record<string, CalcSiteValue> =>
|
||||
Object.fromEntries(Object.entries(draft).filter(([key, value]) => board.sites[key] !== value));
|
||||
const count = el("p", { className: "b01-dashboard__calc-count" });
|
||||
const recount = (): void => {
|
||||
const n = Object.values(draft).filter((value) => value === "server").length;
|
||||
count.textContent = K("ServerCount").replace("{value}", String(n));
|
||||
};
|
||||
recount();
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
const changes = dirty();
|
||||
if (!Object.keys(changes).length) return true;
|
||||
try {
|
||||
board = await saveCalcSites(changes);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
return false;
|
||||
}
|
||||
showToast(K("Saved"), "success");
|
||||
return true;
|
||||
};
|
||||
const saveButton = createButton({
|
||||
label: L("Common_Btn_Save"),
|
||||
variant: "filled",
|
||||
onClick: () => void save().then((ok) => ok && modal.close()),
|
||||
});
|
||||
const modal = openModal({
|
||||
title: K("Title"),
|
||||
closeLabel: L("Common_Btn_Close"),
|
||||
dialogClass: "b01-dashboard__calc-sites",
|
||||
wide: true,
|
||||
actions: [saveButton],
|
||||
beforeClose: async () => {
|
||||
if (!Object.keys(dirty()).length) return true;
|
||||
const choice = await askLeave(K("Leave"));
|
||||
return choice === "discard" || (choice === "save" && (await save()));
|
||||
},
|
||||
mount: (body) => {
|
||||
body.append(
|
||||
count,
|
||||
el("p", { className: "b01-dashboard__calc-note", text: K("NextOpen") }),
|
||||
grid(board.groups, draft, recount),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type ResourceData,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
|
||||
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
|
||||
import { L, roleLabel } from "./B01_Dashboard_UI_Common";
|
||||
import {
|
||||
buildCompanyPanel,
|
||||
@@ -165,13 +166,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement {
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */
|
||||
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) · 계산 자리 = 창(504-2) */
|
||||
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__setting-grid";
|
||||
wrap.append(
|
||||
...settingLinks(user, companyId).map((link) =>
|
||||
settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)),
|
||||
settingCard(link.title, link.desc, () =>
|
||||
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query),
|
||||
),
|
||||
),
|
||||
);
|
||||
return wrap;
|
||||
|
||||
@@ -787,3 +787,26 @@
|
||||
outline: 2px solid var(--color-royal-amethyst, #6843a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 계산 자리 창(504-2) — 묶음 · 까닭 글은 줄바꿈 · 체크 칸은 가운데 */
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td {
|
||||
white-space: normal;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(2),
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(3) {
|
||||
text-align: center;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group td {
|
||||
background: var(--color-surface);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-name {
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group .b01-dashboard__calc-name {
|
||||
padding-left: 0;
|
||||
}
|
||||
.b01-dashboard__calc-count,
|
||||
.b01-dashboard__calc-note {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""도엽 표시본 잘라 두기 — 평면도 = 노선 띠 · 배수유역도 = 유역 + 여유(509-1 · 509-6 · 서버 단독 ③).
|
||||
|
||||
도엽 사본은 약 6.7×8.3 km 라 노선 편집 · 배수유역도가 통째로 받으면 그릴 꼭짓점의 9 할이
|
||||
화면에 안 드는 헛일이다(요구 316). 화면마다 **보는 범위**만 잘라 보낸다 — 형상은 그대로 ·
|
||||
범위 밖만 사라짐. 분석(배수유역 · 관 자리)은 원본 도엽을 읽으므로 이 사본과 상관없다.
|
||||
|
||||
- 범위(사업지 m · 바깥으로 `SNAP_M` 눈금에 맞춤 → 노선을 조금 옮겨도 같은 열쇠)
|
||||
- `route` = 저장 노선 bbox + 띠 `ROUTE_BAND_M` + 여유 `ROUTE_SPARE_M` — 편집이 이 밖으로
|
||||
나가면 화면이 통째 사본을 받는다(`clip_lonlat` 으로 판정).
|
||||
- `drainage` = 세부 유역(노선 교차 관으로 모이는 유역) ∪ 줌아웃 한계(노선 bbox ×2)
|
||||
+ `DRAIN_SPARE_M`.
|
||||
- 파생 저장본 = `_derived/sheet_<범위>/<열쇠>.json`(GeoJSON 그대로 · 열쇠 = 사본 지문 + 범위)
|
||||
· 지워도 다시 셈 · 범위를 못 정하면 사본 그대로.
|
||||
- 경위도 ↔ m 는 화면과 같은 가정(위성 메타의 m 사각형 = 경위도 사각형 · `metricToScreen`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from shapely import clip_by_rect
|
||||
from shapely.geometry import mapping, shape
|
||||
|
||||
from common_util.common_util_derived_store import (
|
||||
derived_dir,
|
||||
derived_key,
|
||||
file_stamp,
|
||||
prune_derived,
|
||||
)
|
||||
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
||||
from common_util.common_util_route_geometry import (
|
||||
expected_route_csv_path,
|
||||
planned_route_initial_path,
|
||||
planned_route_working_path,
|
||||
read_planned_route_csv,
|
||||
)
|
||||
from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCOPES = ("route", "drainage")
|
||||
CLIP_VERSION = 1
|
||||
ROUTE_BAND_M = 300.0 # = B05 `CONTOUR_BAND_M`(등고선을 보일 노선 둘레 띠)
|
||||
ROUTE_SPARE_M = 300.0 # 띠 밖 여유 — 노선을 이만큼 옮겨도 다시 안 받음
|
||||
DRAIN_SPARE_M = 300.0 # 유역 · 줌아웃 한계 밖 여유 — 화면 가로세로 비 · 테두리
|
||||
ZOOM_OUT_ROUTE_FACTOR = 2.0 # = B05 `ZOOM_OUT_ROUTE_FACTOR`(노선 범위 몇 배까지 멀리 보나)
|
||||
SNAP_M = 50.0
|
||||
|
||||
Rect = tuple[float, float, float, float]
|
||||
|
||||
|
||||
def _read_meta(processed: Path) -> dict[str, Any] | None:
|
||||
for name in ("vworld_satellite_meta.json", "vworld_meta.json"):
|
||||
try:
|
||||
meta = json.loads((processed / name).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
keys = ("x_min", "y_min", "width_meters", "height_meters")
|
||||
if all(
|
||||
meta.get(k) is not None for k in keys + ("lon_min", "lon_max", "lat_min", "lat_max")
|
||||
):
|
||||
return meta
|
||||
return None
|
||||
|
||||
|
||||
def _to_lonlat(meta: dict[str, Any], x: float, y: float) -> tuple[float, float]:
|
||||
lon = meta["lon_min"] + (x - meta["x_min"]) / (meta["width_meters"] or 1) * (
|
||||
meta["lon_max"] - meta["lon_min"]
|
||||
)
|
||||
lat = meta["lat_min"] + (y - meta["y_min"]) / (meta["height_meters"] or 1) * (
|
||||
meta["lat_max"] - meta["lat_min"]
|
||||
)
|
||||
return lon, lat
|
||||
|
||||
|
||||
def _to_metric(meta: dict[str, Any], lon: float, lat: float) -> tuple[float, float]:
|
||||
x = (
|
||||
meta["x_min"]
|
||||
+ (lon - meta["lon_min"])
|
||||
/ ((meta["lon_max"] - meta["lon_min"]) or 1)
|
||||
* (meta["width_meters"])
|
||||
)
|
||||
y = (
|
||||
meta["y_min"]
|
||||
+ (lat - meta["lat_min"])
|
||||
/ ((meta["lat_max"] - meta["lat_min"]) or 1)
|
||||
* (meta["height_meters"])
|
||||
)
|
||||
return x, y
|
||||
|
||||
|
||||
def _bounds(points: list[tuple[float, float]]) -> Rect | None:
|
||||
if not points:
|
||||
return None
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def _grow(rect: Rect, dx: float, dy: float | None = None) -> Rect:
|
||||
dy = dx if dy is None else dy
|
||||
return rect[0] - dx, rect[1] - dy, rect[2] + dx, rect[3] + dy
|
||||
|
||||
|
||||
def _union(a: Rect, b: Rect | None) -> Rect:
|
||||
if b is None:
|
||||
return a
|
||||
return min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])
|
||||
|
||||
|
||||
def _route_points(project_root: Path) -> list[tuple[float, float]]:
|
||||
"""저장 노선(`read_route_plan` planned 와 같은 차례 — 수정본 · 초기 · 예상노선)."""
|
||||
for path in (
|
||||
planned_route_working_path(project_root),
|
||||
planned_route_initial_path(project_root),
|
||||
expected_route_csv_path(project_root),
|
||||
):
|
||||
route = read_planned_route_csv(path) if path.is_file() else None
|
||||
if route is not None and len(route.vertices) >= 2:
|
||||
return [(float(v.x), float(v.y)) for v in route.vertices]
|
||||
return []
|
||||
|
||||
|
||||
def _basin_points(project_root: Path, meta: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
"""세부 유역 꼭짓점(m) — 관마다 모이는 유역(B05 배수유역도가 그리는 것)."""
|
||||
path = project_root / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME
|
||||
try:
|
||||
features = json.loads(path.read_text(encoding="utf-8")).get("features") or []
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
points: list[tuple[float, float]] = []
|
||||
for feature in features:
|
||||
try:
|
||||
lon0, lat0, lon1, lat1 = shape(feature["geometry"]).bounds
|
||||
except Exception: # 깨진 피처 하나가 범위를 막지 않음
|
||||
continue
|
||||
points += [_to_metric(meta, lon0, lat0), _to_metric(meta, lon1, lat1)]
|
||||
return points
|
||||
|
||||
|
||||
def clip_rect_m(project_root: Path, scope: str, meta: dict[str, Any]) -> Rect | None:
|
||||
"""범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None."""
|
||||
route = _bounds(_route_points(project_root))
|
||||
if route is None:
|
||||
return None
|
||||
if scope == "route":
|
||||
rect = _grow(route, ROUTE_BAND_M + ROUTE_SPARE_M)
|
||||
else:
|
||||
basins = _bounds(_basin_points(project_root, meta))
|
||||
if basins is None:
|
||||
return None
|
||||
half = (ZOOM_OUT_ROUTE_FACTOR - 1) / 2
|
||||
zoom_out = _grow(route, (route[2] - route[0]) * half, (route[3] - route[1]) * half)
|
||||
rect = _grow(_union(zoom_out, basins), DRAIN_SPARE_M)
|
||||
steps = [round(value / SNAP_M, 6) for value in rect] # 경위도 되셈의 끝자리 흔들림 걷음
|
||||
return (
|
||||
math.floor(steps[0]) * SNAP_M,
|
||||
math.floor(steps[1]) * SNAP_M,
|
||||
math.ceil(steps[2]) * SNAP_M,
|
||||
math.ceil(steps[3]) * SNAP_M,
|
||||
)
|
||||
|
||||
|
||||
def _coord_bounds(coords: Any) -> Rect:
|
||||
"""GeoJSON 좌표(중첩 목록)의 bbox — shapely 를 만들기 전 거르기."""
|
||||
stack = [coords]
|
||||
x0 = y0 = math.inf
|
||||
x1 = y1 = -math.inf
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if item and isinstance(item[0], (int, float)):
|
||||
x, y = item[0], item[1]
|
||||
x0, y0, x1, y1 = min(x0, x), min(y0, y), max(x1, x), max(y1, y)
|
||||
else:
|
||||
stack.extend(item)
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def clip_collection(collection: dict[str, Any], rect: Rect) -> dict[str, Any]:
|
||||
"""사각형(경위도) 밖을 걷은 컬렉션. 안에 다 든 피처는 그대로 · 걸친 것만 잘라 냄."""
|
||||
west, south, east, north = rect
|
||||
features = []
|
||||
for feature in collection.get("features") or []:
|
||||
geometry = feature.get("geometry")
|
||||
if not geometry or not geometry.get("coordinates"):
|
||||
continue
|
||||
x0, y0, x1, y1 = _coord_bounds(geometry["coordinates"])
|
||||
if x1 < west or x0 > east or y1 < south or y0 > north:
|
||||
continue
|
||||
if not (x0 >= west and x1 <= east and y0 >= south and y1 <= north):
|
||||
cut = clip_by_rect(shape(geometry), *rect) # 점은 bbox 로 이미 갈림
|
||||
if cut.is_empty:
|
||||
continue
|
||||
geometry = round_floats(mapping(cut), LONLAT_DIGITS)
|
||||
features.append({**feature, "geometry": geometry})
|
||||
return {**collection, "features": features, "clip_lonlat": [round(v, 7) for v in rect]}
|
||||
|
||||
|
||||
def _write_compact(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as file:
|
||||
json.dump(value, file, ensure_ascii=False, separators=(",", ":"))
|
||||
os.replace(temporary, path)
|
||||
except BaseException:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def clipped_sheet(project_root: Path, source: Path, scope: str) -> Path:
|
||||
"""`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로."""
|
||||
try:
|
||||
meta = _read_meta(project_root / "B04_PreProcess" / "processed")
|
||||
rect_m = clip_rect_m(project_root, scope, meta) if meta and scope in SCOPES else None
|
||||
if rect_m is None or meta is None:
|
||||
return source
|
||||
west, south = _to_lonlat(meta, rect_m[0], rect_m[1])
|
||||
east, north = _to_lonlat(meta, rect_m[2], rect_m[3])
|
||||
kind = f"sheet_{scope}"
|
||||
key = derived_key(CLIP_VERSION, source.name, file_stamp(source), rect_m)
|
||||
target = derived_dir(project_root, kind) / f"{key}.json"
|
||||
if target.is_file():
|
||||
return target
|
||||
collection = json.loads(source.read_text(encoding="utf-8"))
|
||||
_write_compact(target, clip_collection(collection, (west, south, east, north)))
|
||||
prune_derived(project_root, kind, keep=4) # 레이어 둘 × 두 벌
|
||||
logger.info(
|
||||
"도엽 %s 범위 사본: %s (%.1fMB → %.1fMB)",
|
||||
scope,
|
||||
source.name,
|
||||
source.stat().st_size / 1e6,
|
||||
target.stat().st_size / 1e6,
|
||||
)
|
||||
return target
|
||||
except Exception as exc: # 자르기 실패가 화면을 막으면 안 됨 — 통째 사본
|
||||
logger.warning("도엽 %s 범위 사본 실패 (%s): %s", scope, source.name, exc)
|
||||
return source
|
||||
@@ -54,11 +54,8 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Stream import (
|
||||
PrimaryRegion,
|
||||
build_primary_region,
|
||||
)
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
StructureCandidate,
|
||||
find_stream_crossings,
|
||||
)
|
||||
from common_util.common_util_route_geometry import RouteVertex, StructureCandidate
|
||||
from common_util.common_util_stream_crossing import find_stream_crossings
|
||||
from config.config_system import (
|
||||
DRAINAGE_ARROW_BLOCK_M,
|
||||
DRAINAGE_ARROW_MIN_AGREEMENT,
|
||||
|
||||
@@ -47,13 +47,17 @@ from common_util.common_util_drainage_pipes import (
|
||||
clear_pipe_points,
|
||||
load_pipe_points,
|
||||
parse_pipe_points,
|
||||
pipe_points_path,
|
||||
pipe_signature,
|
||||
read_dismissed,
|
||||
read_pipe_points_file,
|
||||
route_signature,
|
||||
save_detail_basins,
|
||||
save_pipe_points,
|
||||
)
|
||||
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
||||
from common_util.common_util_route_geometry import StructureCandidate
|
||||
from common_util.common_util_stream_crossing import keep_labels
|
||||
from config.config_system import (
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
@@ -611,9 +615,13 @@ async def put_pipe_points(
|
||||
context, detail, points, _, stale = resolved
|
||||
|
||||
signature = route_signature(context.vertices)
|
||||
# 자동 세류선 관 라벨 되붙임 · 지운 자동 관 = 지움 표지(507-2)
|
||||
path = pipe_points_path(context.stored_path)
|
||||
dismissed = read_dismissed(path)
|
||||
keep_labels(points, read_pipe_points_file(path), dismissed)
|
||||
# 좌표를 같이 남긴다 — 다른 선(B05 최적 경로)으로 읽어도 그 자리에 되놓는다.
|
||||
saved = await asyncio.to_thread(
|
||||
save_pipe_points, context.stored_path, signature, points, context.vertices
|
||||
save_pipe_points, context.stored_path, signature, points, context.vertices, dismissed
|
||||
)
|
||||
if not stale: # 낡은 분석 — 관 자리 · 속성만 정본으로 · 세부 유역 산출물은 안 씀
|
||||
await asyncio.to_thread(
|
||||
|
||||
@@ -18,6 +18,8 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
|
||||
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clipped_sheet # noqa: E402
|
||||
|
||||
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402
|
||||
|
||||
@@ -96,12 +98,13 @@ async def get_vworld_map(
|
||||
# GeoJSON 조회 API
|
||||
@router.get("/{project_id}/geojson", response_model=None)
|
||||
async def get_project_geojson(
|
||||
project_id: UUID, layer: str, request: Request
|
||||
project_id: UUID, layer: str, request: Request, scope: str | None = None
|
||||
) -> dict[str, Any] | Response | JSONResponse:
|
||||
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.
|
||||
|
||||
도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을
|
||||
파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다.
|
||||
`scope`(route · drainage)를 주면 그 화면이 보는 범위만 자른 사본을 준다(509-1 · 509-6).
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
@@ -153,11 +156,10 @@ async def get_project_geojson(
|
||||
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
|
||||
# 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은
|
||||
# 그대로고 뜻 없는 자리만 사라진다.
|
||||
return cached_file_response(
|
||||
request,
|
||||
await asyncio.to_thread(_display_copy, filepath),
|
||||
"application/geo+json",
|
||||
)
|
||||
display = await asyncio.to_thread(_display_copy, filepath)
|
||||
if scope:
|
||||
display = await asyncio.to_thread(clipped_sheet, project_root, display, scope)
|
||||
return cached_file_response(request, display, "application/geo+json")
|
||||
|
||||
if layer == "등고선":
|
||||
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
|
||||
|
||||
@@ -238,13 +238,23 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
)
|
||||
|
||||
# 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 이 좌표계로 간다.
|
||||
source_crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=epsg)
|
||||
to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
|
||||
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
|
||||
def sheets() -> tuple[Transformer, list[dict[str, Any]], list[dict[str, Any]], str]:
|
||||
source_crs = resolve_project_crs(
|
||||
project_root, route_crs_input=planned.crs_input, db_epsg=epsg
|
||||
)
|
||||
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
|
||||
return (
|
||||
Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True),
|
||||
*_metric_sheets(
|
||||
_sheet_dir(stored_path),
|
||||
drainage_dir(stored_path),
|
||||
source_crs,
|
||||
to_metric_transformer,
|
||||
),
|
||||
)
|
||||
|
||||
streams, contour_features, contour_key = _metric_sheets(
|
||||
_sheet_dir(stored_path), drainage_dir(stored_path), source_crs, to_metric_transformer
|
||||
)
|
||||
# 재투영(저장본 없으면 3.8 s) · 피클 읽기는 스레드로 — 이벤트 루프를 안 막음(505-1)
|
||||
to_lonlat_transformer, streams, contour_features, contour_key = await asyncio.to_thread(sheets)
|
||||
return {
|
||||
"route_source": route_file.name,
|
||||
"vertices": planned.vertices,
|
||||
|
||||
@@ -292,6 +292,8 @@ export function drawStationTicks(
|
||||
context.stroke();
|
||||
|
||||
const label = stationLabel(chainage, interval, options.chainages ? 1 : 0) + (mc ? " (MC)" : "");
|
||||
// MC = 사용자가 고칠 자리(507-3) — 한 단계 큰 굵은 글 · 경고색 · 바탕 = 테마 바탕(밝음 · 어두움 둘 다 읽힘)
|
||||
context.font = mc ? "bold 13px system-ui, sans-serif" : "11px system-ui, sans-serif";
|
||||
const width = context.measureText(label).width + 6;
|
||||
const half = width / 2;
|
||||
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
|
||||
@@ -328,9 +330,9 @@ export function drawStationTicks(
|
||||
context.translate(-lx, -ly);
|
||||
}
|
||||
// 배경을 깔아 등고선 위에서도 읽히게 한다.
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.78)";
|
||||
context.fillStyle = mc ? themeColor("--color-canvas", "#ffffff") : "rgba(255, 255, 255, 0.78)";
|
||||
context.fillRect(lx - half, ly - 8, width, 16);
|
||||
context.fillStyle = "#222222";
|
||||
context.fillStyle = mc ? themeColor("--color-warning", "#b8860b") : "#222222";
|
||||
context.fillText(label, lx, ly);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ export function drawPreparedFeature(
|
||||
): void {
|
||||
const feature = layer.features[index];
|
||||
if (!feature || feature.kind !== "line") return;
|
||||
drawLineParts(context, feature, affineOf(view));
|
||||
drawLineParts(context, feature, affineOf(view), view, Infinity); // 몇 가닥뿐 — 안 거름
|
||||
}
|
||||
|
||||
/** 점과 선분 사이 거리(px). */
|
||||
@@ -358,9 +358,19 @@ function drawLineParts(
|
||||
context: CanvasRenderingContext2D,
|
||||
feature: PreparedFeature,
|
||||
affine: Affine,
|
||||
view: ViewState,
|
||||
pad: number,
|
||||
): void {
|
||||
// affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수.
|
||||
const tolerance = LOD_PX / affine.ax;
|
||||
// 화면 밖 구간 건너뛰기(509-3) — 두 끝이 **같은 쪽** 화면 밖이면 그 구간은 안 보임 → 선을 끊음.
|
||||
// 한쪽이라도 화면 쪽이거나 서로 다른 쪽(모서리를 질러 갈 수 있음)이면 그대로 그림.
|
||||
const left = -CULL_MARGIN - pad;
|
||||
const top = -CULL_MARGIN - pad;
|
||||
const right = view.width + CULL_MARGIN + pad;
|
||||
const bottom = view.height + CULL_MARGIN + pad;
|
||||
const sideOf = (x: number, y: number): number =>
|
||||
(x < left ? 1 : x > right ? 2 : 0) | (y < top ? 4 : y > bottom ? 8 : 0);
|
||||
for (const part of feature.parts) {
|
||||
const coords = part.coords;
|
||||
if (coords.length < 4) continue;
|
||||
@@ -368,19 +378,51 @@ function drawLineParts(
|
||||
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
|
||||
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
|
||||
context.beginPath();
|
||||
let started = false;
|
||||
let kept = 0;
|
||||
let moves = 0;
|
||||
let fromFirst = false;
|
||||
let penDown = false;
|
||||
let firstX = 0;
|
||||
let firstY = 0;
|
||||
let firstSide = 0;
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
let prevSide = 0;
|
||||
for (let i = 0; i < coords.length; i += 2) {
|
||||
if (weights && weights[i / 2] < tolerance) continue;
|
||||
const x = coords[i] * affine.ax + affine.bx;
|
||||
const y = coords[i + 1] * affine.ay + affine.by;
|
||||
if (started) context.lineTo(x, y);
|
||||
else {
|
||||
context.moveTo(x, y);
|
||||
started = true;
|
||||
const side = sideOf(x, y);
|
||||
if (kept === 0) {
|
||||
firstX = x;
|
||||
firstY = y;
|
||||
firstSide = side;
|
||||
} else if (side & prevSide) {
|
||||
penDown = false;
|
||||
} else {
|
||||
if (!penDown) {
|
||||
context.moveTo(prevX, prevY);
|
||||
if (moves === 0) fromFirst = kept === 1;
|
||||
moves += 1;
|
||||
}
|
||||
context.lineTo(x, y);
|
||||
penDown = true;
|
||||
}
|
||||
kept += 1;
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
prevSide = side;
|
||||
}
|
||||
if (part.closed && kept > 2) {
|
||||
// 한 줄로 이어졌으면 closePath(모서리 이음 그대로) · 끊겼으면 닫는 구간만 따로.
|
||||
if (moves === 1 && fromFirst && penDown) context.closePath();
|
||||
else if (!(prevSide & firstSide)) {
|
||||
if (!penDown) context.moveTo(prevX, prevY);
|
||||
context.lineTo(firstX, firstY);
|
||||
moves += 1;
|
||||
}
|
||||
}
|
||||
if (!started) continue;
|
||||
if (part.closed) context.closePath();
|
||||
if (moves === 0) continue;
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
@@ -424,8 +466,8 @@ const CULL_MARGIN = 32;
|
||||
const LABEL_GAP_PX = 10;
|
||||
const LABEL_ROW_PX = 14;
|
||||
|
||||
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
|
||||
const margin = CULL_MARGIN;
|
||||
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState, pad = 0): boolean {
|
||||
const margin = CULL_MARGIN + pad;
|
||||
const minX = feature.minX * affine.ax + affine.bx;
|
||||
const maxX = feature.maxX * affine.ax + affine.bx;
|
||||
const minY = feature.minY * affine.ay + affine.by;
|
||||
@@ -506,21 +548,25 @@ export function pickLevelStep(
|
||||
*
|
||||
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
|
||||
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
|
||||
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
|
||||
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다.
|
||||
*
|
||||
* `padPx` = 화면 밖 판정을 이만큼 넓힘 — 그림을 돌린 화면(B05 노선 편집)은 모서리가 화면 사각형
|
||||
* 밖에서 들어오므로 반 대각선만큼 준다. */
|
||||
export function drawPreparedLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
layer: PreparedLayer,
|
||||
view: ViewState,
|
||||
marker: MarkerKind,
|
||||
everyM?: number,
|
||||
padPx = 0,
|
||||
): void {
|
||||
const affine = affineOf(view);
|
||||
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
|
||||
for (const feature of layer.features) {
|
||||
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
|
||||
if (!isVisible(feature, affine, view)) continue;
|
||||
if (!isVisible(feature, affine, view, padPx)) continue;
|
||||
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
|
||||
else drawLineParts(context, feature, affine);
|
||||
else drawLineParts(context, feature, affine, view, padPx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,51 +6,24 @@
|
||||
* POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial)
|
||||
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
|
||||
*
|
||||
* 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
|
||||
* 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음.
|
||||
* 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 지형 고르기는 추천 창 안 · [적용]이
|
||||
* 그 지형을 설계 지형으로(509-5).
|
||||
*
|
||||
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
|
||||
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { heightAt } from "../common_util/common_util_surface_sampler";
|
||||
import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
||||
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
import type { RouteReset } from "./logic/B05_Profile_Route_Reset";
|
||||
|
||||
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
|
||||
/** 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. 고르기는 추천 노선 창 안(509-5). */
|
||||
export type AnalysisTerrain = "las" | "sheet";
|
||||
|
||||
/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */
|
||||
const terrainContext = new Map<string, { choosable: boolean; design: AnalysisTerrain }>();
|
||||
|
||||
export const setTerrainContext = (
|
||||
projectId: string,
|
||||
choosable: boolean,
|
||||
design: AnalysisTerrain,
|
||||
): void => void terrainContext.set(projectId, { choosable, design });
|
||||
|
||||
/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 ·
|
||||
* 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다).
|
||||
* 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */
|
||||
export const analysisTerrain = (
|
||||
projectId: string,
|
||||
fallback: AnalysisTerrain = "las",
|
||||
): AnalysisTerrain => {
|
||||
const context = terrainContext.get(projectId);
|
||||
if (context && !context.choosable) return context.design;
|
||||
const saved = readState<string>("routeedit-terrain", projectId);
|
||||
return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback);
|
||||
};
|
||||
|
||||
/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */
|
||||
export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void =>
|
||||
writeState("routeedit-terrain", terrain, projectId);
|
||||
|
||||
/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 고르기를 띄운다)과 지금 설계 지형. */
|
||||
/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 추천 창에 고르기)과 지금 설계 지형. */
|
||||
export async function fetchTerrainSources(
|
||||
projectId: string,
|
||||
): Promise<{ sources: AnalysisTerrain[]; design: AnalysisTerrain }> {
|
||||
@@ -216,14 +189,14 @@ export async function replanRoute(
|
||||
*
|
||||
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
|
||||
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다.
|
||||
* 설계 지형 격자를 받아 두었고 분석 지형이 그것과 같으면 **브라우저 표본**(서버와 같은 값 ·
|
||||
* PLAN 344-1) — 아니면 서버에 물음. 자릿수는 서버 응답과 같게 mm. */
|
||||
* 설계 지형 격자를 받아 두었으면 **브라우저 표본**(서버와 같은 값 · PLAN 344-1) — 아니면
|
||||
* 서버에 물음. 자릿수는 서버 응답과 같게 mm. */
|
||||
export async function fetchRouteElevations(
|
||||
projectId: string,
|
||||
points: Array<[number, number]>,
|
||||
): Promise<Array<number | null>> {
|
||||
const local = await loadDesignTerrainGrid(projectId);
|
||||
if (local && analysisTerrain(projectId) === local.design) {
|
||||
if (local) {
|
||||
return points.map(([x, y]) => {
|
||||
const z = heightAt(local.grid, x, y);
|
||||
return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000;
|
||||
@@ -231,7 +204,7 @@ export async function fetchRouteElevations(
|
||||
}
|
||||
const payload = await requestJson<{ z: Array<number | null> }>(
|
||||
`/projects/${projectId}/route/elevations`,
|
||||
{ method: "POST", body: JSON.stringify({ points, terrain: analysisTerrain(projectId) }) },
|
||||
{ method: "POST", body: JSON.stringify({ points }) },
|
||||
60000,
|
||||
);
|
||||
return payload.z;
|
||||
@@ -242,6 +215,8 @@ export interface CrossPreviewResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
/** 측점 갈래 — `curve` = MC(이름 뒤 「 (MC)」 · 507-3). */
|
||||
kind?: string | null;
|
||||
uphill_side: string | null;
|
||||
plan_radius_m: number | null;
|
||||
curve_widening_m: number | null;
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* B05_Profile_Api_RouteInitial.ts
|
||||
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
|
||||
*
|
||||
* POST /projects/{id}/route/initial { mode, weights?, criteria? } → 노드 · 비교값 · 공통 제약 기준값
|
||||
* POST /projects/{id}/route/initial { mode, weights?, criteria?, terrain? } → 노드 · 비교값 · 공통 제약 기준값
|
||||
*
|
||||
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
|
||||
* 받은 비교값·기준값을 보이기만 한다. 모양은 계획서 20 「API」 줄(서버 `initial_route`) —
|
||||
* 답을 펴는 자리는 `normalizeRouteInitial`(`_RouteEdit_Initial_Model`) 한 곳이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { analysisTerrain, requestJson } from "./B05_Profile_Api_Replan";
|
||||
import { requestJson, type AnalysisTerrain } from "./B05_Profile_Api_Replan";
|
||||
|
||||
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
|
||||
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
@@ -44,6 +44,8 @@ export interface RouteInitialRequest {
|
||||
with_profile?: boolean;
|
||||
/** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */
|
||||
recompute?: boolean;
|
||||
/** 창에서 고른 지형(509-5) — 지형마다 미리 셈 저장본 · 안 보내면 설계 지형. */
|
||||
terrain?: AnalysisTerrain;
|
||||
}
|
||||
|
||||
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
|
||||
@@ -60,7 +62,7 @@ export async function fetchRouteInitial(
|
||||
`/projects/${projectId}/route/initial`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
INITIAL_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
|
||||
import { calcOnServer } from "../A00_Common/design/design_calc_sites";
|
||||
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
|
||||
import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround";
|
||||
|
||||
export interface DesignTerrainGrid {
|
||||
/** 이 격자의 지형 — 편집 창 분석 지형이 이것과 같을 때만 씀. */
|
||||
/** 이 격자의 지형 — 계산 묶음 노선 초안 지형(null = 설계)이 이것과 같을 때만 씀. */
|
||||
design: "las" | "sheet";
|
||||
grid: DtmGrid;
|
||||
}
|
||||
@@ -64,6 +65,12 @@ export function loadDesignTerrainGrid(projectId: string): Promise<DesignTerrainG
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** 설계 지형이 바뀜(추천 [적용] · 509-5) — 받아 둔 격자를 버림 · 다음 부름에 새 지형 격자를 받음. */
|
||||
export function forgetDesignTerrainGrid(projectId: string): void {
|
||||
loaded.delete(projectId);
|
||||
ready.delete(projectId);
|
||||
}
|
||||
|
||||
/** 지금 쓸 수 있는 격자 — 받는 중이면 null 을 돌려주고 받기를 띄움(기다리지 않음). */
|
||||
export function designTerrainGridNow(projectId: string): DesignTerrainGrid | null {
|
||||
if (!ready.has(projectId)) void loadDesignTerrainGrid(projectId);
|
||||
@@ -74,9 +81,12 @@ export function designTerrainGridNow(projectId: string): DesignTerrainGrid | nul
|
||||
const ELEVATION_CHUNK = 4000;
|
||||
|
||||
/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길도 소수 6자리 — 격자 길과 같은 값. */
|
||||
export function groundStageDeps(projectId: string): Pick<GroundStageDeps, "grid" | "remote"> {
|
||||
export function groundStageDeps(
|
||||
projectId: string,
|
||||
): Pick<GroundStageDeps, "grid" | "remote" | "serverOnly"> {
|
||||
return {
|
||||
grid: () => designTerrainGridNow(projectId),
|
||||
serverOnly: () => calcOnServer("ground_sample"),
|
||||
async remote(points, terrain, signal) {
|
||||
const chunks: Array<Array<[number, number]>> = [];
|
||||
for (let i = 0; i < points.length; i += ELEVATION_CHUNK) {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
「종단 반영」 켬이면 1 m 균형 종단을 꼭짓점에 얹음(`seat_base_pvi` → `rebuild_alignment_profile`).
|
||||
[적용]은 이 값을 받아 적지 않음 — 서버가 체인에서 같은 조각으로 다시 셈(CLAUDE.md 5).
|
||||
|
||||
저장본 = `B05_Profile/route_recommend/initial/<결과 키>.json` `{fingerprint, profile}` — 결과 정리
|
||||
(`*.json` glob)가 안 보는 하위 폴더 · 결과 짝 없는 파일은 쓸 때 지움 · 지문 다르면 다시 셈.
|
||||
저장본 = `B05_Profile/route_recommend/initial/<결과 키>.json` `{fingerprint, source, profile}` —
|
||||
결과 정리(`*.json` glob)가 안 보는 하위 폴더 · 결과 짝 없는 파일은 쓸 때 지움 · 지문 다르면 다시 셈.
|
||||
응답 칸 `initial_profile`(화면 476-2) = `{stations_m, ground_m, plan_m, line: [[s, z]…], pvi_count,
|
||||
curve_count, straightened, max_grade_pct, curve_length_m, warnings, with_profile, seconds}` · 실패 =
|
||||
`{error}`.
|
||||
@@ -17,6 +17,7 @@ curve_count, straightened, max_grade_pct, curve_length_m, warnings, with_profile
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
@@ -185,8 +186,9 @@ def profile_summary(longitudinal: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@functools.cache
|
||||
def source_fingerprint() -> str:
|
||||
"""초기 종단 코드 지문 — `SOURCE_GLOBS` 내용(줄끝 LF)."""
|
||||
"""초기 종단 코드 지문 — `SOURCE_GLOBS` 내용(줄끝 LF) · 프로세스에 한 번(505-1)."""
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
digest = hashlib.blake2b(digest_size=8)
|
||||
for path in sorted({p for pattern in SOURCE_GLOBS for p in root.glob(pattern)}):
|
||||
@@ -195,17 +197,6 @@ def source_fingerprint() -> str:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def file_marks(paths: list[Path]) -> list[str]:
|
||||
"""지문에 드는 파일 자국 — 내용 해시(작은 정본) · 없음 「-」."""
|
||||
marks = []
|
||||
for path in paths:
|
||||
try:
|
||||
marks.append(hashlib.blake2b(path.read_bytes(), digest_size=8).hexdigest())
|
||||
except OSError:
|
||||
marks.append("-")
|
||||
return marks
|
||||
|
||||
|
||||
def stat_marks(paths: list[Path]) -> list[str]:
|
||||
"""큰 자료(지형 npz · 도엽) 자국 — 이름 · 크기 · 수정시각."""
|
||||
marks = []
|
||||
@@ -218,8 +209,11 @@ def stat_marks(paths: list[Path]) -> list[str]:
|
||||
return marks
|
||||
|
||||
|
||||
def fingerprint(parts: dict[str, Any]) -> str:
|
||||
text = json.dumps({**parts, "source": source_fingerprint()}, sort_keys=True, default=str)
|
||||
def fingerprint(parts: dict[str, Any], source: str | None = None) -> str:
|
||||
"""입력 `parts` + 코드 지문(`source` 없으면 지금 코드)."""
|
||||
text = json.dumps(
|
||||
{**parts, "source": source or source_fingerprint()}, sort_keys=True, default=str
|
||||
)
|
||||
return hashlib.blake2b(text.encode(), digest_size=16).hexdigest()
|
||||
|
||||
|
||||
@@ -227,21 +221,35 @@ def _path(project_root: Path, key: str) -> Path:
|
||||
return Path(project_root) / INITIAL_DIR / f"{key}.json"
|
||||
|
||||
|
||||
def read_initial(project_root: Path, key: str, mark: str) -> dict[str, Any] | None:
|
||||
"""저장본 — 없거나 지문 다르면 None."""
|
||||
def read_initial(
|
||||
project_root: Path, key: str, mark: str, parts: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""저장본 — 없거나 지문 다르면 None. `parts` 있음 = 프로젝트 접근(505-1 덧) — 코드 지문은
|
||||
안 견주고 입력만(그 파일의 코드 지문으로 다시 냄) · 코드 지문 없는 옛 파일은 그대로 꺼냄."""
|
||||
try:
|
||||
data = json.loads(_path(project_root, key).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("fingerprint") == mark:
|
||||
return data.get("profile")
|
||||
if parts is None:
|
||||
return None
|
||||
source = data.get("source")
|
||||
return (
|
||||
data.get("profile") if isinstance(data, dict) and data.get("fingerprint") == mark else None
|
||||
data.get("profile")
|
||||
if source is None or fingerprint(parts, source) == data.get("fingerprint")
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def write_initial(project_root: Path, key: str, mark: str, profile: dict[str, Any]) -> None:
|
||||
"""한 결과에 하나(덮어씀) · 결과 저장본 짝 없는 파일 지움."""
|
||||
path = _path(project_root, key)
|
||||
atomic_write_json(path, {"fingerprint": mark, "profile": profile})
|
||||
atomic_write_json(
|
||||
path, {"fingerprint": mark, "source": source_fingerprint(), "profile": profile}
|
||||
)
|
||||
results = path.parent.parent
|
||||
for other in path.parent.glob("*.json"):
|
||||
if not (results / other.name).exists():
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · 표준 횡단면 · `ALGO_VERSION` · 엔진 소스 지문. 관
|
||||
지점은 뺀다 — 덮개 수는 꺼낼 때 다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을
|
||||
올린다 — 빠뜨려도 엔진 소스 지문이 바뀌어 새로 셈. 다른 판 · 지문 파일은 안 읽고 남기며 30일 지난
|
||||
것만 미리 셈 때 지운다(420-2 · storage 를 두 PC · 여러 워크트리가 같이 씀).
|
||||
것만 미리 셈 때 지운다(420-2 · storage 를 두 PC · 여러 워크트리가 같이 씀). 프로젝트 접근(화면
|
||||
요청)은 같은 판 · 같은 입력이면 다른 소스 지문 저장본도 꺼냄(`read_stored_any` · 505-1 덧) — 지문
|
||||
견줌 · 다시 셈은 전처리 체인에서만.
|
||||
기본값 · 저장된 제약 결과(`default`)는 「최근 벌」을 따로 센다(351-1).
|
||||
"""
|
||||
|
||||
@@ -25,7 +27,7 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator
|
||||
from typing import Any, AsyncIterator, Callable
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
DEFAULT_WEIGHTS,
|
||||
@@ -48,7 +50,7 @@ SOURCE_GLOBS = (
|
||||
"common_util/common_util_route_polyline.py",
|
||||
)
|
||||
STORE_DIR = "B05_Profile/route_recommend"
|
||||
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
|
||||
# 종류(갈래 · 지형) 마다 남길 벌 수 — 최근에 적은 것이 남음(꺼내기는 읽기만 · 505-1)
|
||||
KEEP_PER_KIND = 2
|
||||
# 다른 판 · 소스 지문 저장본을 이 날수 지나면 미리 셈 때 지움(420-2)
|
||||
STALE_DAYS = 30
|
||||
@@ -91,8 +93,11 @@ def request_fingerprint(
|
||||
with_profile: bool,
|
||||
design_speed: int,
|
||||
standard: dict[str, Any] | None = None,
|
||||
source: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키."""
|
||||
"""(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키.
|
||||
|
||||
`source` = 그 엔진 소스 지문의 키(없으면 지금 코드 · `read_stored_any`)."""
|
||||
pool = _pool_key(
|
||||
expected,
|
||||
terrain,
|
||||
@@ -109,7 +114,7 @@ def request_fingerprint(
|
||||
int(design_speed),
|
||||
json.dumps(standard, sort_keys=True, default=str) if standard else None,
|
||||
ALGO_VERSION,
|
||||
source_fingerprint(),
|
||||
source or source_fingerprint(),
|
||||
)
|
||||
key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest()
|
||||
return key, pool
|
||||
@@ -122,12 +127,11 @@ def is_default(
|
||||
terrain: Any,
|
||||
saved: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""지킬 결과 — 기본값(빈 제약 · 50/25/25 · 설계 지형) 또는 저장된 제약(`initial_choice`) 그대로.
|
||||
"""지킬 결과 — 기본값(빈 제약 · 50/25/25) 또는 저장된 제약(`initial_choice`) 그대로 · 지형은
|
||||
안 따짐(지형마다 미리 셈 · 509-5 · `terrain` 은 부르는 쪽 꼴 맞춤).
|
||||
|
||||
모달이 여는 값이라 「최근 2벌」 정리에서 따로 센다(351-1 · 사용자 시도에 안 밀림).
|
||||
"""
|
||||
if terrain is not None:
|
||||
return False
|
||||
filled = {key: value for key, value in (criteria or {}).items() if value is not None}
|
||||
used = resolve_weights(mode, weights)
|
||||
if not filled and (mode != "weighted" or used == DEFAULT_WEIGHTS):
|
||||
@@ -147,19 +151,35 @@ def _current(data: Any) -> bool:
|
||||
|
||||
|
||||
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
|
||||
"""저장본 한 벌 — 없거나 못 읽거나 판 · 소스 지문이 다르면 None."""
|
||||
"""저장본 한 벌 — 없거나 못 읽거나 판 · 소스 지문이 다르면 None · 파일을 안 건드림(505-1)."""
|
||||
path = store_dir(project_root) / f"{key}.json"
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not _current(data):
|
||||
return data if _current(data) else None
|
||||
|
||||
|
||||
def read_stored_any(
|
||||
project_root: Path, pool: str, key_of: Callable[[str], str]
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
"""(키, 저장본) — 같은 판 · 묶음이고 그 파일의 소스 지문으로 다시 낸 키(`key_of`)가 파일 이름과
|
||||
같은 것 중 최근 것 · 없으면 None. 프로젝트 접근 전용(505-1 덧 · 엔진 고쳐도 안내 안 쏟아짐)."""
|
||||
found: list[tuple[float, str, dict[str, Any]]] = []
|
||||
for path in store_dir(project_root).glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
mtime = path.stat().st_mtime
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not isinstance(data, dict) or data.get("version") != ALGO_VERSION:
|
||||
continue
|
||||
if data.get("pool") == pool and key_of(str(data.get("source"))) == path.stem:
|
||||
found.append((mtime, path.stem, data))
|
||||
if not found:
|
||||
return None
|
||||
try:
|
||||
path.touch() # 최근에 쓴 것 = 정리 때 남김
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
_, key, data = max(found, key=lambda item: item[0])
|
||||
return key, data
|
||||
|
||||
|
||||
def write_stored(
|
||||
|
||||
@@ -108,6 +108,7 @@ def load_station_snap_step(project_root: Path) -> float | None:
|
||||
"""스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘).
|
||||
|
||||
10 만 그 값 · 칸 없음 · 옛 0.1 · 1 m 등 그 밖 = None(측점 No. · 280-4).
|
||||
짝 = `A00_Common/design/logic/design_pipeline_stations.ts` snapOptions(505-2 · 거울 시험).
|
||||
"""
|
||||
step = _read_design_curve_table(project_root).get("station_snap_step")
|
||||
return 10.0 if isinstance(step, int | float) and step == 10 else None
|
||||
|
||||
@@ -171,19 +171,29 @@ def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]:
|
||||
|
||||
|
||||
def followed_pipes(
|
||||
old_pipes: list, old_xy: list[list[float]], points: list[dict[str, float]], streams: list
|
||||
old_pipes: list,
|
||||
old_xy: list[list[float]],
|
||||
points: list[dict[str, float]],
|
||||
streams: list,
|
||||
dismissed: list | None = None,
|
||||
) -> tuple[list, int, list]:
|
||||
"""관 따라감 + 새 세류선 교차점 기본 관(저장 안 함) → (짝, 교차 수, 새 노선 정점) — 체인 ·
|
||||
추천 초기 종단(476) 한 벌."""
|
||||
추천 초기 종단(476) 한 벌. `dismissed` = 지움 표지(507-2 · 옛 노선 라벨 붙임을 제자리에)."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import _apply_new_defaults
|
||||
from common_util.common_util_drainage_pipes import fill_pipe_coordinates
|
||||
from common_util.common_util_stream_crossing import adopt_labels
|
||||
|
||||
vertices = vertices_of([(p["x"], p["y"]) for p in points])
|
||||
fill_pipe_coordinates(old_pipes, vertices_of(old_xy))
|
||||
held = [] if dismissed is None else dismissed
|
||||
if len(old_xy) >= 2: # 라벨 없는 옛 자동 관 · 지움 자리 = 옛 노선 교차 라벨(507-2)
|
||||
overlap = adopt_labels(old_pipes, held, _base_pipes(vertices_of(old_xy), streams), old_xy)
|
||||
if overlap:
|
||||
logger.warning("관 라벨: 세류선 교차 5 m 안 겹친 자동 관 %d개(안 지움)", overlap)
|
||||
pairs = follow_pipes(old_pipes, vertices)
|
||||
total = float(vertices[-1].chainage_m)
|
||||
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
|
||||
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total, held)
|
||||
# 새 교차 관만 B04 와 같은 등록부 기본값(54-39 · 494-2) — 옛 관 빈칸은 미확정 그대로
|
||||
_apply_new_defaults([pipe for index, pipe in pairs if index < 0])
|
||||
return pairs, crossing, vertices
|
||||
@@ -204,6 +214,7 @@ async def _place_pipes(
|
||||
old_pipes: list,
|
||||
old_xy: list[list[float]],
|
||||
points: list[dict[str, float]],
|
||||
dismissed: list | None = None,
|
||||
) -> tuple[list, dict[str, int]]:
|
||||
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).
|
||||
초기화(492)면 부르는 쪽이 옛 관 · 옛 선을 빈 목록으로 줌 = 새 노선 교차 관만.
|
||||
@@ -218,13 +229,17 @@ async def _place_pipes(
|
||||
|
||||
prepared = await _prepare(project_id)
|
||||
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] # 도엽 없음
|
||||
pairs, crossing, vertices = followed_pipes(old_pipes, old_xy, points, streams)
|
||||
held = [] if dismissed is None else dismissed
|
||||
pairs, crossing, vertices = await asyncio.to_thread(
|
||||
followed_pipes, old_pipes, old_xy, points, streams, held
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
save_pipe_points,
|
||||
stored_path,
|
||||
route_signature(vertices),
|
||||
[pipe for _, pipe in pairs],
|
||||
vertices,
|
||||
held,
|
||||
)
|
||||
line = [(p["x"], p["y"]) for p in points]
|
||||
outside = sum(route_outside(pipe.x, pipe.y, line) for _, pipe in pairs) # 459 · 측점 안 세움
|
||||
@@ -452,7 +467,11 @@ async def run_route_save_chain(
|
||||
from B06_Section.B06_Section_Router_Confirm import confirm_sections
|
||||
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
|
||||
from B06_Section.B06_Section_Schema import SectionConfirmRequest
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
pipe_points_path,
|
||||
read_dismissed,
|
||||
read_pipe_points_file,
|
||||
)
|
||||
from common_util.common_util_initial_snapshot import clear_designing, mark_designing
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
|
||||
@@ -509,8 +528,14 @@ async def run_route_save_chain(
|
||||
marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter()))
|
||||
|
||||
# 초기화 = 옛 관(옵션 · 사용자 관) 버림 · 새 노선 세류선 교차 관만(492)
|
||||
# 지움 표지도 — [적용] = 새 초기값(표지 없음 · 507-2)
|
||||
pairs, pipes = await _place_pipes(
|
||||
project_id, stored_path, [] if initial else old_pipes, [] if initial else old_xy, points
|
||||
project_id,
|
||||
stored_path,
|
||||
[] if initial else old_pipes,
|
||||
[] if initial else old_xy,
|
||||
points,
|
||||
[] if initial else read_dismissed(pipe_points_path(stored_path)),
|
||||
)
|
||||
marks.append(("관 따라감 · 교차점", time.perf_counter()))
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
|
||||
|
||||
- 관 = 좌표가 정본 — 새 노선에 투영해 누가거리만 다시(`project_pipe_points` · 정수 m). 지우지 않음
|
||||
(사용자 관 편집 지킴).
|
||||
- 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음).
|
||||
- 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음) ·
|
||||
같은 세류선 라벨 관(옮겼어도) · 지움 표지가 짝으로 잡은 교차점엔 안 더함(507-2 ·
|
||||
`common_util_stream_crossing`).
|
||||
- 관 최대 간격 보충 없음(369) — 간격 보충은 배수유역 [자동 제안]만.
|
||||
- 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
관 `pipe:<id>` · 구조물 `structure:<id>` · 시종점 `end:BP` · `end:EP` ·
|
||||
@@ -38,6 +40,7 @@ from common_util.common_util_station_key import (
|
||||
station_key,
|
||||
station_seat,
|
||||
)
|
||||
from common_util.common_util_stream_crossing import claim_crossings, crossing_labels
|
||||
from common_util.common_util_structure_station import plan_stations, snap_structure_station
|
||||
from config.config_system import DRAINAGE_PIPE_MIN_SPACING_M
|
||||
|
||||
@@ -78,18 +81,33 @@ def add_crossing_pipes(
|
||||
crossings: Sequence[Any],
|
||||
old_xy: Sequence[Sequence[float]] | None,
|
||||
total_m: float,
|
||||
dismissed: Sequence[dict[str, Any]] = (),
|
||||
) -> int:
|
||||
"""옛 노선에 없던 세류선 교차점에 관이 없으면 기본 관을 더함 · 더한 수."""
|
||||
"""옛 노선에 없던 세류선 교차점에 관이 없으면 기본 관(새 라벨)을 더함 · 더한 수."""
|
||||
old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None
|
||||
held = [pipe.stream_label for _, pipe in pairs if pipe.stream_label]
|
||||
claimed = claim_crossings([*held, *(mark for mark in dismissed if "stream" in mark)], crossings)
|
||||
labels = crossing_labels(crossings)
|
||||
added = 0
|
||||
for crossing in crossings:
|
||||
for index, crossing in enumerate(crossings):
|
||||
if index in claimed:
|
||||
continue # 같은 세류선 라벨 관(옮겼어도) · 지운 자동 관(507-2)
|
||||
if not _moved_away(old_line, crossing.x, crossing.y):
|
||||
continue # 옛 교차점 — 사용자가 지운 관을 되살리지 않음
|
||||
at = snap_structure_station(float(crossing.chainage_m), [total_m])
|
||||
if any(abs(pipe.chainage_m - at) < DRAINAGE_PIPE_MIN_SPACING_M for _, pipe in pairs):
|
||||
continue
|
||||
pairs.append(
|
||||
(-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_STREAM, x=crossing.x, y=crossing.y))
|
||||
(
|
||||
-1,
|
||||
PipePoint(
|
||||
chainage_m=at,
|
||||
source=PIPE_SOURCE_STREAM,
|
||||
x=crossing.x,
|
||||
y=crossing.y,
|
||||
stream_label=labels[index],
|
||||
),
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
pairs.sort(key=lambda pair: pair[1].chainage_m)
|
||||
|
||||
@@ -236,6 +236,15 @@ async def reset_route_design(
|
||||
project_id: UUID,
|
||||
session: dict[str, Any] | None = Depends(get_optional_session), # 사본 가져온이
|
||||
) -> JSONResponse:
|
||||
"""[초기화] — [저장] · [확정]과 같은 프로젝트 잠금 안(505-2 · 저장 도중 초기화가 끼어 서로 덮지 않음 ·
|
||||
몸통 `_reset_route_design` · 안에서 같은 잠금 안 잡음)."""
|
||||
from common_util import common_util_save_guard as guard
|
||||
|
||||
async with guard.project_lock(str(project_id)):
|
||||
return await _reset_route_design(project_id, session)
|
||||
|
||||
|
||||
async def _reset_route_design(project_id: UUID, session: dict[str, Any] | None) -> JSONResponse:
|
||||
"""B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼).
|
||||
|
||||
**초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계
|
||||
|
||||
@@ -63,6 +63,8 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
|
||||
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
|
||||
# 고른 분석 지형의 지표면 모델이 DB 에 없음(505-2 5d)
|
||||
TERRAIN_MODEL_MISSING = "고른 분석 지형의 지표면 모델이 없습니다 — 전처리에서 만든 뒤 다시 하세요."
|
||||
|
||||
|
||||
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
|
||||
@@ -336,7 +338,11 @@ async def _recompute(
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음
|
||||
switched = await switch_design_terrain(connection, project_id, terrain)
|
||||
try:
|
||||
switched = await switch_design_terrain(connection, project_id, terrain)
|
||||
except LookupError: # 고른 지형의 DB 모델 없음 — 409 사유 한 줄(505-2 5d)
|
||||
await connection.rollback()
|
||||
return {"error": TERRAIN_MODEL_MISSING}
|
||||
await connection.commit()
|
||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
@@ -448,10 +454,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
if paths is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
project_root, _ = paths
|
||||
# B05 열 때 추천 저장본이 없거나 판이 다르면 뒤에서 미리 셈(364-4 · 있으면 꺼내기만)
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
schedule_precompute(project_id)
|
||||
# 추천 미리 셈은 전처리 체인에서만(505-1) — B05 열기는 저장본을 안 셈 · 안 씀
|
||||
# 예상노선 정본이 먼저, 없으면 옛 자리(초기값 스냅샷)를 본다.
|
||||
expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root))
|
||||
if not expected:
|
||||
@@ -634,7 +637,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
|
||||
if request.balanced_profile and as_initial
|
||||
else None
|
||||
),
|
||||
request.terrain,
|
||||
request.terrain if as_initial else None, # 지형 바꿈 = 추천 [적용]만(509-5)
|
||||
(old_line, await asyncio.to_thread(_vertices_of, working_path)),
|
||||
as_initial,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -25,6 +26,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜)
|
||||
_RUNNING: dict[str, dict[str, Any]] = {}
|
||||
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음
|
||||
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다."
|
||||
|
||||
|
||||
@replan.router.post("/{project_id}/route/initial", response_model=None)
|
||||
@@ -40,12 +43,15 @@ async def initial_route(
|
||||
|
||||
|
||||
async def compute_initial(
|
||||
project_id: UUID, request: replan.RouteInitialRequest
|
||||
project_id: UUID, request: replan.RouteInitialRequest, precompute: bool = False
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로.
|
||||
|
||||
응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음).
|
||||
`recompute` = [재계산] — 묶음 잠금 안에서 이 묶음 저장본 · 메모리 묶음을 버리고 새로 셈(420).
|
||||
미리 셈 갈래(기본 · 저장 제약 · 종단 켬 · 설계 지형)는 `precompute`(전처리 체인)만 셈 — 화면
|
||||
요청은 저장본만(엔진 소스 지문이 달라도 · 505-1 덧) · 없으면 409 `NOT_PREPARED`(505-1).
|
||||
사용자가 바꾼 값 · [재계산]은 그 자리 셈.
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
@@ -63,12 +69,14 @@ async def compute_initial(
|
||||
is_default,
|
||||
pool_lock,
|
||||
read_stored,
|
||||
read_stored_any,
|
||||
request_fingerprint,
|
||||
write_stored,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial_Initial import initial_profile_of
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
analysis_terrain_params,
|
||||
design_terrain,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
|
||||
@@ -110,7 +118,9 @@ async def compute_initial(
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||
selection = analysis_terrain_params(selection, request.terrain)
|
||||
# 화면은 늘 분석 지형을 실음 — 설계 지형과 같으면 설계 지형(미리 셈 갈래 판정 · 505-1)
|
||||
picked = None if request.terrain == design_terrain(selection) else request.terrain
|
||||
selection = analysis_terrain_params(selection, picked)
|
||||
inputs = {
|
||||
"mode": request.mode,
|
||||
"weights": weights,
|
||||
@@ -124,6 +134,13 @@ async def compute_initial(
|
||||
# 균형 종단 · 횡단 토공 표준 횡단면 = 프로젝트 저장 패널값(420)
|
||||
"standard": await project_standard(project_id),
|
||||
}
|
||||
criteria_dump = criteria.model_dump() if criteria else None
|
||||
may_compute = (
|
||||
precompute
|
||||
or request.recompute
|
||||
or not request.with_profile
|
||||
or not is_default(request.mode, weights, criteria_dump, picked, current)
|
||||
)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
terrain, streams = await asyncio.to_thread(
|
||||
@@ -139,6 +156,21 @@ async def compute_initial(
|
||||
logger.info("추천 [재계산]: project_id=%s 저장본 %d 지움", project_id, removed)
|
||||
forget_pool_on_standard(pool_key, inputs["standard"])
|
||||
stored = await asyncio.to_thread(read_stored, project_root, key)
|
||||
if stored is None and not may_compute:
|
||||
# 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧)
|
||||
found = await asyncio.to_thread(
|
||||
read_stored_any,
|
||||
project_root,
|
||||
pool_key,
|
||||
lambda source: request_fingerprint(
|
||||
route, terrain, streams, **inputs, source=source
|
||||
)[0],
|
||||
)
|
||||
if found is None:
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "error", "message": NOT_PREPARED}
|
||||
)
|
||||
key, stored = found
|
||||
if stored is not None:
|
||||
result = stored["result"]
|
||||
else:
|
||||
@@ -157,13 +189,7 @@ async def compute_initial(
|
||||
pool_key,
|
||||
request.model_dump(exclude={"recompute"}),
|
||||
result,
|
||||
is_default(
|
||||
request.mode,
|
||||
weights,
|
||||
criteria.model_dump() if criteria else None,
|
||||
request.terrain,
|
||||
current,
|
||||
),
|
||||
is_default(request.mode, weights, criteria_dump, picked, current),
|
||||
)
|
||||
# 측점 맞춘 선 · 맞추기 전 선(351-2) 둘 다 덮개 수를 지금 관으로
|
||||
shown = [result, *([result["unsnapped"]] if result.get("unsnapped") else [])]
|
||||
@@ -180,7 +206,7 @@ async def compute_initial(
|
||||
result = {
|
||||
**result,
|
||||
"initial_profile": await initial_profile_of( # 476
|
||||
project_id, project_root, paths[1], result, request, selection, key
|
||||
project_id, project_root, paths[1], result, request, selection, key, may_compute
|
||||
),
|
||||
}
|
||||
logger.info(
|
||||
@@ -224,7 +250,8 @@ async def project_standard(project_id: UUID) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
async def precompute_defaults(project_id: UUID) -> None:
|
||||
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯.
|
||||
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯 ×
|
||||
고를 수 있는 지형(509-5 · 설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만).
|
||||
|
||||
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
|
||||
다섯 갈래 모두 종단 켬(420) · 첫머리에 30일 지난 다른 판 · 소스 지문 저장본을 지운다(420-2).
|
||||
@@ -235,11 +262,13 @@ async def precompute_defaults(project_id: UUID) -> None:
|
||||
started = time.perf_counter()
|
||||
variants: list[tuple[Any, Any]] = [(None, None)]
|
||||
modes = list(DEFAULT_MODES)
|
||||
terrains: list[Any] = [None]
|
||||
try:
|
||||
paths = await replan._project_paths(project_id)
|
||||
except LookupError:
|
||||
paths = None
|
||||
if paths is not None:
|
||||
terrains = await precompute_terrains(project_id, paths[0])
|
||||
removed = await asyncio.to_thread(purge_outdated, paths[0])
|
||||
if removed:
|
||||
logger.info("추천 옛 판 저장본 지움: project_id=%s %d", project_id, removed)
|
||||
@@ -251,29 +280,51 @@ async def precompute_defaults(project_id: UUID) -> None:
|
||||
modes.sort(key=lambda mode: mode != saved["logic"] or mode == "follow")
|
||||
progress = _RUNNING.get(str(project_id))
|
||||
if progress is not None:
|
||||
progress.update(total=len(variants) * len(modes))
|
||||
for criteria, weights in variants:
|
||||
for mode in modes:
|
||||
if progress is not None:
|
||||
progress["mode"] = mode
|
||||
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420)
|
||||
request = replan.RouteInitialRequest(
|
||||
mode=mode,
|
||||
with_profile=True,
|
||||
criteria=criteria,
|
||||
weights=weights if mode == "weighted" else None,
|
||||
progress.update(total=len(terrains) * len(variants) * len(modes))
|
||||
for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes):
|
||||
if progress is not None:
|
||||
progress["mode"] = mode
|
||||
# 화면과 같은 몸통 — 창 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420)
|
||||
request = replan.RouteInitialRequest(
|
||||
mode=mode,
|
||||
with_profile=True,
|
||||
criteria=criteria,
|
||||
weights=weights if mode == "weighted" else None,
|
||||
terrain=terrain,
|
||||
)
|
||||
outcome = await compute_initial(project_id, request, precompute=True)
|
||||
if isinstance(outcome, JSONResponse):
|
||||
logger.warning(
|
||||
"추천 미리 셈 멈춤(%s %s %s): project_id=%s",
|
||||
terrain,
|
||||
mode,
|
||||
outcome.status_code,
|
||||
project_id,
|
||||
)
|
||||
outcome = await compute_initial(project_id, request)
|
||||
if isinstance(outcome, JSONResponse):
|
||||
logger.warning(
|
||||
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
|
||||
)
|
||||
return
|
||||
if progress is not None:
|
||||
progress["done"] += 1
|
||||
return
|
||||
if progress is not None:
|
||||
progress["done"] += 1
|
||||
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
|
||||
|
||||
|
||||
async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any]:
|
||||
"""미리 셈 지형(509-5) — 설계 지형 먼저 · 추천 창이 고를 수 있는 다른 지형."""
|
||||
from B05_Profile.B05_Profile_Router_Terrain import _MODELS_SUBDIR, choosable_terrains
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
design_terrain,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
sources = await choosable_terrains(
|
||||
connection, project_id, params, Path(project_root) / _MODELS_SUBDIR
|
||||
)
|
||||
design = design_terrain(params)
|
||||
return [design, *[source for source in sources if source != design]]
|
||||
|
||||
|
||||
async def run_precompute(project_id: UUID) -> None:
|
||||
"""미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음)."""
|
||||
key = str(project_id)
|
||||
@@ -304,8 +355,9 @@ async def initial_progress(project_id: UUID) -> dict[str, Any]:
|
||||
|
||||
|
||||
def schedule_precompute(project_id: UUID) -> None:
|
||||
"""추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1) · B05 열 때(364-4). 기다리지 않음 ·
|
||||
이미 돌면 끝난 뒤 한 번 더 · 실패는 로그만. 저장본이 다 있으면 꺼내기만(갈래마다 0.2 s 안팎)."""
|
||||
"""추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1)만 · B05 열기는 안 부름(505-1).
|
||||
기다리지 않음 · 이미 돌면 끝난 뒤 한 번 더 · 실패는 로그만. 저장본이 다 있으면 꺼내기만
|
||||
(갈래마다 0.2 s 안팎)."""
|
||||
try:
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ async def initial_profile_of(
|
||||
request: Any,
|
||||
selection: dict[str, Any],
|
||||
key: str,
|
||||
compute: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""추천 후보 초기 종단(476 · `B05_Profile_Engine_InitialProfile` 머리) — [적용] 체인과 같은
|
||||
입력 · 같은 조각 · 지문 같으면 저장본 꺼냄. 실패 = `{error}`."""
|
||||
입력 · 같은 조각 · 지문 같으면 저장본 꺼냄. 실패 = `{error}`. `compute` 거짓 = 저장본만
|
||||
(505-1)."""
|
||||
from dataclasses import asdict
|
||||
|
||||
import aiomysql
|
||||
@@ -43,7 +45,11 @@ async def initial_profile_of(
|
||||
from B05_Profile.B05_Profile_Engine_Sections import _MODELS_SUBDIR
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
from B05_Profile.B05_Profile_Route_Save import section_seed
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import balanced_profile_of, project_standard
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import (
|
||||
NOT_PREPARED,
|
||||
balanced_profile_of,
|
||||
project_standard,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
get_route_generation_source,
|
||||
@@ -88,30 +94,33 @@ async def initial_profile_of(
|
||||
crs = f"EPSG:{epsg}" if epsg is not None else None
|
||||
surface = {k: selection.get(k) for k in ("source_filter", "method", "smooth")}
|
||||
models = project_root / _MODELS_SUBDIR
|
||||
route_path = str(latest.get("route_data_path") or "")
|
||||
mark = ip.fingerprint(
|
||||
{
|
||||
"key": key,
|
||||
"nodes": result["nodes"],
|
||||
"curves": result.get("curves"),
|
||||
"radius": radius_m,
|
||||
"surface": surface,
|
||||
"models": ip.stat_marks(list(models.glob(f"*_{surface['source_filter']}*.npz"))),
|
||||
"streams": ip.stat_marks([_sheet_dir(stored_path) / STREAM_FILE]),
|
||||
# 초기화(492) = 저장 관 · 구조물 · 추가 측점을 안 씀 — 노선 파일만
|
||||
"files": ip.file_marks([project_root / route_path]),
|
||||
"options": asdict(options),
|
||||
"grade": grade_options.as_dict() if grade_options else None,
|
||||
"crs": crs,
|
||||
"with_profile": request.with_profile,
|
||||
"criteria": request.criteria.model_dump() if request.criteria else None,
|
||||
"standard": await project_standard(project_id),
|
||||
"road": await replan._road_settings(project_id),
|
||||
}
|
||||
parts = {
|
||||
"key": key,
|
||||
"nodes": result["nodes"],
|
||||
"curves": result.get("curves"),
|
||||
"radius": radius_m,
|
||||
"surface": surface,
|
||||
"models": ip.stat_marks(list(models.glob(f"*_{surface['source_filter']}*.npz"))),
|
||||
"streams": ip.stat_marks([_sheet_dir(stored_path) / STREAM_FILE]),
|
||||
# 저장 노선 파일은 안 넣음(505-1) — 초기화(492) 셈은 노선 파일 · 관 · 구조물 ·
|
||||
# 추가 측점을 안 읽음(선 = 후보 노드) · 넣으면 노선 [저장]마다 다시 셈
|
||||
"options": asdict(options),
|
||||
"grade": grade_options.as_dict() if grade_options else None,
|
||||
"crs": crs,
|
||||
"with_profile": request.with_profile,
|
||||
"criteria": request.criteria.model_dump() if request.criteria else None,
|
||||
"standard": await project_standard(project_id),
|
||||
"road": await replan._road_settings(project_id),
|
||||
}
|
||||
mark = ip.fingerprint(parts)
|
||||
# 프로젝트 접근(compute 거짓) = 코드 지문 안 견줌(505-1 덧)
|
||||
stored = await asyncio.to_thread(
|
||||
ip.read_initial, project_root, key, mark, None if compute else parts
|
||||
)
|
||||
stored = await asyncio.to_thread(ip.read_initial, project_root, key, mark)
|
||||
if stored is not None:
|
||||
return stored
|
||||
if not compute:
|
||||
return {"error": NOT_PREPARED}
|
||||
prepared = await _prepare(project_id)
|
||||
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"]
|
||||
facilities = await asyncio.to_thread(ip.reset_facilities, line, streams)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag)
|
||||
|
||||
분석 지형(`terrain`, PLAN 133-1) — las = 라이다 · sheet = 수치지형도 · None = 지금 설계 지형.
|
||||
모달 분석만 고른 지형을 쓴다 — 설계 지형(확정)은 [확인] 때만 바뀜(133-2 · `/route/replan`).
|
||||
고르기는 추천 노선 창 안(509-5) — 설계 지형(확정)은 추천 [적용] 때만 바뀜(`/route/replan`).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -116,7 +116,7 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
|
||||
|
||||
@router.get("/{project_id}/route/terrain-sources", response_model=None)
|
||||
async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
|
||||
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김."""
|
||||
"""추천 창이 고를 수 있는 분석 지형(509-5 · 둘이면 고르기) · 설계 지형 · 설계 지형 격자 자리."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -127,8 +127,8 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
confirmed = await _confirmed_model(connection, project_id)
|
||||
models_dir = Path(resolve_stored_project_path(stored)) / _MODELS_SUBDIR
|
||||
sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir)
|
||||
models_dir = Path(resolve_stored_project_path(stored)) / _MODELS_SUBDIR
|
||||
sources = await choosable_terrains(connection, project_id, params, models_dir)
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
@@ -138,6 +138,24 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
|
||||
}
|
||||
|
||||
|
||||
async def choosable_terrains(
|
||||
connection: aiomysql.Connection, project_id: UUID, params: dict, models_dir: Path
|
||||
) -> list[str]:
|
||||
"""고를 수 있는 분석 지형 — 설계 지형 아닌 쪽은 DB 완료 모델이 있어야 둠(505-2 5d) · 추천 창
|
||||
고르기 · 지형마다 추천 미리 셈(509-5)이 같이 씀."""
|
||||
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
|
||||
|
||||
sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir)
|
||||
for source in [s for s in sources if s != design_terrain(params)]:
|
||||
try:
|
||||
await find_surface_model_for_selection(
|
||||
connection, project_id, analysis_terrain_params(params, source)
|
||||
)
|
||||
except LookupError:
|
||||
sources.remove(source)
|
||||
return sources
|
||||
|
||||
|
||||
async def _confirmed_model(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> tuple[int, str, str] | None:
|
||||
|
||||
@@ -44,6 +44,12 @@ interface CacheEntry {
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
/** 지금 노선 한 벌만 — 노선 [저장]마다 새 노선 번호라 옛 칸이 쌓임(298-3). */
|
||||
function remember(key: string, entry: CacheEntry): void {
|
||||
cache.clear();
|
||||
cache.set(key, entry);
|
||||
}
|
||||
|
||||
function keyOf(projectId: string, routeId: number): string {
|
||||
return `${projectId}:${routeId}`;
|
||||
}
|
||||
@@ -158,7 +164,7 @@ export async function ensureCorridor(
|
||||
if (lookup.kind === "envelope" && lookup.envelope.hash === hash) {
|
||||
try {
|
||||
const build = deserialize(lookup.envelope);
|
||||
cache.set(key, { hash, build });
|
||||
remember(key, { hash, build });
|
||||
markSource("stored", hash);
|
||||
return build;
|
||||
} catch {
|
||||
@@ -182,7 +188,7 @@ export async function ensureCorridor(
|
||||
}
|
||||
// 미리보기 — 메모리에만(올리지 않음 · 서버가 [저장]·[확정] 때 정본으로 지음)
|
||||
markSource("built", hash);
|
||||
cache.set(key, { hash, build });
|
||||
remember(key, { hash, build });
|
||||
return build;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { calcOnServer } from "../A00_Common/design/design_calc_sites";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
@@ -48,12 +49,14 @@ function getWorker(): Worker | null {
|
||||
return worker;
|
||||
}
|
||||
|
||||
/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). */
|
||||
/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수).
|
||||
* 계산 자리 판(504-2) D3 = server 면 안 지음(null) — 서버가 [저장] · [확정] 때 지은 모형만. */
|
||||
export async function buildCorridorOffThread(
|
||||
crossSections: CrossSection[],
|
||||
routePoints: RoutePoint[],
|
||||
designSamples?: Samples,
|
||||
): Promise<CorridorBuildResult | null> {
|
||||
if (await calcOnServer("corridor_mesh")) return null;
|
||||
const onMain = (): CorridorBuildResult | null =>
|
||||
buildCorridor(crossSections, routePoints, designSamples);
|
||||
const target = getWorker();
|
||||
|
||||
@@ -10,7 +10,11 @@ import { stateKey } from "../A00_Common/b_page_state";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
DRAINAGE_SHEET_LAYERS,
|
||||
fetchCachedSheetLayer,
|
||||
type SheetScope,
|
||||
} from "../A00_Common/b_asset_cache";
|
||||
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
computeMapRect,
|
||||
@@ -439,10 +443,12 @@ export function reconcilePipes(
|
||||
}
|
||||
|
||||
/** 배경지도 메타와 도엽 레이어를 한 번에 받아 온다. 한 레이어가 실패해도 나머지는 살린다.
|
||||
* 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다. */
|
||||
* 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다.
|
||||
* `scope` = 서버가 자른 범위(기본 배수유역도 = 유역 + 여유 · 노선 편집 = "route" · null = 통째). */
|
||||
export async function fetchDrainageLayers(
|
||||
projectId: string,
|
||||
onProgress: (ratio: number) => void,
|
||||
scope: SheetScope | null = "drainage",
|
||||
): Promise<{
|
||||
meta: VWorldMeta;
|
||||
layers: Array<readonly [DrainageLayer, GeoJsonCollection | null]>;
|
||||
@@ -453,7 +459,12 @@ export async function fetchDrainageLayers(
|
||||
DRAINAGE_LAYERS.map(async (layer) => {
|
||||
try {
|
||||
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
|
||||
return [layer, await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)] as const;
|
||||
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
layer,
|
||||
scope ?? undefined,
|
||||
);
|
||||
return [layer, collection] as const;
|
||||
} catch {
|
||||
return [layer, null] as const;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSelectField } from "@ui/ui_template_elements";
|
||||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||||
import {
|
||||
L,
|
||||
PROFILE_CRITERIA,
|
||||
@@ -137,13 +138,13 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
const gradeSummary = document.createElement("summary");
|
||||
gradeSummary.textContent = "기준값 직접 지정";
|
||||
// 칸을 펼 때 · 펴 둔 동안 누름 · 키 뒤마다 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤)
|
||||
// ponytail: 문서 듣기는 페이지마다 하나 · 떼지 않음(가벼움) — 새는 게 보이면 페이지 수명에 묶을 것
|
||||
// 문서 듣기는 칸 수명 — 페이지를 다시 세워도 옛 칸을 붙들지 않음(298-3)
|
||||
gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets());
|
||||
const refreshSoon = (): void => {
|
||||
if (gradeAdvanced.isConnected && gradeAdvanced.open) requestAnimationFrame(showEndOffsets);
|
||||
};
|
||||
document.addEventListener("pointerup", refreshSoon);
|
||||
document.addEventListener("keyup", refreshSoon);
|
||||
listenWhileAlive(document, "pointerup", gradeAdvanced, refreshSoon);
|
||||
listenWhileAlive(document, "keyup", gradeAdvanced, refreshSoon);
|
||||
gradeAdvanced.append(
|
||||
gradeSummary,
|
||||
maxGradePct.wrapper,
|
||||
|
||||
@@ -22,8 +22,12 @@
|
||||
|
||||
.b05-crossmodal__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.b05-crossmodal__station {
|
||||
@@ -39,16 +43,26 @@
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* B06 카드 읽기 전용(505-3) — 칸 테두리 안 · 고름 강조 · 손가락 없음 */
|
||||
.b05-crossmodal__box > .b06-cross-card {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.b05-crossmodal__foot {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 단추 = 제목 줄 안 오른쪽 맞춤(500-1) */
|
||||
.b05-crossmodal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8, 8px);
|
||||
margin-left: auto;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 임시 횡단이 열리는 자리 가까이 표시(490-1) — 누름은 아래 그래프 몫(통과) · 세로 축(5) 밑 */
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침
|
||||
* (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산
|
||||
* `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` ·
|
||||
* 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
|
||||
* 저장 계획선 기준 · 글 한 줄) · 그림 = 평면 「횡단」 판과 같은 B06 카드(구조물 형상 · 포장 · 면적 · 505-3 ·
|
||||
* 서버 미리 뜬 것만 선 그림 `buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
|
||||
* [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가
|
||||
* 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1).
|
||||
* ========================================================================== */
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from "../A00_Common/design/logic/design_profile_free";
|
||||
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
|
||||
import { pagePipeline, pipelineDetail } from "../A00_Common/design/design_pipeline_page";
|
||||
import { pipelineCrossAtChainage } from "../A00_Common/design/design_pipeline_probe";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations";
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
CROSS_PLOT_PAD,
|
||||
summarizeCross,
|
||||
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
||||
import { crossSourceOf, routeCrossCard } from "./B05_Profile_UI_RouteEdit_Cross_Card";
|
||||
import {
|
||||
crossPickAt,
|
||||
groundElevationAt,
|
||||
@@ -57,11 +59,17 @@ export function crossPickFromEvent(
|
||||
): { chainageM: number; groundY: number | null } | null {
|
||||
const target = event.target as Element;
|
||||
const marker = target.closest(".b06-chart__station");
|
||||
const plusRaw = marker?.querySelector(".b06-chart__station-line--plus")
|
||||
const plusRaw = marker?.hasAttribute("data-plus")
|
||||
? Number(marker.getAttribute("data-chainage"))
|
||||
: null;
|
||||
const hit: CrossPickHit =
|
||||
plusRaw !== null ? { plusM: plusRaw } : marker || target.closest("button") ? "other" : null;
|
||||
plusRaw !== null
|
||||
? { plusM: plusRaw }
|
||||
: target.closest("button")
|
||||
? "other"
|
||||
: marker
|
||||
? "station"
|
||||
: null;
|
||||
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
|
||||
const read = (name: string): number => Number(svg?.getAttribute(name));
|
||||
const view: ChartWindow = {
|
||||
@@ -93,7 +101,7 @@ export function crossPickFromEvent(
|
||||
}
|
||||
|
||||
/** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1).
|
||||
* 멀어짐 · 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */
|
||||
* 멀어짐 · 띠 밖 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */
|
||||
export function mountCrossHint(
|
||||
chart: HTMLElement,
|
||||
scroller: HTMLElement,
|
||||
@@ -130,6 +138,50 @@ export function mountCrossHint(
|
||||
chart.addEventListener("pointerleave", hide);
|
||||
}
|
||||
|
||||
/** 제목 줄 끌어 창 옮김(500-1) — 종단 칸 · 화면 안으로 가둠 · 단추 누름은 단추 몫 · 돌려줌 = 끌어 둔 자리 다시 가둠. */
|
||||
function bindHeadDrag(root: HTMLElement, head: HTMLElement): () => void {
|
||||
let start: { id: number; x: number; y: number; left: number; top: number } | null = null;
|
||||
const put = (left: number, top: number): void => {
|
||||
const box = root.parentElement!.getBoundingClientRect();
|
||||
const now = root.getBoundingClientRect();
|
||||
const [dx, dy] = [left - root.offsetLeft, top - root.offsetTop];
|
||||
const [minX, maxX] = [
|
||||
Math.max(box.left, 0) - now.left,
|
||||
Math.min(box.right, innerWidth) - now.right,
|
||||
];
|
||||
const [minY, maxY] = [
|
||||
Math.max(box.top, 0) - now.top,
|
||||
Math.min(box.bottom, innerHeight) - now.bottom,
|
||||
];
|
||||
root.style.left = `${root.offsetLeft + Math.max(minX, Math.min(dx, maxX))}px`;
|
||||
root.style.top = `${root.offsetTop + Math.max(minY, Math.min(dy, maxY))}px`;
|
||||
root.style.right = "auto";
|
||||
};
|
||||
head.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0 || (event.target as Element).closest("button")) return;
|
||||
start = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: root.offsetLeft,
|
||||
top: root.offsetTop,
|
||||
};
|
||||
head.setPointerCapture(event.pointerId);
|
||||
});
|
||||
head.addEventListener("pointermove", (event) => {
|
||||
if (start?.id !== event.pointerId) return;
|
||||
put(start.left + event.clientX - start.x, start.top + event.clientY - start.y);
|
||||
});
|
||||
const end = (event: PointerEvent): void => {
|
||||
if (start?.id === event.pointerId) start = null;
|
||||
};
|
||||
head.addEventListener("pointerup", end);
|
||||
head.addEventListener("pointercancel", end);
|
||||
return () => {
|
||||
if (root.style.left) put(root.offsetLeft, root.offsetTop);
|
||||
};
|
||||
}
|
||||
|
||||
interface CrossModal {
|
||||
open(request: CrossModalOpen): void;
|
||||
}
|
||||
@@ -145,14 +197,14 @@ function createCrossModal(host: HTMLElement): CrossModal {
|
||||
<div class="b05-crossmodal__head">
|
||||
<strong>임시 횡단</strong>
|
||||
<span class="b05-crossmodal__station"></span>
|
||||
<div class="b05-crossmodal__actions">
|
||||
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="add">측점 추가</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="remove">삭제</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost ui-btn--sm" data-act="close">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-crossmodal__box"></div>
|
||||
<div class="b05-crossmodal__foot"></div>
|
||||
<div class="b05-crossmodal__actions">
|
||||
<button type="button" class="ui-button" data-act="add">측점 추가</button>
|
||||
<button type="button" class="ui-button" data-act="remove">삭제</button>
|
||||
<button type="button" class="ui-button" data-act="close">닫기</button>
|
||||
</div>`;
|
||||
<div class="b05-crossmodal__foot"></div>`;
|
||||
const $ = <T extends HTMLElement>(selector: string): T => root.querySelector<T>(selector)!;
|
||||
const station = $(".b05-crossmodal__station");
|
||||
const box = $(".b05-crossmodal__box");
|
||||
@@ -164,6 +216,7 @@ function createCrossModal(host: HTMLElement): CrossModal {
|
||||
root.addEventListener(type, (event) => event.stopPropagation()),
|
||||
);
|
||||
host.append(root);
|
||||
const refit = bindHeadDrag(root, $(".b05-crossmodal__head"));
|
||||
|
||||
let current: CrossModalOpen | null = null;
|
||||
let turn = 0;
|
||||
@@ -190,7 +243,7 @@ function createCrossModal(host: HTMLElement): CrossModal {
|
||||
const now = await pipelineCrossAtChainage(projectId(), m, current?.intervalM ?? 20).catch(
|
||||
() => null,
|
||||
);
|
||||
const [cross] = now ? [now] : await previewStructureSections(projectId(), [m]);
|
||||
const [cross] = now ? [now.preview] : await previewStructureSections(projectId(), [m]);
|
||||
if (mine !== turn) return;
|
||||
if (!cross) {
|
||||
foot.textContent = "이 자리의 횡단을 뜨지 못했습니다.";
|
||||
@@ -198,21 +251,37 @@ function createCrossModal(host: HTMLElement): CrossModal {
|
||||
}
|
||||
const preview = cross as unknown as CrossPreviewResponse;
|
||||
const rect = box.getBoundingClientRect();
|
||||
const plot = buildCrossSvg(
|
||||
preview,
|
||||
Math.round(rect.width) || 320,
|
||||
Math.round(rect.height) || 180,
|
||||
);
|
||||
box.replaceChildren(plot.svg);
|
||||
attachSvgZoomPan({
|
||||
svg: plot.svg,
|
||||
layer: plot.layer,
|
||||
widthPx: plot.width,
|
||||
heightPx: plot.height,
|
||||
pad: CROSS_PLOT_PAD,
|
||||
content: plot.content,
|
||||
wheelZoom: true,
|
||||
});
|
||||
const [width, height] = [Math.round(rect.width) || 320, Math.round(rect.height) || 180];
|
||||
if (now) {
|
||||
// 「횡단」 판과 같은 B06 카드(505-3) — 그림 밖 높이만큼 줄여 한 번 더 지어 칸에 맞춤
|
||||
const detail = pipelineDetail(projectId());
|
||||
const source = crossSourceOf(
|
||||
projectId(),
|
||||
now.section,
|
||||
detail?.cross_sections ?? [now.section],
|
||||
detail?.longitudinal.design_profiles,
|
||||
"modal",
|
||||
);
|
||||
const build = (plotHeight: number): HTMLElement => {
|
||||
const card = routeCrossCard(source, current?.intervalM ?? 20, width, plotHeight);
|
||||
box.replaceChildren(card);
|
||||
return card;
|
||||
};
|
||||
const extra = build(height).offsetHeight - height;
|
||||
if (extra > 0) build(Math.max(120, height - extra));
|
||||
} else {
|
||||
const plot = buildCrossSvg(preview, width, height);
|
||||
box.replaceChildren(plot.svg);
|
||||
attachSvgZoomPan({
|
||||
svg: plot.svg,
|
||||
layer: plot.layer,
|
||||
widthPx: plot.width,
|
||||
heightPx: plot.height,
|
||||
pad: CROSS_PLOT_PAD,
|
||||
content: plot.content,
|
||||
wheelZoom: true,
|
||||
});
|
||||
}
|
||||
const planZ = Number(
|
||||
(preview.design as { design_elevation_m?: unknown } | null)?.design_elevation_m,
|
||||
);
|
||||
@@ -272,6 +341,7 @@ function createCrossModal(host: HTMLElement): CrossModal {
|
||||
current = request;
|
||||
station.textContent = stationLabel(request.chainageM, request.intervalM);
|
||||
root.hidden = false;
|
||||
refit();
|
||||
syncButtons();
|
||||
watchPipe();
|
||||
void draw(request.chainageM);
|
||||
|
||||
@@ -294,6 +294,11 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
),
|
||||
selectedStationId,
|
||||
stationDisplay,
|
||||
mcKeys: new Set(
|
||||
(longitudinal.stations ?? [])
|
||||
.filter((station) => station.kind === "curve")
|
||||
.map((station) => station.chainage_m.toFixed(3)),
|
||||
),
|
||||
onCurveRadiusChange: (curve, radius) =>
|
||||
ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
|
||||
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
|
||||
@@ -308,36 +313,43 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
chartWrap.style.height = `${chartHeight}px`;
|
||||
// 빈 곳 누름은 고름을 안 풂 — 풀기는 Esc · 같은 것 다시 누름 한 길(3D 와 같은 규칙 · 233-4).
|
||||
// 도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 그 도구 몫 그대로.
|
||||
chartWrap.addEventListener("click", (event) => {
|
||||
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
|
||||
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
|
||||
if (ctx.toolActive()) {
|
||||
const marker = (event.target as HTMLElement).closest(".b06-chart__station");
|
||||
const raw = marker?.getAttribute("data-chainage");
|
||||
if (raw !== null && raw !== undefined) {
|
||||
if (ctx.handleToolPick(Number(raw))) return;
|
||||
} else {
|
||||
const rect = chartWrap.getBoundingClientRect();
|
||||
const chainage = chainageInverter(
|
||||
longitudinal,
|
||||
width,
|
||||
layout.originOffset,
|
||||
)(event.clientX - rect.left + chartWrap.scrollLeft);
|
||||
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
|
||||
// 잡는 단계(capture) — 띠 안 측점선 누름은 임시 횡단이 먼저라 측점선 자기 고름을 막아야 함(500-2).
|
||||
chartWrap.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
|
||||
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
|
||||
if (ctx.toolActive()) {
|
||||
const marker = (event.target as HTMLElement).closest(".b06-chart__station");
|
||||
const raw = marker?.getAttribute("data-chainage");
|
||||
if (raw !== null && raw !== undefined) {
|
||||
if (ctx.handleToolPick(Number(raw))) return;
|
||||
} else {
|
||||
const rect = chartWrap.getBoundingClientRect();
|
||||
const chainage = chainageInverter(
|
||||
longitudinal,
|
||||
width,
|
||||
layout.originOffset,
|
||||
)(event.clientX - rect.left + chartWrap.scrollLeft);
|
||||
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3)
|
||||
// 다른 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼)
|
||||
const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset);
|
||||
const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples);
|
||||
if (at === null) return;
|
||||
openCrossModal(body.parentElement ?? body, {
|
||||
chainageM: at.chainageM,
|
||||
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
|
||||
intervalM: stationIntervalM ?? 20,
|
||||
});
|
||||
});
|
||||
// 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3)
|
||||
// 띠 밖 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼) — 띠 안 측점선은 임시 횡단이 먼저(500-2)
|
||||
const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset);
|
||||
const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples);
|
||||
if (at === null) return;
|
||||
const marker = (event.target as Element).closest(".b06-chart__station");
|
||||
if (marker && !marker.hasAttribute("data-plus")) event.stopPropagation();
|
||||
openCrossModal(body.parentElement ?? body, {
|
||||
chainageM: at.chainageM,
|
||||
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
|
||||
intervalM: stationIntervalM ?? 20,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
const derived = deriveProfile(ctx, detail, alignment, irregularStations);
|
||||
const { designProfiles, graphData, graphLongitudinal } = derived;
|
||||
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface ProfileTableOptions {
|
||||
irregularStations?: IrregularStation[];
|
||||
/** 현재 선택된 측점 id. 규칙/비정규 측점 모두 그 측점의 값 열을 테이블에 겹쳐 보여준다. */
|
||||
selectedStationId?: string | null;
|
||||
/** MC(곡선 가운데) 측점 누가거리(`toFixed(3)` 열쇠) — 측점 행에 「 (MC)」 · 크게 · 굵게 · 경고색(507-3 덧). */
|
||||
mcKeys?: ReadonlySet<string>;
|
||||
/** 이어 공사 시작 기준 — 측점번호·누가거리 표시를 이만큼 더한다(내부 chainage는 0기준 유지). */
|
||||
stationDisplay?: { station: number; cumulative: number };
|
||||
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
|
||||
@@ -55,6 +57,8 @@ interface StationRowSpec {
|
||||
unit: string;
|
||||
cell: (index: number) => string;
|
||||
modifier?: string;
|
||||
/** 측점 이름 행 — MC 열에 (MC) 강조. */
|
||||
station?: boolean;
|
||||
}
|
||||
|
||||
interface SegmentRowSpec {
|
||||
@@ -208,6 +212,7 @@ function buildStationRows(
|
||||
{
|
||||
label: "측점",
|
||||
unit: "측점번호+잔여거리",
|
||||
station: true,
|
||||
cell: (index) => displayStationLabel(stations[index].chainage_m, interval, display.station),
|
||||
},
|
||||
];
|
||||
@@ -458,6 +463,7 @@ function buildSelectedColumn(
|
||||
display: { station: number; cumulative: number },
|
||||
onAdjustStation?: (chainageM: number, deltaM: number) => void,
|
||||
isIrregular = false,
|
||||
mc = false,
|
||||
): HTMLElement {
|
||||
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
|
||||
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
|
||||
@@ -487,7 +493,12 @@ function buildSelectedColumn(
|
||||
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
|
||||
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
|
||||
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
|
||||
{ value: { text: displayStationLabel(chainage, interval, display.station) }, modifier: "" }, // 측점
|
||||
{
|
||||
value: {
|
||||
text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""),
|
||||
},
|
||||
modifier: mc ? "mc" : "",
|
||||
}, // 측점
|
||||
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
|
||||
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
|
||||
];
|
||||
@@ -617,10 +628,13 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||||
alignment.stations.forEach((_, stationIndex) => {
|
||||
// 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다.
|
||||
const structural = isStructural[stationIndex];
|
||||
// MC 열 = 측점 행에 「 (MC)」 · 굵게 · 경고색(507-3 덧) — 칸 폭 판정(`longest`)은 옛 글 그대로
|
||||
const mc =
|
||||
spec.station && options.mcKeys?.has(alignment.stations[stationIndex].chainage_m.toFixed(3));
|
||||
const cell = element(
|
||||
"span",
|
||||
`b05-profile-table__cell${structural ? " is-structure" : ""}`,
|
||||
structural ? "" : spec.cell(stationIndex),
|
||||
`b05-profile-table__cell${structural ? " is-structure" : ""}${mc ? " b06-station--mc" : ""}`,
|
||||
structural ? "" : spec.cell(stationIndex) + (mc ? " (MC)" : ""),
|
||||
);
|
||||
const rotated = stationThickness[stationIndex];
|
||||
if (rotated !== null) {
|
||||
@@ -649,6 +663,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||||
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
|
||||
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
|
||||
const selectedIrregular = selectedIrregularEntry;
|
||||
const mcAt = (chainageM: number): boolean => options.mcKeys?.has(chainageM.toFixed(3)) ?? false;
|
||||
const selectedRegularIndex = selectedStationRow
|
||||
? alignment.stations.indexOf(selectedStationRow)
|
||||
: -1;
|
||||
@@ -665,6 +680,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||||
display,
|
||||
options.onAdjustStation,
|
||||
true,
|
||||
mcAt(selectedIrregular.chainage_m),
|
||||
),
|
||||
);
|
||||
} else if (selectedRegularIndex >= 0) {
|
||||
@@ -678,6 +694,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||||
display,
|
||||
options.onAdjustStation,
|
||||
false,
|
||||
mcAt(alignment.stations[selectedRegularIndex].chainage_m),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,17 +25,14 @@ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
bindMinorContourToggle,
|
||||
fetchRouteSheets,
|
||||
loadRouteEditBackground,
|
||||
type RouteEditContours,
|
||||
} from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchRoutePlan,
|
||||
fetchTerrainSources,
|
||||
setAnalysisTerrain,
|
||||
setTerrainContext,
|
||||
type AnalysisTerrain,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
@@ -116,7 +113,7 @@ export async function mountRouteEditor(
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome(parent, options.tools);
|
||||
const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome;
|
||||
const { minorContourButton, terrainSelect, stationSnapButton } = chrome;
|
||||
const { minorContourButton, terrainName, stationSnapButton } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
@@ -161,11 +158,10 @@ export async function mountRouteEditor(
|
||||
let meta: VWorldMeta | null = null;
|
||||
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
|
||||
let contours: RouteEditContours | null = null;
|
||||
/** 지금 설계 지형(PLAN 133-2) — 고른 분석 지형이 이와 다르면 [확인]이 설계 지형을 바꾼다. */
|
||||
/** 지금 설계 지형 — 편집 분석이 쓰는 지형 · 바꾸기는 추천 창 [적용](509-5). */
|
||||
let designTerrain: AnalysisTerrain = "las";
|
||||
/** [확인]에 실을 분석 지형 — 고르기가 안 보이면 null(설계 지형 그대로). */
|
||||
const pickedTerrain = (): AnalysisTerrain | null =>
|
||||
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId);
|
||||
/** 추천 창이 고를 지형 — `/route/terrain-sources` 를 읽은 뒤. */
|
||||
let terrainSources: { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null = null;
|
||||
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
|
||||
let otherSheets: PreparedLayer[] = [];
|
||||
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
||||
@@ -191,7 +187,8 @@ export async function mountRouteEditor(
|
||||
station_interval_m: stationIntervalM,
|
||||
station_snap: stationSnap,
|
||||
}),
|
||||
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
|
||||
// 횡단이 뜨면 그 측점 반폭 선을 그린다 — 새로 고침 뒤 고른 측점은 만드는 도중 불려 아래 선언이 아직이라 한 박자 뒤에(505-4).
|
||||
onShown: () => queueMicrotask(draw),
|
||||
});
|
||||
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
@@ -266,6 +263,7 @@ export async function mountRouteEditor(
|
||||
planned: () => planned,
|
||||
planMinRadiusM: () => minRadiusM,
|
||||
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
||||
terrains: () => terrainSources,
|
||||
});
|
||||
|
||||
function resize(): void {
|
||||
@@ -446,8 +444,8 @@ export async function mountRouteEditor(
|
||||
historyControls.sync();
|
||||
}
|
||||
|
||||
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430 · 433-14 ②). */
|
||||
const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed();
|
||||
/** 계산 묶음에 알림 — 노선 · 스냅이 저장분과 다르면 초안 · 같으면 null(430 · 433-14 ②). */
|
||||
const otherChanged = (): boolean => snap.changed();
|
||||
function tellDraft(): void {
|
||||
if (!history) return;
|
||||
const dirty = lineChanged(history) || otherChanged();
|
||||
@@ -586,9 +584,7 @@ export async function mountRouteEditor(
|
||||
const unbindApply = bindRouteApply({
|
||||
projectId,
|
||||
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
|
||||
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
|
||||
changed: () => (!!history && lineChanged(history)) || otherChanged(),
|
||||
terrain: pickedTerrain,
|
||||
snap: snap.draft,
|
||||
initial,
|
||||
onApplied,
|
||||
@@ -600,11 +596,11 @@ export async function mountRouteEditor(
|
||||
host.onHide(() => (historyControls.listen(false), viewMemory.flush()));
|
||||
host.onDispose(dispose);
|
||||
|
||||
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
||||
// ── 자료 읽기 — 노선 두 벌 + 노선 띠로 자른 도엽(509-1) ──
|
||||
try {
|
||||
const [plan, drainage, sources] = await Promise.all([
|
||||
fetchRoutePlan(projectId),
|
||||
fetchDrainageLayers(projectId, () => {}),
|
||||
fetchRouteSheets(projectId, () => plannedLine),
|
||||
fetchTerrainSources(projectId).catch(() => null),
|
||||
]);
|
||||
if (closed) return;
|
||||
@@ -632,21 +628,16 @@ export async function mountRouteEditor(
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 열 때 안 씀(352-3).
|
||||
// 고른 적 없으면 데이터대로(설계 지형 · 라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억.
|
||||
// 설계 지형 이름만(509-5) — 고르기는 추천 창 안 · 둘 다 있을 때만 이름을 보임.
|
||||
designTerrain = sources?.design ?? "las";
|
||||
const choosable = !!sources?.sources.includes("las") && !!sources?.sources.includes("sheet");
|
||||
terrainSelect.closest("label")!.hidden = !choosable;
|
||||
setTerrainContext(projectId, choosable, designTerrain);
|
||||
terrainSelect.value = analysisTerrain(projectId);
|
||||
terrainSources = sources;
|
||||
terrainName.hidden = !(sources?.sources.includes("las") && sources.sources.includes("sheet"));
|
||||
terrainName.textContent = `지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"}`;
|
||||
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
||||
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
|
||||
const loadBackground = () =>
|
||||
loadRouteEditBackground(projectId, drainage, {
|
||||
surfaceModelId:
|
||||
analysisTerrain(projectId) === "las" && designTerrain === "las"
|
||||
? (options.surfaceModelId ?? null)
|
||||
: null,
|
||||
surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null,
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
});
|
||||
@@ -669,21 +660,6 @@ export async function mountRouteEditor(
|
||||
};
|
||||
useBackground(await loadBackground());
|
||||
if (closed) return;
|
||||
// 고르면 모달 안 분석을 고른 지형으로 다시 그림 — 등고선 · 기울기 · 종단 띠 · 횡단 · 잰 값.
|
||||
let terrainTicket = 0;
|
||||
terrainSelect.addEventListener("change", () => {
|
||||
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
|
||||
tellDraft(); // 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
|
||||
const mine = ++terrainTicket;
|
||||
void loadBackground().then((background) => {
|
||||
if (closed || mine !== terrainTicket) return;
|
||||
useBackground(background);
|
||||
measure.clear();
|
||||
slope.refresh();
|
||||
showStatus();
|
||||
draw();
|
||||
});
|
||||
});
|
||||
viewWanted = true;
|
||||
resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
|
||||
showStatus();
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* · 페이지 [저장](`design_save`)이 `pendingRouteSave` 로 지금 노선 초안을 받아 한 본문(`POST /design/save` ·
|
||||
* `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B).
|
||||
* · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지
|
||||
* 한 번에 · 다 저장한 뒤 `routeSaved` · 결과 한 줄 · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
|
||||
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정).
|
||||
* 한 번에 · 다 저장한 뒤 `routeSaved` · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
|
||||
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정) ·
|
||||
* 창에서 고른 지형이 설계 지형(509-5 · 손 [저장]은 지형 안 바꿈).
|
||||
* 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나).
|
||||
* 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽).
|
||||
* ========================================================================== */
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import type { SavePart } from "../A00_Common/design/design_save_api";
|
||||
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
|
||||
import { forgetDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
||||
import { reloadSavedPlusStations } from "./B05_Profile_Api_PlusStations";
|
||||
import {
|
||||
replanRoute,
|
||||
@@ -55,8 +57,6 @@ export interface RouteApplyParams {
|
||||
nodes: () => RouteNodes;
|
||||
/** 마지막 저장 뒤 노선이 바뀌었나 — 안 바뀌었으면 보내지 않는다. */
|
||||
changed: () => boolean;
|
||||
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
|
||||
terrain: () => string | null;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */
|
||||
snap: () => SnapSetting;
|
||||
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
@@ -95,7 +95,8 @@ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteD
|
||||
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. 돌려준 함수 = 편집기가 떠날 때 풂
|
||||
* (안 풀면 노선 저장 뒤 다시 선 페이지에서 옛 편집기 「고친 것 있음」이 [저장] · [확정]마다 노선 저장을 다시 돌림 · 445-2). */
|
||||
export function bindRouteApply(params: RouteApplyParams): () => void {
|
||||
const settings = () => ({ ...params.snap(), terrain: params.terrain() });
|
||||
// 손으로 고친 노선 = 설계 지형 그대로 — 지형 바꿈은 추천 [적용]만(509-5)
|
||||
const settings = () => ({ ...params.snap(), terrain: null });
|
||||
const source: RouteSource = {
|
||||
pending: async () => {
|
||||
const nodes = params.nodes();
|
||||
@@ -142,20 +143,6 @@ export function routeSaved(projectId: string): void {
|
||||
sources.get(projectId)?.saved();
|
||||
}
|
||||
|
||||
/** 저장 결과 한 줄 — 관 지킴 · 새 교차 관 · 구조물 옮김 · 멀어짐 · 노선 밖 고침 · 배수유역 낡음. */
|
||||
export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): string {
|
||||
const parts = [
|
||||
`관 지킴 ${saved.pipes?.kept ?? 0}`,
|
||||
`새 교차 관 ${saved.pipes?.crossing ?? 0}`,
|
||||
`구조물 옮김 ${saved.structures?.moved ?? 0}`,
|
||||
`멀어짐 ${saved.structures?.far ?? 0}`,
|
||||
];
|
||||
const dropped = (saved.cross_dropped ?? 0) + outside;
|
||||
if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`);
|
||||
if (saved.drainage_stale) parts.push("배수유역 낡음");
|
||||
return `노선 저장 — ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선 바뀐 [저장]의 횡단 조각(472-6 6-B) — 옛 노선 세션 고침 원본(측점 열쇠 칸 · 누가거리 칸 · 열쇠 목록 · 관 장부)을
|
||||
* `carry` 로 · 횡단 본문 = 표준 횡단면 · 풍선 자리(옛 `saveCachedCrossPatches` 와 같은 칸). 새 측점 patch 는 서버가
|
||||
@@ -205,18 +192,26 @@ export async function saveRecommendedRoute(
|
||||
nodes: RouteNodes,
|
||||
initial: unknown,
|
||||
balancedProfile: boolean,
|
||||
terrain: string | null = null,
|
||||
): Promise<boolean> {
|
||||
const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null };
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const saved = await saveRouteDraft(projectId, {
|
||||
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장
|
||||
draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }),
|
||||
// 창에서 고른 지형 = 설계 지형(509-5 · 서버 `switch_design_terrain`)
|
||||
draft: routeDraftOf(nodes, {
|
||||
...settings,
|
||||
stationSnap: true,
|
||||
stationSnapStep: null,
|
||||
terrain,
|
||||
}),
|
||||
initial,
|
||||
balanced_profile: balancedProfile,
|
||||
as_initial: true,
|
||||
});
|
||||
clearDrafts(projectId);
|
||||
forgetDesignTerrainGrid(projectId); // 지형이 바뀌었으면 새 지형 격자로(509-2)
|
||||
await reloadSavedPlusStations(projectId); // 페이지를 다시 세워도 남는 옛 저장분(493-5)
|
||||
sources.get(projectId)?.saved();
|
||||
const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0);
|
||||
|
||||
@@ -58,7 +58,8 @@ export interface RouteEditChrome {
|
||||
/** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */
|
||||
stationSnapButton: HTMLButtonElement;
|
||||
/** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */
|
||||
terrainSelect: HTMLSelectElement;
|
||||
/** 설계 지형 이름(509-5) — 고르기는 추천 창 안. */
|
||||
terrainName: HTMLElement;
|
||||
}
|
||||
|
||||
/** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */
|
||||
@@ -119,13 +120,8 @@ export function createRouteEditChrome(
|
||||
</select>
|
||||
</label>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
<option value="las">라이다</option>
|
||||
<option value="sheet">수치지형도</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="b05-routeedit__terrain" data-slot="terrain-name" hidden
|
||||
title="설계 지형 — 바꾸기는 [노선 추천] 창에서 지형을 골라 [적용]"></span>
|
||||
</div>
|
||||
<div class="b05-routeedit__hint">
|
||||
<div class="b05-routeedit__hint-body">
|
||||
@@ -253,7 +249,7 @@ export function createRouteEditChrome(
|
||||
const minorContourButton = overlay.querySelector<HTMLButtonElement>(
|
||||
'[data-act="contour-minor"]',
|
||||
)!;
|
||||
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
|
||||
const terrainName = overlay.querySelector<HTMLElement>('[data-slot="terrain-name"]')!;
|
||||
const stationSnapButton = overlay.querySelector<HTMLButtonElement>('[data-act="station-snap"]')!;
|
||||
bindFootprintToggle(overlay.querySelector<HTMLButtonElement>('[data-act="footprint"]')!);
|
||||
bindMarkToggle(overlay.querySelector<HTMLButtonElement>('[data-act="stations"]')!, "stations");
|
||||
@@ -269,6 +265,6 @@ export function createRouteEditChrome(
|
||||
measureButton,
|
||||
minorContourButton,
|
||||
stationSnapButton,
|
||||
terrainSelect,
|
||||
terrainName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
prepareMetricPolylines,
|
||||
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { CONTOUR_BAND_M } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
|
||||
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
|
||||
const SHEET_ELEVATION_KEYS = ["등고수치"];
|
||||
@@ -102,18 +104,61 @@ export async function loadRouteEditContours(
|
||||
return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null };
|
||||
}
|
||||
|
||||
/** 잘라 받은 도엽(`clip_lonlat`)이 노선 띠(bbox + `bandM`)를 덮나 — 안 잘렸으면 덮음(509-1).
|
||||
* 경위도 ↔ m 는 화면 가정 그대로(메타의 m 사각형 = 경위도 사각형). */
|
||||
export function sheetCoversBand(
|
||||
collection: GeoJsonCollection | null,
|
||||
meta: VWorldMeta,
|
||||
line: ReadonlyArray<readonly [number, number]>,
|
||||
bandM: number,
|
||||
): boolean {
|
||||
const clip = (collection as { clip_lonlat?: number[] } | null)?.clip_lonlat;
|
||||
if (!clip || line.length < 2) return true;
|
||||
const xs = line.map((point) => point[0]);
|
||||
const ys = line.map((point) => point[1]);
|
||||
const lon = (x: number): number =>
|
||||
meta.lon_min + ((x - meta.x_min) / (meta.width_meters || 1)) * (meta.lon_max - meta.lon_min);
|
||||
const lat = (y: number): number =>
|
||||
meta.lat_min + ((y - meta.y_min) / (meta.height_meters || 1)) * (meta.lat_max - meta.lat_min);
|
||||
return (
|
||||
lon(Math.min(...xs) - bandM) >= clip[0] &&
|
||||
lat(Math.min(...ys) - bandM) >= clip[1] &&
|
||||
lon(Math.max(...xs) + bandM) <= clip[2] &&
|
||||
lat(Math.max(...ys) + bandM) <= clip[3]
|
||||
);
|
||||
}
|
||||
|
||||
type SheetLayers = {
|
||||
meta: VWorldMeta;
|
||||
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
|
||||
/** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */
|
||||
line?: () => ReadonlyArray<readonly [number, number]>;
|
||||
};
|
||||
|
||||
/** 노선 편집이 받는 도엽 — 노선 띠로 자른 것(509-1). `line` = 지금 노선(배경 준비 때 읽음). */
|
||||
export async function fetchRouteSheets(
|
||||
projectId: string,
|
||||
line: () => ReadonlyArray<readonly [number, number]>,
|
||||
): Promise<SheetLayers & { meta: VWorldMeta }> {
|
||||
return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line };
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선).
|
||||
* 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다.
|
||||
* 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의
|
||||
* 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다.
|
||||
*/
|
||||
export async function loadRouteEditBackground(
|
||||
projectId: string,
|
||||
drainage: {
|
||||
meta: VWorldMeta;
|
||||
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
|
||||
},
|
||||
clipped: SheetLayers,
|
||||
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
|
||||
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> {
|
||||
// ponytail: 여는 때 · 지형 바꿀 때만 잼 — 편집 도중 여유 밖으로 끌면 다음 열 때 통째로 받음.
|
||||
const drainage = clipped.layers.every(([, collection]) =>
|
||||
sheetCoversBand(collection, clipped.meta, clipped.line?.() ?? [], CONTOUR_BAND_M),
|
||||
)
|
||||
? clipped
|
||||
: await fetchDrainageLayers(projectId, () => {}, null);
|
||||
const normalizer = createNormalizer(drainage.meta);
|
||||
const otherSheets = drainage.layers
|
||||
.filter(([layer]) => layer !== "도엽_등고선")
|
||||
|
||||
@@ -197,7 +197,9 @@ function createPane(title: string, tag: string, empty: string): CrossPane {
|
||||
}
|
||||
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
||||
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
||||
station.textContent = `${tag}${formatStation(preview.chainage_m, intervalM)}`;
|
||||
const mc = preview.kind === "curve"; // MC = 고칠 자리(507-3) — (MC) · 크게 · 굵게 · 경고색
|
||||
station.textContent = `${tag}${formatStation(preview.chainage_m, intervalM)}${mc ? " (MC)" : ""}`;
|
||||
station.className = `b05-routeedit__cross-station${mc ? " b06-station--mc" : ""}`;
|
||||
draw(preview);
|
||||
foot.textContent = source ? "" : summarizeCross(preview); // 카드 = 그래프만(393-3)
|
||||
},
|
||||
@@ -228,6 +230,7 @@ export function pipelineCrossAt(
|
||||
status: "success",
|
||||
chainage_m: section.chainage_m,
|
||||
label: section.label ?? null,
|
||||
kind: section.kind ?? null,
|
||||
uphill_side: section.uphill_side ?? null,
|
||||
plan_radius_m: section.plan_radius_m ?? null,
|
||||
curve_widening_m: section.curve_widening_m ?? null,
|
||||
@@ -352,7 +355,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
showStation(station?.key ?? null, station?.chainageM ?? at, structure, open);
|
||||
}
|
||||
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
|
||||
if (from !== PLAN_PICK_FROM) follow(selection);
|
||||
// 평면 이름 = 눈금 누름(제가 이미 그림)만 거름 · 마커 고름 풂(값 없음)은 따라 비움(500-1)
|
||||
if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection);
|
||||
});
|
||||
attach();
|
||||
// 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄
|
||||
|
||||
@@ -15,7 +15,7 @@ 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 type { CrossSection, DesignProfile } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { peekSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
||||
import { displayHalfWidth } from "../B06_Section/B06_Section_Display_Width";
|
||||
import { pageRouteId } from "../A00_Common/design/design_page_route";
|
||||
@@ -74,7 +74,17 @@ export function routeCrossSource(
|
||||
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;
|
||||
return crossSourceOf(projectId, found, sections, detail.longitudinal.design_profiles, pane);
|
||||
}
|
||||
|
||||
/** 단면 하나 → 판 재료(사본) — 이웃 단면 · 계획선은 연동 관 · 반폭 · 계획고 재료(종단 임시 횡단 창도 씀 · 505-3). */
|
||||
export function crossSourceOf(
|
||||
projectId: string,
|
||||
found: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
profiles: DesignProfile[] | undefined,
|
||||
pane: string,
|
||||
): RouteCrossSource {
|
||||
const planZ = designElevationAt(profiles, found.chainage_m);
|
||||
return {
|
||||
section: {
|
||||
@@ -146,6 +156,14 @@ export function routeCrossCard(
|
||||
box,
|
||||
);
|
||||
// 그래프만(393-3) — 카드 머리 · 줌 단추 · 발(암 경계 · 요약) 걷음 · 벽 · 선 · 면적 고름 막음
|
||||
// 경고 칩(빈칸 · 기본값 · 사면 열림)만 그래프 오른쪽 위에 겹쳐 남김(505-3)
|
||||
const chips = card.querySelectorAll(":scope > header .b06-cross-card__warning");
|
||||
if (chips.length) {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b05-cross-chips";
|
||||
box.append(...chips);
|
||||
card.append(box);
|
||||
}
|
||||
card
|
||||
.querySelectorAll(":scope > header, :scope > footer, .b06-cross-card__zoom")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
@@ -180,6 +180,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
);
|
||||
}
|
||||
|
||||
// 가둠 판(도구줄 · 횡단 판 · 종단 판)이 열리고 닫히고 크기가 바뀌면 다시 가둠(505-3) · 편집 창이 닫히면 끊음
|
||||
const watched = new Set<Element>();
|
||||
const refit = new ResizeObserver(() => {
|
||||
if (!params.canvas.isConnected) return refit.disconnect();
|
||||
sync();
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
|
||||
function sync(): void {
|
||||
const {
|
||||
@@ -199,11 +206,25 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
return;
|
||||
}
|
||||
const rect = params.canvas.getBoundingClientRect();
|
||||
// 가둠 = 도구줄 아래 · 열린 횡단 판 왼쪽 · 뜬 종단 판 위(505-3 · 판 밑에 깔려 가리지 않게)
|
||||
const main = params.canvas.closest(".design-page__main") ?? params.canvas.parentElement;
|
||||
const box = (selector: string): DOMRect | null => {
|
||||
const element = main?.querySelector(selector);
|
||||
if (element && !watched.has(element)) {
|
||||
watched.add(element);
|
||||
refit.observe(element);
|
||||
}
|
||||
const found = element?.getBoundingClientRect();
|
||||
return found && found.width > 0 && found.height > 0 ? found : null;
|
||||
};
|
||||
const tools = box(".b05-routeedit__tools");
|
||||
const side = box(".b05-routeedit__side-wrap");
|
||||
const profile = box(".b05-route-profile");
|
||||
const bounds = {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
top: Math.max(rect.top, tools?.bottom ?? rect.top) + 8,
|
||||
right: Math.min(rect.right, side?.left ?? rect.right) - 8,
|
||||
bottom: Math.min(rect.bottom, profile?.top ?? rect.bottom) - 8,
|
||||
};
|
||||
const last = planned.length - 1;
|
||||
if (picked === 0 || picked === last) {
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
fetchRoutePlan,
|
||||
type AnalysisTerrain,
|
||||
type RoutePlanResponse,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
fetchPrecomputeProgress,
|
||||
fetchRouteInitial,
|
||||
@@ -57,6 +61,8 @@ export interface InitialPanelParams {
|
||||
planMinRadiusM: () => number;
|
||||
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
|
||||
apply: (snapshot: RouteEditSnapshot, message: string) => void;
|
||||
/** 추천 창이 고를 지형 · 지금 설계 지형(509-5) — 못 읽었으면 null(설계 지형 하나). */
|
||||
terrains: () => { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null;
|
||||
}
|
||||
|
||||
export interface InitialPanel {
|
||||
@@ -126,6 +132,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
criteria: RouteInitialCriteria | null,
|
||||
withProfile: boolean,
|
||||
recompute: boolean,
|
||||
terrain: AnalysisTerrain,
|
||||
): Promise<InitialChoice> {
|
||||
const response = await fetchRouteInitial(projectId, {
|
||||
mode: logic,
|
||||
@@ -133,8 +140,9 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
...(criteria ? { criteria } : {}),
|
||||
with_profile: withProfile,
|
||||
...(recompute ? { recompute: true } : {}),
|
||||
terrain,
|
||||
});
|
||||
return normalizeRouteInitial(response, logic, weights, criteria, withProfile);
|
||||
return { ...normalizeRouteInitial(response, logic, weights, criteria, withProfile), terrain };
|
||||
}
|
||||
|
||||
recommendButton.addEventListener("click", () => {
|
||||
@@ -158,6 +166,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
|
||||
current: params.planned().map(([x, y]) => [x, y]),
|
||||
request,
|
||||
terrains: params.terrains()?.sources ?? [],
|
||||
terrain: params.terrains()?.design ?? "las",
|
||||
progress: () => fetchPrecomputeProgress(projectId),
|
||||
confirmText: () => applyResetText(projectId),
|
||||
onApply: (picked) => {
|
||||
@@ -179,6 +189,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
|
||||
},
|
||||
choiceWithProfile(picked),
|
||||
picked.terrain ?? null,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -215,6 +215,8 @@ export interface InitialChoice {
|
||||
/** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */
|
||||
endsGradePct?: number | null;
|
||||
endsOverLimit?: boolean;
|
||||
/** 추천 창에서 고른 지형(509-5) — [적용]이 설계 지형으로. 옛 캐시면 없음(설계 지형 그대로). */
|
||||
terrain?: "las" | "sheet";
|
||||
}
|
||||
|
||||
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* 일은 끌기(`_Pointer`)와 같음. 걸음 · 결과 한 줄 셈은 `logic/B05_Profile_RouteEdit_KeyNudge`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||||
import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
|
||||
import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer";
|
||||
@@ -109,14 +110,8 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
|
||||
params.draw();
|
||||
}
|
||||
|
||||
const detach = (): void => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener("keyup", onKeyUp, true);
|
||||
window.removeEventListener("blur", release);
|
||||
};
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (!canvas.isConnected) return detach();
|
||||
if (!canvas.isConnected) return;
|
||||
if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return; // B06 겹침 평면도 같음(436)
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
|
||||
@@ -147,14 +142,15 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
|
||||
}
|
||||
|
||||
function onKeyUp(event: KeyboardEvent): void {
|
||||
if (!canvas.isConnected) return detach();
|
||||
if (!canvas.isConnected) return;
|
||||
if (!hold || event.key !== hold.key) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
release();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener("keyup", onKeyUp, true);
|
||||
window.addEventListener("blur", release);
|
||||
// 창 듣기는 캔버스 수명에 묶음 — 다시 세운 옛 페이지를 안 붙듦(298-3)
|
||||
listenWhileAlive(window, "keydown", canvas, onKeyDown, true);
|
||||
listenWhileAlive(window, "keyup", canvas, onKeyUp, true);
|
||||
listenWhileAlive(window, "blur", canvas, release);
|
||||
}
|
||||
|
||||
@@ -243,8 +243,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로)
|
||||
const pick = choice.pick;
|
||||
// 교각점 · 손잡이 · 호(곡선 고름) = 마커 고름 유지(500-1) · 그 밖 마커 아닌 곳 = 풂(오른쪽 판도 비움)
|
||||
const onCurve =
|
||||
(!!pick && "node" in pick) ||
|
||||
choice.handle !== null ||
|
||||
(!(pick && "chainage" in pick) && arcAtScreen(state.curveInfo, toScreen, px, py) >= 0);
|
||||
if (!onCurve) marks.deselect();
|
||||
// 노드 가운데(6px)는 노드 — 헤어핀처럼 곡선이 몰린 데서도 노드를 집음(2026-09-07 사용자 지적 ④) ·
|
||||
// 그 밖은 횡단 눈금 · 손잡이와 가까운 쪽(381-3 `pickAt` · 433-14 ③ 낮은 확대 R 12 손잡이).
|
||||
const hitNode = pick && "node" in pick ? pick.node : -1;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
|
||||
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
|
||||
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
|
||||
* 지형 고르기(509-5 · 라이다 · 수치지형도 둘일 때) — 바꾸면 그 지형 미리 셈 저장본 · 없으면 안내 + [재계산] ·
|
||||
* [적용]하면 그 지형이 설계 지형(초기값도 그 지형으로).
|
||||
* 적용될 초기 종단(476) — 서버가 [적용]과 같은 코드로 세운 종단을 같은 그림에 선 · 한 줄로 더한다
|
||||
* (끔이어도) · [적용]은 「종단 · 횡단 손 입력이 모두 지워짐」 확인 뒤(요구 277).
|
||||
* ========================================================================== */
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
||||
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
|
||||
import type {
|
||||
PrecomputeProgress,
|
||||
RouteInitialCriteria,
|
||||
@@ -125,6 +128,8 @@ const CRITERIA_FIELD: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const TERRAIN_NAME: Record<AnalysisTerrain, string> = { las: "라이다", sheet: "수치지형도" };
|
||||
|
||||
export interface RecommendParams {
|
||||
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
|
||||
criteria: RouteInitialCriteria | null;
|
||||
@@ -148,7 +153,11 @@ export interface RecommendParams {
|
||||
criteria: RouteInitialCriteria | null,
|
||||
withProfile: boolean,
|
||||
recompute: boolean,
|
||||
terrain: AnalysisTerrain,
|
||||
) => Promise<InitialChoice>;
|
||||
/** 고를 수 있는 지형(509-5) — 둘이면 창에 고르기 · 처음 값 = 지금 설계 지형. */
|
||||
terrains: AnalysisTerrain[];
|
||||
terrain: AnalysisTerrain;
|
||||
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
|
||||
onApply: (choice: InitialChoice) => void;
|
||||
/** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */
|
||||
@@ -169,6 +178,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
let preview: InitialChoice | null = null;
|
||||
/** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */
|
||||
let profileOn = params.withProfile;
|
||||
/** 창 지형(509-5) — 바꾸면 그 지형 저장본 · [적용]이 설계 지형으로. */
|
||||
let terrain = params.terrain;
|
||||
const choosable = params.terrains.includes("las") && params.terrains.includes("sheet");
|
||||
let busy = false;
|
||||
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
|
||||
let dirty = false;
|
||||
@@ -211,6 +223,12 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
<div class="b05-routerec__bars" data-slot="bars"></div>
|
||||
<span class="b05-routerec__sum" data-weight-sum></span>
|
||||
</div>
|
||||
<label class="b05-routerec__terrain" data-terrain-row hidden
|
||||
title="추천 · 초기 종단을 셀 지형 — [적용]하면 이 지형이 설계 지형(평면 · 종단 · 횡단)">지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
<option value="las">라이다</option>
|
||||
<option value="sheet">수치지형도</option>
|
||||
</select></label>
|
||||
<div class="b05-routerec__criteria" data-slot="criteria"></div>
|
||||
<label class="b05-routerec__target" data-target-row hidden
|
||||
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
|
||||
@@ -336,6 +354,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
rerun.title = "지금 칸 값으로 서버 저장본을 지우고 새로 셈";
|
||||
rerun.before(profileToggle);
|
||||
const stateEl = q<HTMLElement>("[data-state]");
|
||||
const terrainSelect = q<HTMLSelectElement>('[data-act="terrain"]');
|
||||
q<HTMLElement>("[data-terrain-row]").hidden = !choosable;
|
||||
terrainSelect.value = terrain;
|
||||
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
|
||||
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
|
||||
bindRecommendHelp(body);
|
||||
@@ -420,6 +441,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
|
||||
// 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421)
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
terrainSelect.disabled = busy;
|
||||
profileToggle.disabled = busy;
|
||||
profileToggle.setAttribute("aria-pressed", String(profileOn));
|
||||
profileToggle.classList.toggle("is-active", profileOn);
|
||||
@@ -498,7 +520,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
preview = null;
|
||||
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
|
||||
const started = Date.now();
|
||||
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}`;
|
||||
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}${
|
||||
choosable ? ` · ${TERRAIN_NAME[terrain]}` : ""
|
||||
}`;
|
||||
/** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */
|
||||
let behind = "";
|
||||
const tick = (): void => {
|
||||
@@ -529,6 +553,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
Object.keys(criteria).length ? criteria : null,
|
||||
withProfile,
|
||||
recompute,
|
||||
terrain,
|
||||
);
|
||||
if (closed || mine !== seq) return;
|
||||
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
|
||||
@@ -545,11 +570,10 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
} — [적용]하면 바로 저장 · 새 초기값이 됩니다.`;
|
||||
} catch (error) {
|
||||
if (closed || mine !== seq) return;
|
||||
stateEl.textContent = "셈하지 못했습니다.";
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
// 서버 사유를 판에 남김 — 저장본 없음이면 「전처리를 다시 하면 생깁니다」(505-1)
|
||||
const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.";
|
||||
stateEl.textContent = message;
|
||||
showToast(message, "error");
|
||||
} finally {
|
||||
if (mine === seq) window.clearInterval(ticker);
|
||||
if (!closed && mine === seq) {
|
||||
@@ -618,6 +642,10 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
});
|
||||
});
|
||||
rerun.addEventListener("click", () => void run(true));
|
||||
terrainSelect.addEventListener("change", () => {
|
||||
terrain = terrainSelect.value === "sheet" ? "sheet" : "las";
|
||||
void run(); // 그 지형 저장본 — 없으면 안내 · [재계산](509-5)
|
||||
});
|
||||
applyButton.addEventListener("click", () => {
|
||||
const picked = preview;
|
||||
if (!picked) return;
|
||||
|
||||
@@ -45,7 +45,7 @@ const LOCKED_WIDTH = 4;
|
||||
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
||||
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
||||
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
||||
const CONTOUR_BAND_M = 300;
|
||||
export const CONTOUR_BAND_M = 300;
|
||||
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
||||
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
||||
const CURVE_HANDLE_PX = 5;
|
||||
@@ -171,6 +171,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.translate(-view.width / 2, -view.height / 2);
|
||||
}
|
||||
|
||||
// 돌린 화면은 모서리가 화면 사각형 밖에서 들어옴 — 화면 밖 거르기를 반 대각선만큼 넓힘(509-3).
|
||||
const pad = scene.rotationRad ? Math.hypot(view.width, view.height) / 2 : 0;
|
||||
context.save();
|
||||
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
||||
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
||||
@@ -192,7 +194,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
|
||||
context.clip();
|
||||
}
|
||||
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
|
||||
for (const layer of scene.otherSheets)
|
||||
drawPreparedLayer(context, layer, view, "dot", undefined, pad);
|
||||
context.restore();
|
||||
if (scene.contours) {
|
||||
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
|
||||
@@ -203,12 +206,12 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.strokeStyle =
|
||||
style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)";
|
||||
context.lineWidth = 0.5;
|
||||
drawPreparedLayer(context, scene.minorContours, view, "dot");
|
||||
drawPreparedLayer(context, scene.minorContours, view, "dot", undefined, pad);
|
||||
}
|
||||
// 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②).
|
||||
context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8";
|
||||
context.lineWidth = 0.8;
|
||||
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
|
||||
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM, pad);
|
||||
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
||||
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
|
||||
if (scene.pickedContour >= 0) {
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
* · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 ·
|
||||
* 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본).
|
||||
* · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름.
|
||||
* · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로 ·
|
||||
* · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판도 비움 500-1 ·
|
||||
* 좌측 목록 강조 · 폼은 풂 409-2).
|
||||
* 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||||
import { draftProjectId } from "../A00_Common/design/design_store";
|
||||
import {
|
||||
NO_SELECTION,
|
||||
@@ -333,7 +334,7 @@ export function bindStructureMarks(
|
||||
return moving;
|
||||
};
|
||||
|
||||
/** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */
|
||||
/** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판도 값 없음이라 따라 비움(500-1). */
|
||||
const deselect = (): boolean => {
|
||||
if (!planMarks(canvas)?.picked) return false;
|
||||
selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });
|
||||
@@ -365,13 +366,8 @@ export function bindStructureMarks(
|
||||
holdKey = null;
|
||||
finish();
|
||||
};
|
||||
const detach = (): void => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener("keyup", onKeyUp, true);
|
||||
window.removeEventListener("blur", release);
|
||||
};
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (!canvas.isConnected) return detach();
|
||||
if (!canvas.isConnected) return;
|
||||
const escape = event.key === "Escape";
|
||||
if ((!escape && !isArrowKey(event.key)) || !lastPointerWithin(".b05-routeedit")) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
@@ -392,12 +388,13 @@ export function bindStructureMarks(
|
||||
}, HOLD_DELAY_MS);
|
||||
}
|
||||
function onKeyUp(event: KeyboardEvent): void {
|
||||
if (!canvas.isConnected) return detach();
|
||||
if (!canvas.isConnected) return;
|
||||
if (event.key === holdKey) release();
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener("keyup", onKeyUp, true);
|
||||
window.addEventListener("blur", release);
|
||||
// 창 듣기는 캔버스 수명에 묶음 — 다시 세운 옛 페이지를 안 붙듦(298-3)
|
||||
listenWhileAlive(window, "keydown", canvas, onKeyDown, true);
|
||||
listenWhileAlive(window, "keyup", canvas, onKeyUp, true);
|
||||
listenWhileAlive(window, "blur", canvas, release);
|
||||
|
||||
return {
|
||||
press(px, py) {
|
||||
|
||||
@@ -637,8 +637,8 @@ export function createStructuresSection(
|
||||
const type = typeMap().get(typeId);
|
||||
if (!type) return;
|
||||
// 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서
|
||||
// 시작해 옛 고름 · 집계 항목 · 그룹 잇기를 비우고 패널을 열어 「구조물 배치」를 폄.
|
||||
// 필수 빈칸이 없으면 바로 추가 + 새 항목 고름 · 남으면 폼만(A군은 임시 배치까지).
|
||||
// 시작해 옛 고름 · 집계 항목 · 그룹 잇기를 비우고 「구조물 배치」를 폄(좌측 패널은 안 엶 · 499-1).
|
||||
// 등록부 기본값 뒤 필수 빈칸이 없으면 바로 추가 + 새 항목 고름 · 남으면 폼만(A군은 임시 배치까지).
|
||||
startNew(type.group, typeId);
|
||||
callbacks.onReveal?.();
|
||||
root.classList.remove("is-collapsed");
|
||||
@@ -654,7 +654,7 @@ export function createStructuresSection(
|
||||
// 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 사용자가 고치면 됨).
|
||||
renderOptionFields();
|
||||
syncFacilityForm();
|
||||
if (addsAtOnce(type, optionInputs))
|
||||
if (addsAtOnce(optionInputs))
|
||||
return finishAdd({ type, anchorM: chainageM, structures, commit, pick: this });
|
||||
// 임시 배치도 즉시 진행(A군).
|
||||
if (type.managed_by) commitTempPipe(true);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Panel_AddAt.ts
|
||||
* 종단 우클릭 → 종류 고름 = 「[추가]까지 끝난 상태」(PLAN 116-1) — 세 길(B05 종단 · B06 종단 ·
|
||||
* 3D)이 함께 쓰는 패널 `addAt` 의 끝맺음. 필수 빈칸이 없는 종류는 그 측점에 바로 추가하고
|
||||
* 새 항목을 고른 채로 둠 · 남는 종류는 폼만. 세월교 · 물넘이포장도 바로 추가 — 빈 월류 높이는
|
||||
* 3D)이 함께 쓰는 패널 `addAt` 의 끝맺음. 등록부 기본값을 넣고도 필수 빈칸이 없는 종류는 그
|
||||
* 측점에 바로 추가하고 새 항목을 고른 채로 둠 · 남는 종류는 폼만(값 지어내지 않음). 포장도 바로
|
||||
* 추가(물넘이 걸침 = 확인창 · 499-1). 세월교 · 물넘이포장도 바로 추가 — 빈 월류 높이는
|
||||
* 배수유역 재계산 유량이 오면 그 항목에 채움(126-1 · `_Drainage_Ford_Fill`).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -10,18 +11,14 @@ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structu
|
||||
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
|
||||
/** 바로 추가하지 않는 동작 — 포장 · 물넘이 겹침(확인창 · 323-7 종류 키 대신 동작 이름). */
|
||||
const FORM_ONLY = "포장_물넘이_겹침";
|
||||
/** 월류 높이를 유량 뒤에 받는 동작 — 추가 때 알림 한 줄. */
|
||||
const FORD_KINDS = "월류단면";
|
||||
export const FORD_WAIT_NOTICE = "월류 높이는 배수유역 재계산(설계유량)이 오면 채워집니다.";
|
||||
|
||||
/** 우클릭 한 번으로 [추가]까지 끝낼 수 있는지 — [추가]가 막는 빈 필수 칸이 없어야 함. */
|
||||
export function addsAtOnce(
|
||||
type: StructureType,
|
||||
optionInputs: ReadonlyArray<{ required: boolean; isEmpty: () => boolean }>,
|
||||
): boolean {
|
||||
if (type.behaviors?.includes(FORM_ONLY)) return false;
|
||||
return !optionInputs.some((entry) => entry.required && entry.isEmpty());
|
||||
}
|
||||
|
||||
@@ -32,7 +29,7 @@ export async function finishAdd(ctx: {
|
||||
anchorM: number;
|
||||
structures: StructureInstance[];
|
||||
commit: () => Promise<void>;
|
||||
/** 패널 — 바깥에서 고를 때와 같은 길(좌측 패널 폄 · 폼 · 목록 고름). */
|
||||
/** 패널 — 바깥에서 고를 때와 같은 길(카드 폄 · 폼 · 목록 고름). */
|
||||
pick: Pick<StructuresSection, "selectById" | "selectPipeByChainage">;
|
||||
}): Promise<boolean> {
|
||||
if (ctx.type.managed_by) {
|
||||
|
||||
@@ -259,6 +259,13 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* MC 측점(507-3) — 한 단계 크게 · 굵게 · 경고색 */
|
||||
.b05-routeedit__cross-station.b06-station--mc {
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-station:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -288,6 +295,32 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 카드 경고 칩(빈칸 · 기본값 · 사면 열림) — 머리를 걷어도 그래프 오른쪽 위에 겹쳐 남김(505-3) · 판 · 임시 횡단 창 같이 씀 */
|
||||
.b06-cross-card:has(> .b05-cross-chips) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.b05-cross-chips {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
max-width: 70%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-cross-chips > .b06-cross-card__warning {
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 85%, transparent);
|
||||
font-size: var(--text-caption);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-foot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -372,6 +372,19 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 지형 고르기(509-5) — 이름 · 고르기 한 줄. */
|
||||
.b05-routerec__terrain {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7em;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routerec__terrain[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */
|
||||
.b05-routerec__criteria {
|
||||
display: grid;
|
||||
|
||||
@@ -288,6 +288,12 @@
|
||||
color: rgb(37 99 235);
|
||||
}
|
||||
|
||||
/* MC 측점 이름(507-3 덧) — 굵게 · 경고색 */
|
||||
.b05-profile-table__irregular-col-cell.is-mc {
|
||||
color: var(--color-warning);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */
|
||||
|
||||
/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */
|
||||
|
||||
@@ -231,7 +231,7 @@ export function stationSection(
|
||||
|
||||
/** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획
|
||||
* 제목 · 457) — 옛 측점 그림을 옮기지 않음. */
|
||||
function attachStatus(sections: CrossSection[], status: ShapeStatus): void {
|
||||
export function attachStatus(sections: CrossSection[], status: ShapeStatus): void {
|
||||
const { pipes, structures, types } = status;
|
||||
attachShapes(sections as unknown as Array<Record<string, unknown>>, pipes, structures, types);
|
||||
const planned = planStations(pipes, structures, types, registryDefaults(types));
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface GroundStageDeps {
|
||||
terrain: string | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<number | null>>;
|
||||
/** 계산 자리 판(504-2) G1 = server — 격자가 있어도 서버 길 · 격자는 cross 도 쓰므로 여기서만 가름. */
|
||||
serverOnly?(): boolean | Promise<boolean>;
|
||||
/** 횡단 반폭(m) — 비면 서버 기본(20 m). */
|
||||
crossHalfWidthM?(): number | null;
|
||||
}
|
||||
@@ -122,7 +124,7 @@ export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> {
|
||||
const terrain = ctx.inputs.route?.terrain ?? null;
|
||||
const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M;
|
||||
const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_M);
|
||||
const local = deps.grid();
|
||||
const local = (await deps.serverOnly?.()) ? null : deps.grid();
|
||||
const useGrid = local !== null && (terrain === null || terrain === local.design);
|
||||
|
||||
const tag = terrain ?? "design";
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* logic/B05_Profile_Profile_CrossPick.ts
|
||||
* 종단 그래프 한 점 → 임시 횡단을 열 누가거리(정수 m) · 아니면 null(368-3 · 490-1).
|
||||
*
|
||||
* 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 다른 측점선 · 단추 =
|
||||
* 그 몫(null) · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안.
|
||||
* 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 단추 = 그 몫(null) ·
|
||||
* 다른 측점선 · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안(측점선은 띠 밖이면 측점 고름 몫 · 500-2).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 원지반선을 눌렀다고 볼 위아래 여유(px). */
|
||||
export const GROUND_SLACK_PX = 8;
|
||||
export const GROUND_SLACK_PX = 12;
|
||||
|
||||
/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선 · 단추("other") · 빈 곳(null). */
|
||||
export type CrossPickHit = { plusM: number } | "other" | null;
|
||||
/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선("station") · 단추("other") · 빈 곳(null). */
|
||||
export type CrossPickHit = { plusM: number } | "station" | "other" | null;
|
||||
|
||||
export type GroundSample = { chainage_m?: number; elevation_m?: number | null };
|
||||
|
||||
@@ -44,9 +44,10 @@ export function groundElevationAt(
|
||||
/** 임시 횡단을 열 누가거리(정수 m) · 아니면 null. */
|
||||
export function crossPickAt(input: CrossPickInput): number | null {
|
||||
if (input.hit === "other") return null;
|
||||
if (input.hit) return Math.round(input.hit.plusM);
|
||||
if (typeof input.hit === "object" && input.hit) return Math.round(input.hit.plusM);
|
||||
const ground = groundElevationAt(input.samples, input.chainageM);
|
||||
if (ground === null) return null;
|
||||
// "station"·null 모두 띠 안이면 임시 횡단 — 측점선은 띠 밖에서만 측점 고름 몫.
|
||||
return Math.abs(input.pointerY - input.groundY(ground)) <= GROUND_SLACK_PX
|
||||
? Math.round(input.chainageM)
|
||||
: null;
|
||||
|
||||
@@ -48,7 +48,7 @@ from B06_Section.B06_Section_Schema import (
|
||||
)
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import _AREA_KEYS
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, start_stage
|
||||
from common_util.common_util_workflow_state import complete_stage, mark_stage, start_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -443,7 +443,7 @@ async def confirm_sections(
|
||||
# 경로 상태(DRAFT)가 남지 않도록 여기서 함께 CONFIRMED로 닫는다.
|
||||
await confirm_route_status(connection, route_id)
|
||||
await complete_stage(cursor, str(project_id), 2)
|
||||
await complete_stage(cursor, str(project_id), 3)
|
||||
await mark_stage(str(project_id), 3, "next", cursor) # 4 진행 중(B1)
|
||||
else:
|
||||
await start_stage(cursor, str(project_id), 3)
|
||||
await connection.commit()
|
||||
|
||||
@@ -186,15 +186,32 @@ async def _run_steps(
|
||||
save = confirm_sections if request.confirm else save_sections
|
||||
crossing = save(project_id, request.route_id, request.cross)
|
||||
else:
|
||||
return out
|
||||
cross = await step("cross", crossing)
|
||||
if cross.get("recomputed") is False:
|
||||
raise StepFailed("cross", "서버 재계산 실패")
|
||||
if cross.get("stations_synced") is False:
|
||||
raise StepFailed("cross", "구조물 측점 맞춤 실패")
|
||||
crossing = None
|
||||
if crossing is not None:
|
||||
cross = await step("cross", crossing)
|
||||
if cross.get("recomputed") is False:
|
||||
raise StepFailed("cross", "서버 재계산 실패")
|
||||
if cross.get("stations_synced") is False:
|
||||
raise StepFailed("cross", "구조물 측점 맞춤 실패")
|
||||
# [>](확정) = confirm_sections 가 단계를 닫음 · 그 밖 [저장] = 2 · 3 진행 중 · 뒤 STALE(B1)
|
||||
sent = any(value is not None for key, value in request if key not in ("route_id", "confirm"))
|
||||
if sent and not (request.confirm and not moved and request.cross is not None):
|
||||
try:
|
||||
await _save_stages(str(project_id))
|
||||
except Exception as error:
|
||||
logger.exception("한 본문 저장 단계 상태 실패: project_id=%s", project_id)
|
||||
raise StepFailed("stages", str(error) or type(error).__name__) from None
|
||||
return out
|
||||
|
||||
|
||||
async def _save_stages(project_id: str) -> None:
|
||||
"""B05 · B06 한 본문 [저장] — 단계 2 · 3(실패면 한 본문 되돌림이 단계 행도 되살림)."""
|
||||
from common_util.common_util_workflow_state import mark_stage
|
||||
|
||||
await mark_stage(project_id, 2, "save")
|
||||
await mark_stage(project_id, 3, "save")
|
||||
|
||||
|
||||
@router.post("/{project_id}/design/save")
|
||||
async def save_design(
|
||||
project_id: UUID,
|
||||
|
||||
@@ -89,6 +89,7 @@ export function appendCardHeader(
|
||||
const label = document.createElement("strong");
|
||||
label.textContent =
|
||||
stationLabel(section.chainage_m, stationInterval) + (section.kind === "curve" ? " (MC)" : "");
|
||||
if (section.kind === "curve") label.className = "b06-station--mc"; // 507-3 크게 · 굵게 · 경고색
|
||||
const chainage = document.createElement("span");
|
||||
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
|
||||
title.append(label, chainage);
|
||||
|
||||
@@ -298,7 +298,10 @@ function drawChart(
|
||||
|
||||
function titleOf(section: CrossSection): string {
|
||||
const stations = host?.detail()?.longitudinal.stations ?? [];
|
||||
return stationLabel(section.chainage_m, inferStationInterval(stations));
|
||||
return (
|
||||
stationLabel(section.chainage_m, inferStationInterval(stations)) +
|
||||
(section.kind === "curve" ? " (MC)" : "")
|
||||
);
|
||||
}
|
||||
|
||||
/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */
|
||||
|
||||
@@ -404,7 +404,7 @@ export function createLongitudinalProfile(
|
||||
const stationTiers = labelTiers(
|
||||
data.stations.map((station, index) => ({
|
||||
x: x(station.chainage_m),
|
||||
width: labelWidthPx(stationTexts[index], 10),
|
||||
width: labelWidthPx(stationTexts[index], data.stations[index].kind === "curve" ? 12 : 10),
|
||||
})),
|
||||
);
|
||||
const named = data.stations.filter((station) => station.structure);
|
||||
@@ -424,7 +424,8 @@ export function createLongitudinalProfile(
|
||||
x: stationX,
|
||||
y: heightPx - bottomInsetPx - 5 - LABEL_TIER_PX * tier,
|
||||
"text-anchor": "middle",
|
||||
class: `b06-chart__station-label${tier ? " b06-chart__station-label--lifted" : ""}${tier && selected ? " b06-chart__station-label--selected" : ""}`,
|
||||
// MC = 사용자가 고칠 자리(507-3) — 크게 · 굵게 · 경고색(_Style_Cross.css)
|
||||
class: `b06-chart__station-label${station.kind === "curve" ? " b06-chart__station-label--mc" : ""}${tier ? " b06-chart__station-label--lifted" : ""}${tier && selected ? " b06-chart__station-label--selected" : ""}`,
|
||||
});
|
||||
if (tier) structureLabels.push(label);
|
||||
const marker = svgElement("g", {
|
||||
@@ -435,6 +436,8 @@ export function createLongitudinalProfile(
|
||||
role: "button",
|
||||
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`,
|
||||
});
|
||||
// 추가 측점 = 고른 뒤에도(선 클래스 --selected) B05 누름 판정이 읽음(505-2)
|
||||
if (station.plus) marker.setAttribute("data-plus", "1");
|
||||
marker.addEventListener("click", () => onSelectStation(station.station_id));
|
||||
marker.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
|
||||
|
||||
@@ -100,6 +100,8 @@ export function createSectionView(
|
||||
const cards = document.createElement("div");
|
||||
cards.className = "b06-section__cards";
|
||||
root.append(cards);
|
||||
let pendingReveal: string | null = null; // 숨은 동안 고른 카드 — 다음 다시 그림 끝에 밂(505-2)
|
||||
cards.onwheel = cards.onpointerdown = () => (pendingReveal = null); // 사용자 스크롤 · 누름 = 버림
|
||||
let currentDetail: SectionDetailResponse | null = null;
|
||||
let selectedStationId: string | null = null;
|
||||
/** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */
|
||||
@@ -211,6 +213,7 @@ export function createSectionView(
|
||||
*/
|
||||
const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => {
|
||||
if (!stationId) return;
|
||||
pendingReveal = stationId;
|
||||
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "start" });
|
||||
};
|
||||
|
||||
@@ -494,6 +497,8 @@ export function createSectionView(
|
||||
const keepScrollTop = cards.scrollTop;
|
||||
cards.replaceChildren(fillSlopeNotice.root, grid);
|
||||
cards.scrollTop = keepScrollTop;
|
||||
if (pendingReveal) revealCard(pendingReveal, "auto");
|
||||
pendingReveal = null;
|
||||
offscreen.drain();
|
||||
syncProfile();
|
||||
};
|
||||
|
||||
@@ -331,7 +331,7 @@ export function createStandardPanel(
|
||||
pending.className = "b06-std__pending";
|
||||
pending.textContent =
|
||||
"고친 값이 측점에 아직 안 실렸습니다 — [전체 측점 반영]을 누르면 바로 따라오고, " +
|
||||
"[저장]·[확정] 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다.";
|
||||
"[저장]·[>](확정) 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다.";
|
||||
const showPending = (): void => {
|
||||
const now = JSON.stringify(state);
|
||||
// 값이 실렸는지 눈으로도 캘 수 있게 남긴다 — 화면 실측에서 판정 근거가 된다.
|
||||
|
||||
@@ -332,6 +332,12 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* MC 측점 이름(507-3) — 카드 머리 글보다 한 단계 크게 · 경고색 */
|
||||
.b06-cross-card > header strong.b06-station--mc {
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 비정규 측점의 구조물 라벨 — kind(일반측점/BP/EP) 좌측에 자수정색 pill로 표기. */
|
||||
.b06-cross-card__structure {
|
||||
padding: 1px 6px;
|
||||
@@ -395,6 +401,19 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* MC(곡선 가운데) 측점 = 사용자가 고칠 자리(507-3) — 한 단계 크게 · 굵게 · 경고색. 고름 빨강은 아래가 이김. */
|
||||
.b06-chart__station-label--mc {
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
fill: var(--color-warning);
|
||||
}
|
||||
|
||||
.b06-station--mc {
|
||||
font-size: 1.15em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 축선 앞에서 멈춤 ·
|
||||
바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) · 색 = 원지반선 · 설계선(379-3 · `--b06-tag` = _Cross_Areas.css) */
|
||||
.b06-chart__center-box {
|
||||
@@ -436,6 +455,12 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* 지반선 · 계획선은 누름을 통과 — 겹친 측점선 누름이 측점 고름 · 임시 횡단으로 감(505-3) */
|
||||
.b06-chart__profile,
|
||||
.b06-chart__design-profile {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b06-chart__cross-profile {
|
||||
fill: none;
|
||||
stroke: var(--color-chart-1);
|
||||
|
||||
@@ -292,7 +292,7 @@ export function swapCrossSections(
|
||||
const place = {
|
||||
station_id: prior && !vacated ? prior.station_id : stationIdOf(station.chainageM),
|
||||
chainage_m: station.chainageM,
|
||||
label: formatStation(station.chainageM),
|
||||
label: formatStation(station.chainageM) + (kind === "curve" ? " (MC)" : ""), // 서버 꼴(507-3)
|
||||
kind,
|
||||
...(onGrid ? { grid: true } : {}),
|
||||
center_z: canon
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* B07 상세 설계 도면 목록·단건 API 클라이언트. */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import type { CadGuide } from "@ui/cad_host/cad_host";
|
||||
|
||||
export interface DesignDrawingItem {
|
||||
@@ -138,9 +138,14 @@ export interface DesignDrawingConfirmResponse {
|
||||
design?: CrossDesignInfo | null;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
/** `timeoutMs` = 끊는 한도 — 도면 GET · 저장 PUT 은 `API_ANALYSIS_TIMEOUT_MS`(평면도 작도 · 횡단 읽기가 30 s 넘음). */
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs: number = API_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
@@ -164,7 +169,11 @@ export function fetchDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<DesignDrawingResponse> {
|
||||
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
|
||||
{},
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmDesignDrawing(
|
||||
@@ -179,6 +188,7 @@ export function confirmDesignDrawing(
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ drawing, quantity_table: quantityTable ?? null }),
|
||||
},
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,6 +199,20 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
|
||||
);
|
||||
}
|
||||
|
||||
/** [초기화](298-2) 답 — 풀린 장 수 · 지운 설정 파일 · 잠정으로 돌린 측점 수. */
|
||||
export interface DesignDrawingResetResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
released: number;
|
||||
cleared: string[];
|
||||
provisional: number;
|
||||
}
|
||||
|
||||
/** [초기화](298-2) — 저장본 확정 풂 · 설정 지움(서버 `_Router_Reset.py`) · 본문 없음. */
|
||||
export function resetDesignDrawings(projectId: string): Promise<DesignDrawingResetResponse> {
|
||||
return requestJson(`/projects/${projectId}/design-drawings/reset`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** B07 단계(4) 완료(281) — 페이지 이동 저장 뒤 B08 · B09 로 갈 때(확정 잠금 없음). */
|
||||
export function completeDesignStage(projectId: string): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/design-drawings-complete`, { method: "POST" });
|
||||
|
||||
@@ -330,6 +330,11 @@ def station_no_label(chainage_m: float, interval_m: float) -> str:
|
||||
return f"No.{number}+{remainder:.1f}"
|
||||
|
||||
|
||||
def mc_suffix(station: dict[str, Any] | None) -> str:
|
||||
"""곡선 가운데(MC · `kind` curve) 측점 글 뒤 「 (MC)」 — 09-03 표기 위에 더함(507-3)."""
|
||||
return " (MC)" if isinstance(station, dict) and station.get("kind") == "curve" else ""
|
||||
|
||||
|
||||
def _profile_alignment(longitudinal: dict[str, Any]) -> dict[str, Any]:
|
||||
alignment = longitudinal.get("profile_alignment")
|
||||
return alignment if isinstance(alignment, dict) else {}
|
||||
|
||||
@@ -37,6 +37,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_stations,
|
||||
_text_entity,
|
||||
infer_station_interval,
|
||||
mc_suffix,
|
||||
points_from_samples,
|
||||
polyline_entity,
|
||||
station_no_label,
|
||||
@@ -155,7 +156,7 @@ def _long_table_values(
|
||||
distance = chainage - chainage_all[position - 1] if position > 0 else None
|
||||
rows.append(
|
||||
{
|
||||
"station": station_no_label(chainage, interval_m),
|
||||
"station": station_no_label(chainage, interval_m) + mc_suffix(station),
|
||||
"distance": _format(distance, 1),
|
||||
"cum_distance": _format(chainage, 1),
|
||||
"ground": _format(ground),
|
||||
|
||||
@@ -64,7 +64,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
from common_util.common_util_dev_unlock import is_dev_environment, unlock_status
|
||||
from common_util.common_util_drainage_context import load_drainage_context
|
||||
from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, start_stage
|
||||
from common_util.common_util_workflow_state import mark_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -470,10 +470,7 @@ async def confirm_design_drawing(
|
||||
patch={"status": "confirmed"},
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
if all_confirmed:
|
||||
await complete_stage(cursor, str(project_id), 4)
|
||||
else:
|
||||
await start_stage(cursor, str(project_id), 4)
|
||||
await mark_stage(str(project_id), 4, "save", cursor) # [저장](B1)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -536,7 +533,7 @@ async def invalidate_design_drawing(
|
||||
patch={"status": "provisional"},
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 4)
|
||||
await mark_stage(str(project_id), 4, "save", cursor)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -572,7 +569,7 @@ async def complete_design_stage(project_id: UUID) -> Any:
|
||||
await connection.begin()
|
||||
try:
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), _B07_STAGE_NO)
|
||||
await mark_stage(str(project_id), _B07_STAGE_NO, "next", cursor) # [>](B1)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -626,7 +623,7 @@ async def put_cross_layout(project_id: UUID, request: CrossLayoutRequest) -> Any
|
||||
patch={"status": "provisional"},
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 4)
|
||||
await mark_stage(str(project_id), 4, "save", cursor)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""B07 [초기화](요구 298-2 · 300 C1 · C3) — 그 페이지 사용자 값 · 설정을 지움.
|
||||
|
||||
POST /api/projects/{id}/design-drawings/reset 본문 없음
|
||||
|
||||
기준 = 저장본 없이 지금 B06 정본으로 그린 원본(떠 두지 않음 · 앞 단계 저장 = 저절로 새 기준).
|
||||
지움 = 모든 저장본 확정 풂(`confirmed=False` · 도면번호 남김 · json 걷음 = [원본에서 다시 그리기] 꼴) ·
|
||||
B07 이 확정으로 올린 측점 설계 → 잠정 · 설정 파일(축척 · 횡단 배치 · 표 배율 · 평면도 종류 · 표식).
|
||||
M02 층(도각 · 표지)은 안 건드림. 단계 = [저장]과 같음(`mark_stage` save).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B06_Section.B06_Section_Repository import merge_cross_section_design_by_round
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import drawing_scales_path
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source, _designs_by_chainage
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import CROSS_LAYOUT_FILE
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_design_root,
|
||||
_read_manifest,
|
||||
_write_manifest,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import _kinds_dir, _marks_path
|
||||
from common_util.common_util_workflow_state import mark_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
|
||||
def reset_design_files(project_root: Path) -> dict[str, Any]:
|
||||
"""저장본 풂 + 설정 파일 지움 → {released: 풀린 장 수, cleared: 지운 설정 이름}."""
|
||||
manifest = _read_manifest(project_root)
|
||||
released = 0
|
||||
for entry in manifest["drawings"].values():
|
||||
if entry.get("confirmed"):
|
||||
entry["confirmed"] = False
|
||||
released += 1
|
||||
if released:
|
||||
_write_manifest(project_root, manifest)
|
||||
cleared = []
|
||||
for path in (
|
||||
drawing_scales_path(project_root),
|
||||
_design_root(project_root) / CROSS_LAYOUT_FILE,
|
||||
_marks_path(project_root),
|
||||
):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
cleared.append(path.name)
|
||||
if _kinds_dir(project_root).is_dir():
|
||||
shutil.rmtree(_kinds_dir(project_root))
|
||||
cleared.append(_kinds_dir(project_root).name)
|
||||
return {"released": released, "cleared": cleared}
|
||||
|
||||
|
||||
@router.post("/{project_id}/design-drawings/reset")
|
||||
async def reset_design_drawings(project_id: UUID) -> Any:
|
||||
try:
|
||||
route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id)
|
||||
result = await asyncio.to_thread(reset_design_files, project_root)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
confirmed = [chainage for chainage, d in designs.items() if d.get("status") == "confirmed"]
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
for chainage in confirmed:
|
||||
await merge_cross_section_design_by_round(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage=chainage,
|
||||
patch={"status": "provisional"},
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await mark_stage(str(project_id), 4, "save", cursor)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
**result,
|
||||
"provisional": len(confirmed),
|
||||
}
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except PermissionError as exc:
|
||||
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B07 초기화 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "상세 설계를 초기화하지 못했습니다."},
|
||||
)
|
||||
@@ -21,6 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
GROUND_LAYER_ID,
|
||||
build_cross_drawing,
|
||||
infer_station_interval,
|
||||
mc_suffix,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
|
||||
@@ -220,6 +221,7 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra
|
||||
chainages = sheet["chainages"]
|
||||
first = station_by_chainage.get(chainages[0], {})
|
||||
station = station_no_label(float(first.get("chainage_m", chainages[0])), interval)
|
||||
station += mc_suffix(first)
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=sheet["id"],
|
||||
@@ -426,7 +428,8 @@ def _cross_section_input(
|
||||
),
|
||||
"design_line": _cross_design_line(longitudinal_path, source, design),
|
||||
"quantity_table": _quantity_table(source, design),
|
||||
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
|
||||
"title": station_no_label(float(source.get("chainage_m", chainage)), interval)
|
||||
+ mc_suffix(source),
|
||||
"overlay": overlay,
|
||||
}
|
||||
|
||||
@@ -616,7 +619,7 @@ def _read_drawing(
|
||||
# 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식)
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
title = station_no_label(float(source.get("chainage_m", 0.0)), interval)
|
||||
title = station_no_label(float(source.get("chainage_m", 0.0)), interval) + mc_suffix(source)
|
||||
# 계획고(로컬좌표 기준) + 노선 공통 범위 — 측점 이동 시 화면 배치 고정.
|
||||
design_elevation = design_elevation_from_longitudinal(
|
||||
longitudinal, float(source.get("chainage_m", 0.0))
|
||||
|
||||
@@ -67,8 +67,15 @@ def _read_manifest(project_root: Path) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
|
||||
"""쓰기 한 곳 — 풀린 장(`confirmed` 거짓)의 저장본 json · 수량표 찌꺼기를 함께 걷음."""
|
||||
stage_root = _design_root(project_root)
|
||||
stage_root.mkdir(parents=True, exist_ok=True)
|
||||
for drawing_id, entry in manifest["drawings"].items():
|
||||
if entry.get("confirmed") or "file" not in entry:
|
||||
continue
|
||||
(stage_root / "drawings" / f"{drawing_id}.json").unlink(missing_ok=True)
|
||||
for key in ("file", "quantity_table", "quantity_tables"):
|
||||
entry.pop(key, None)
|
||||
path = stage_root / "manifest.json"
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
@@ -25,7 +25,11 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
infer_station_interval,
|
||||
mc_suffix,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines, ip_table_piece
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_structure_label,
|
||||
@@ -502,6 +506,11 @@ def build_pieces(
|
||||
pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box)
|
||||
if "station" in wanted:
|
||||
items = []
|
||||
curve_at = {
|
||||
float(item["chainage_m"])
|
||||
for item in longitudinal.get("stations") or []
|
||||
if mc_suffix(item) and isinstance(item.get("chainage_m"), (int, float))
|
||||
}
|
||||
for chainage, x, y in stations:
|
||||
if not (start_m <= chainage <= end_m):
|
||||
continue
|
||||
@@ -509,6 +518,8 @@ def build_pieces(
|
||||
label = station_no_label(chainage, interval).replace("No.", "NO.")
|
||||
number_only = "+" not in label
|
||||
major = number_only and int(label[3:]) % 5 == 0
|
||||
if chainage in curve_at: # MC = 고칠 자리(507-3) — 5 배수가 아니어도 글 · 큰 글(major)
|
||||
label, major = label + " (MC)", True
|
||||
items.append(
|
||||
{
|
||||
"chainage_m": chainage,
|
||||
|
||||
@@ -29,7 +29,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_write_manifest,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_workflow_state import start_stage
|
||||
from common_util.common_util_workflow_state import mark_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
@@ -176,7 +176,7 @@ async def put_drawing_scales(project_id: UUID, request: DrawingScalesRequest) ->
|
||||
patch={"status": "provisional"},
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 4)
|
||||
await mark_stage(str(project_id), 4, "save", cursor)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
|
||||
import "./B07_DesignDetail_UI_Style.css";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
@@ -24,6 +29,7 @@ import {
|
||||
} from "./B07_DesignDetail_UI_Panels";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
clearWorkflowStateCache,
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
@@ -48,7 +54,15 @@ import {
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
|
||||
import { hasRedrawable, redrawFromSource, saveEditedDrawings } from "./B07_DesignDetail_UI_Save";
|
||||
import {
|
||||
hasRedrawable,
|
||||
isSaving,
|
||||
recoveryScopeOf,
|
||||
redrawFromSource,
|
||||
resetDrawings,
|
||||
saveDrawingsNow,
|
||||
saveEditedDrawings,
|
||||
} from "./B07_DesignDetail_UI_Save";
|
||||
import { openDrawingSettings } from "./B07_DesignDetail_UI_Settings";
|
||||
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
|
||||
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
|
||||
@@ -65,8 +79,6 @@ interface DesignMeta {
|
||||
/** 참이면 CAD 읽기 전용 — 잠금 없음(요구 281)이라 늘 거짓 · 저장본도 그대로 고침. */
|
||||
confirmed: boolean;
|
||||
quantityTable: QuantityTable | null;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
/** CAD 저장 응답 (도면 + 편집된 수량표). */
|
||||
@@ -117,7 +129,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadDirty = dirty;
|
||||
if (dirty) tabs.markDirty();
|
||||
},
|
||||
onNavigate: (direction) => navigateDrawing(direction),
|
||||
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
|
||||
onGuideChange: (area) => frameEditor.guideChanged(area),
|
||||
});
|
||||
@@ -189,19 +200,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
};
|
||||
|
||||
const buildMeta = (
|
||||
drawing: DesignDrawingItem,
|
||||
response: DesignDrawingResponse,
|
||||
index: number,
|
||||
): DesignMeta => ({
|
||||
const buildMeta = (drawing: DesignDrawingItem, response: DesignDrawingResponse): DesignMeta => ({
|
||||
kind: drawing.kind,
|
||||
drawingId: drawing.id,
|
||||
title: drawing.label,
|
||||
info: drawing.kind === "cross" ? drawing.label : "",
|
||||
confirmed: false, // 잠금 없음(요구 281)
|
||||
quantityTable: response.quantity_table ?? null,
|
||||
hasPrev: index > 0,
|
||||
hasNext: index < drawings.length - 1,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -244,8 +249,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const prefetchAllDrawings = async (): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
for (const item of drawings) {
|
||||
// 클릭이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다.
|
||||
while (loadInFlight) await new Promise((done) => window.setTimeout(done, 120));
|
||||
// 클릭 · [저장]이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다.
|
||||
while (loadInFlight || isSaving()) await new Promise((done) => window.setTimeout(done, 120));
|
||||
if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다
|
||||
try {
|
||||
await requestDrawing(item);
|
||||
@@ -284,8 +289,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
updateInfoPanel(drawing, response);
|
||||
// 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만)
|
||||
const guide = response.guide ?? null;
|
||||
const meta = buildMeta(drawing, response, index);
|
||||
cad.load(stash ?? response.drawing, meta, false, {}, { guide });
|
||||
const meta = buildMeta(drawing, response);
|
||||
const recoveryScope = recoveryScopeOf(projectId, drawing.id);
|
||||
cad.load(stash ?? response.drawing, meta, false, {}, { guide, recoveryScope });
|
||||
if (guideShown() && guide?.marks?.length) {
|
||||
showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning");
|
||||
}
|
||||
@@ -340,13 +346,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
void openTab(drawing);
|
||||
};
|
||||
|
||||
const navigateDrawing = (direction: "prev" | "next") => {
|
||||
if (currentIndex < 0) return;
|
||||
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (target < 0 || target >= drawings.length) return;
|
||||
void openTab(drawings[target]);
|
||||
};
|
||||
|
||||
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
|
||||
|
||||
/** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */
|
||||
@@ -399,7 +398,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
reset: () => resetFrameTemplate(projectId as string),
|
||||
},
|
||||
sendLoad: (drawing, meta, frameEdit, frameFields, extra) =>
|
||||
cad.load(drawing, meta, frameEdit, frameFields, extra),
|
||||
cad.load(drawing, meta, frameEdit, frameFields, {
|
||||
...extra,
|
||||
recoveryScope: `b07-frame:${projectId}`,
|
||||
}),
|
||||
setGuideArea: (area) => cad.setGuideArea(area),
|
||||
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
|
||||
restoreDrawing: () => {
|
||||
@@ -451,13 +453,35 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
|
||||
actions.className = "b07-drawing-actions ui-sidebar-actions";
|
||||
// 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]
|
||||
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움).
|
||||
// 액션 칸은 세 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]
|
||||
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움),
|
||||
// 3행 [?] [<] [>] [저장] [초기화](298-2 · B05 · B06 과 같은 차례 · [?] [<] [>] 는 공용이 앞에 붙임).
|
||||
const frameRow = document.createElement("div");
|
||||
frameRow.className = "b07-drawing-actions__row";
|
||||
frameRow.append(frameEditor.button);
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b07-drawing-actions__row";
|
||||
actionRow.dataset.helpRow = "";
|
||||
actionRow.append(frameEditor.button);
|
||||
actions.append(frameEditor.banner, actionRow);
|
||||
const refreshStages = (): void => {
|
||||
if (!projectId) return;
|
||||
clearWorkflowStateCache();
|
||||
void fetchWorkflowState(projectId)
|
||||
.then((state) => layout.setStages(state.stages))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
const saveButton = createButton({
|
||||
label: L("Common_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void saveDrawingsNow(store, refreshStages),
|
||||
});
|
||||
const resetButton = createButton({
|
||||
label: L("Common_Btn_Reset"),
|
||||
variant: "danger-ghost",
|
||||
onClick: () => void resetDrawings(store),
|
||||
});
|
||||
saveButton.disabled = resetButton.disabled = !projectId;
|
||||
actionRow.append(saveButton, resetButton);
|
||||
actions.append(frameEditor.banner, frameRow, actionRow);
|
||||
|
||||
drawingPanel.append(infoPanelHost, actions);
|
||||
|
||||
|
||||
@@ -178,7 +178,10 @@ export function buildDrawingSidePanel(
|
||||
}
|
||||
for (const drawing of items) {
|
||||
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
|
||||
section.append(drawingButton(drawing, drawing.label));
|
||||
const button = drawingButton(drawing, drawing.label);
|
||||
// 이름 뒤 「 (MC)」(507-3 덧) — 한 칸에 안 들어가 두 열을 가로지름
|
||||
if (drawing.label.includes("(MC)")) button.dataset.mc = "true";
|
||||
section.append(button);
|
||||
}
|
||||
if (structure && items.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* 고친 탭(●) + 지금 탭을 `confirm` 길로 · 안 고친 도면은 보내지 않음(늘 원본에서 새로 그림).
|
||||
* 잠금 없음 — 저장본도 그대로 고침 · 다시 옮길 때 다시 저장.
|
||||
* 원본 되돌림 = ⚙ 모달 [원본에서 다시 그리기] — 저장본이면 `invalidate` · 고친 것 버리고 다시 실음.
|
||||
* 좌측 [저장](298-2) = 이동 없이 같은 저장 · [초기화] = 물음 → `reset`(저장본 풂 · 설정 지움) →
|
||||
* CAD 자동백업 지움 → 페이지 다시 읽음(기준 = 지금 앞 단계로 그린 원본).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -18,7 +20,9 @@ import type { CadSaveResult } from "@ui/cad_host/cad_host";
|
||||
import type { CadTabs } from "@ui/cad_host/cad_host_tabs";
|
||||
import {
|
||||
confirmDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
invalidateDesignDrawing,
|
||||
resetDesignDrawings,
|
||||
type CadDrawing,
|
||||
type DesignDrawingItem,
|
||||
type QuantityTable,
|
||||
@@ -41,6 +45,24 @@ export interface DrawingStore {
|
||||
reload: (drawing: DesignDrawingItem) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 저장 PUT 이 도는 중인가 — 미리받기(`_UI_Page`)가 이 동안 쉼. */
|
||||
let saving = false;
|
||||
export const isSaving = (): boolean => saving;
|
||||
|
||||
/** CAD 자동백업 칸 이름 — 프로젝트 · 도면마다(같은 도면 id 의 남의 프로젝트 백업과 안 겹침). */
|
||||
export const recoveryScopeOf = (projectId: string, drawingId: string): string =>
|
||||
`b07:${projectId}:${drawingId}`;
|
||||
|
||||
/** 끊긴(abort) 저장 — 서버는 끝까지 저장하므로 목록의 `confirmed` 로 판정 · 못 읽으면 거짓. */
|
||||
async function savedOnServer(projectId: string, id: string): Promise<boolean> {
|
||||
try {
|
||||
const list = await fetchDesignDrawingList(projectId);
|
||||
return list.drawings.some((item) => item.id === id && item.confirmed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 좌측 단추 저장본 띠(초록) — 저장본 = 서버 `confirmed`. */
|
||||
function markButton(id: string, saved: boolean): void {
|
||||
document
|
||||
@@ -74,12 +96,18 @@ export async function saveEditedDrawings(store: DrawingStore): Promise<boolean>
|
||||
drawing: CadDrawing,
|
||||
table: QuantityTable | null,
|
||||
) => {
|
||||
await confirmDesignDrawing(store.projectId, item.id, drawing, table);
|
||||
try {
|
||||
await confirmDesignDrawing(store.projectId, item.id, drawing, table);
|
||||
} catch (error) {
|
||||
const aborted = error instanceof DOMException && error.name === "AbortError";
|
||||
if (!aborted || !(await savedOnServer(store.projectId, item.id))) throw error;
|
||||
}
|
||||
item.confirmed = true;
|
||||
markButton(item.id, true);
|
||||
store.forget(item.id); // 저장본이 정본 — 다음 열기는 서버에서
|
||||
};
|
||||
showLoadingOverlay();
|
||||
saving = true;
|
||||
try {
|
||||
if (currentEdited && current) {
|
||||
const saved = await store.requestCad();
|
||||
@@ -104,6 +132,7 @@ export async function saveEditedDrawings(store: DrawingStore): Promise<boolean>
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
saving = false;
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -145,3 +174,51 @@ export async function redrawFromSource(store: DrawingStore): Promise<boolean> {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** 좌측 [저장](298-2) — 이동 없이 고친 탭 저장 · 저장했으면 `onSaved`(단계 색 다시 읽음). */
|
||||
export async function saveDrawingsNow(store: DrawingStore, onSaved: () => void): Promise<void> {
|
||||
if (!store.cadDirty() && !store.tabs.anyDirty()) {
|
||||
showToast("고친 도면이 없습니다.", "info");
|
||||
return;
|
||||
}
|
||||
if (!(await saveEditedDrawings(store))) return;
|
||||
showToast("저장했습니다.", "success");
|
||||
onSaved();
|
||||
}
|
||||
|
||||
/** CAD 자동백업 칸(`openwebcad/src/helpers/autosave.ts`) — 칸 이름 = `recoveryScopeOf`. */
|
||||
const CAD_RECOVERY_KEY = "OPEN_WEB_CAD__RECOVERY";
|
||||
|
||||
/**
|
||||
* 좌측 [초기화](298-2 · 300 C1 · C3) — 물은 뒤 서버가 저장본을 풀고 설정을 지움 → 고친 것 · CAD 자동백업 버림 →
|
||||
* 페이지 다시 읽음(다음 GET 이 지금 앞 단계 저장본으로 원본을 그림). [취소] = 그대로.
|
||||
*/
|
||||
export async function resetDrawings(store: DrawingStore): Promise<void> {
|
||||
if (store.isFrameEditing()) {
|
||||
showToast("도각 편집을 마친 뒤 초기화하세요.", "warning");
|
||||
return;
|
||||
}
|
||||
const ok = await showConfirmDialog(
|
||||
"이 페이지에서 저장한 도면 · 고친 것 · 설정(축척 · 횡단 배치 · 평면도 종류 · 표식)을 모두 지우고 " +
|
||||
"지금 앞 단계 값으로 다시 그릴까요? " +
|
||||
"(프로젝트 도각은 그대로 — 되돌리려면 [도각 편집] → [기본 도각 복원])",
|
||||
"초기화",
|
||||
);
|
||||
if (!ok) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await resetDesignDrawings(store.projectId);
|
||||
store.drawings.forEach((item) =>
|
||||
localStorage.removeItem(`${CAD_RECOVERY_KEY}__${recoveryScopeOf(store.projectId, item.id)}`),
|
||||
);
|
||||
showToast("초기화했습니다.", "success");
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "상세 설계를 초기화하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,14 +116,22 @@ function buildScaleFields(
|
||||
void save({ x: scaleToSend(x, fields[0].fallback), y: scaleToSend(y, fields[1].fallback) });
|
||||
},
|
||||
});
|
||||
// 칸 밑 오류 글은 숨기고(틀린 칸만 길어짐) 줄 아래 한 줄로 알림
|
||||
const alertLine = document.createElement("p");
|
||||
alertLine.className = "b07-scale__error";
|
||||
alertLine.setAttribute("role", "alert");
|
||||
alertLine.hidden = true;
|
||||
const check = (): void => {
|
||||
let bad = false;
|
||||
const errors = new Map<string, string[]>();
|
||||
for (const f of fields) {
|
||||
const result = f.read();
|
||||
f.field.setError(typeof result === "string" ? result : undefined);
|
||||
bad ||= typeof result === "string";
|
||||
const message = typeof result === "string" ? result : undefined;
|
||||
f.field.setError(message);
|
||||
if (message) errors.set(message, [...(errors.get(message) ?? []), f.axis.toUpperCase()]);
|
||||
}
|
||||
saveButton.disabled = bad;
|
||||
alertLine.textContent = [...errors].map(([m, axes]) => `${axes.join(" · ")}: ${m}`).join(" / ");
|
||||
alertLine.hidden = errors.size === 0;
|
||||
saveButton.disabled = errors.size > 0;
|
||||
};
|
||||
for (const f of fields) {
|
||||
f.field.input.addEventListener("input", check);
|
||||
@@ -153,5 +161,5 @@ function buildScaleFields(
|
||||
const buttons = document.createElement("div");
|
||||
buttons.className = "b07-scale__actions";
|
||||
buttons.append(reset, saveButton);
|
||||
section.append(title, row, buttons);
|
||||
section.append(title, row, alertLine, buttons);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user