Merge remote-tracking branch 'origin/안티그래비티' into 안티그래비티

This commit is contained in:
2026-10-22 06:43:27 +09:00
464 changed files with 13249 additions and 4630 deletions
+17 -4
View File
@@ -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`, {
-2
View File
@@ -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": {
+59
View File
@@ -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";
}
+5 -13
View File
@@ -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();
+8 -3
View File
@@ -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())) {
+5
View File
@@ -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;
+20 -4
View File
@@ -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);
},
});
+8 -3
View File
@@ -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 => {
+29 -5
View File
@@ -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]]);
}
+8 -12
View File
@@ -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();
+12 -3
View File
@@ -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 };
}
+8 -1
View File
@@ -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);
+81 -16
View File
@@ -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();
}
}
+86 -18
View File
@@ -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";
+6 -1
View File
@@ -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 -4
View File
@@ -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);
}
+7 -4
View File
@@ -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) => {
+1 -1
View File
@@ -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 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */
+9 -2
View File
@@ -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),
+5 -2
View File
@@ -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> {
+9 -19
View File
@@ -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"])
+36
View File
@@ -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)
+6
View File
@@ -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)
+155
View File
@@ -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),
);
},
});
}
+5 -2
View File
@@ -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;
+23
View File
@@ -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(
+8 -6
View File
@@ -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();
}
+59 -13
View File
@@ -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);
}
}
+10 -35
View File
@@ -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;
+5 -3
View File
@@ -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 -2
View File
@@ -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
+31 -6
View File
@@ -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()))
+22 -4
View File
@@ -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장). 자동설계
+9 -6
View File
@@ -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,
)
+84 -32
View File
@@ -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)
+22 -4
View File
@@ -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:
+8 -2
View File
@@ -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();
+14 -3
View File
@@ -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;
}
+4 -3
View File
@@ -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);
+41 -29
View File
@@ -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
+20 -3
View File
@@ -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),
),
);
}
+17 -41
View File
@@ -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();
+15 -20
View File
@@ -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);
+6 -10
View File
@@ -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). */
+7 -11
View File
@@ -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;
+2 -2
View File
@@ -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()
+23 -6
View File
@@ -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);
+4 -1
View File
@@ -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)" : "")
);
}
/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */
+5 -2
View File
@@ -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();
};
+1 -1
View File
@@ -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
+28 -4
View File
@@ -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),
+5 -8
View File
@@ -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()
+52 -28
View File
@@ -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");
+78 -1
View File
@@ -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