diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 6d7d2a4af..7ce332dd7 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -326,14 +326,23 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심 /** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다. * * 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다. - * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */ -export async function fetchCachedSheetLayer(projectId: string, layer: string): Promise { + * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. + * `scope` = 그 화면이 보는 범위만 자른 사본(route = 노선 띠 · drainage = 유역 + 여유 · 509-1 · 509-6) + * — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다. */ +export async function fetchCachedSheetLayer( + projectId: string, + layer: string, + scope?: SheetScope, +): Promise { + const range = scope ? `&scope=${scope}` : ""; return fetchCachedJson( 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), + ), + ), ); // 위성사진은 로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다. await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, { diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 8d6e8082e..5462c2491 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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": { diff --git a/A00_Common/design/design_calc_sites.ts b/A00_Common/design/design_calc_sites.ts new file mode 100644 index 000000000..7ecd81f8b --- /dev/null +++ b/A00_Common/design/design_calc_sites.ts @@ -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; + groups: CalcSiteGroup[]; +} + +async function request(init: RequestInit = {}): Promise { + 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 => request(); + +export const saveCalcSites = (sites: Record): Promise => + request({ method: "PUT", body: JSON.stringify({ sites }) }); + +let opened: Promise> | null = null; + +/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */ +export function openCalcSites(): Promise> { + opened = fetchCalcSites().then( + (board) => board.sites, + () => ({}), + ); + return opened; +} + +/** 이 묶음을 조작 중에도 서버가 셈? — 받기 전이면 받기를 기다림. */ +export async function calcOnServer(key: string): Promise { + return (await (opened ?? openCalcSites()))[key] === "server"; +} diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index 9b2c1a00a..7959a910c 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -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) => 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(); diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index 3c9a534b8..55fdd5005 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -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(); +/** 지금 고침 지문(짧게) — 저장 본문을 낸 때와 끝난 때를 견줌(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, ): 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 => { if (!root.isConnected) return true; if (SAME_PAGE.has(currentRoute())) { diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index b169717c2..81881acbe 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -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; diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 1d54d57e2..4f0515c18 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -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); }, }); diff --git a/A00_Common/design/design_page_data.ts b/A00_Common/design/design_page_data.ts index 1426b964d..74143063c 100644 --- a/A00_Common/design/design_page_data.ts +++ b/A00_Common/design/design_page_data.ts @@ -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(); -/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */ +/** 이 누름이 묶음 차례를 띄웠나 — 그 누름 안 폼 다시 실음은 좌측 패널 안 폄(`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 | 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 => { diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 4382d9be0..cd22d4114 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -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, + 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]]); +} diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index cf56a56db..cb2021ab2 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -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 => { // 뒤 차례(B06 cross 등)에 밀림 · 초안은 그대로 버려짐 — 마지막 차례 끝에서 되돌림(B05 와 같게) const pushed = record.outcome === "dropped" && record.generation !== pipe.generation; - if (pushed && dropPending && !pipe.input("route")) - return (lastRun as Promise).then(finishDrop); + if (pushed && dropPending && !pipe.input("route")) { + const next = await (lastRun as Promise); + 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(); diff --git a/A00_Common/design/design_pipeline_probe.ts b/A00_Common/design/design_pipeline_probe.ts index daa0da8cf..83e22c5dc 100644 --- a/A00_Common/design/design_pipeline_probe.ts +++ b/A00_Common/design/design_pipeline_probe.ts @@ -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 { +): 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[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 }; } diff --git a/A00_Common/design/design_reset.ts b/A00_Common/design/design_reset.ts index 44b660b48..20b78745e 100644 --- a/A00_Common/design/design_reset.ts +++ b/A00_Common/design/design_reset.ts @@ -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 { - 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); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index eda24c8cf..f814effb7 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -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>; /** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */ crossPart: (confirm: boolean) => Promise; - /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ - done?: (confirm: boolean) => Promise | void; + /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. `reload` 거짓 = 알림만(노선 바뀐 저장 = + * 곧 다시 세움 · 옛 노선 id 로 다시 읽기는 헛일 · 498-3). */ + done?: (confirm: boolean, reload?: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; + /** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */ + routeMoved?: () => Promise; } export type PartOverride = ( @@ -73,6 +76,47 @@ export type PartOverride = ( ) => Promise | SavePart | null; const saving = new Set(); +/** 프로젝트마다 지금 도는 저장 한 번(498-3 D1) — 확정인가 · 끝 약속. */ +const inflight = new Map }>(); +/** 저장 중 막음 알림 — [저장] · [초기화] 같은 글(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( + projectId: string, + confirm: boolean, + job: () => Promise, +): Promise { + 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> = {}; // 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀 @@ -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(); } } diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index cef8daf8e..2e538b2f0 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -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>[0]; /** 시설 줄 한 벌(462) — 알약 · 비정규 측점 · 흙덮기 관 · 관 id 장부 · 평면 마커 원천. 보기는 읽기만. */ lane: FacilityLane; - /** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */ - revealLeft: () => void; /** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */ ensureView: (view: DesignView) => Promise; + /** 자료가 섰다(`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(); + +/** 자료가 섰다 — [저장] · 확정 이동을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */ 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 { + 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 { } /** - * [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, - * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳. + * [저장] · 확정 한 번(확정 = `target` 으로 감 · 기본 B07) — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, + * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳 · 줄 세움은 `queueDesignSave`(498-3). */ -export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise { +export async function saveSharedDesign( + shared: DesignPageShared, + confirm: boolean, + target: RoutePath = WORKFLOW_STEP_ROUTES[4], +): Promise { + await queueDesignSave(shared.projectId, confirm, () => saveOnce(shared, confirm, target)); +} + +async function saveOnce( + shared: DesignPageShared, + confirm: boolean, + target: RoutePath, +): Promise { 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"; diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 1448cc72e..fac7319a8 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -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 ?? ""); diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index ee2e6a242..de52b3b7b 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -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) | 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 { +export function setPipeCrossOpener(opener: () => Promise): () => 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["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(모름). */ diff --git a/A00_Common/design/design_user_pick.ts b/A00_Common/design/design_user_pick.ts index dbd062541..3d075da31 100644 --- a/A00_Common/design/design_user_pick.ts +++ b/A00_Common/design/design_user_pick.ts @@ -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); } diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 9711831d4..ccee54e26 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -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) => { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 57ce81425..bf8e88b5e 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -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; diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index 204cc2b15..053a4d96b 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -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 { - 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 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */ diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts index 432177a8e..73b09f8c7 100644 --- a/A00_Common/m_settings_nav.ts +++ b/A00_Common/m_settings_nav.ts @@ -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), diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index 66532ac0e..4b0088898 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -29,8 +29,11 @@ async function post(path: string, body?: object): Promise { return result; } -export function requestLogin(email: string, password: string): Promise { - return post("/auth/login/request", { email, password }); +export async function requestLogin(email: string, password: string): Promise { + const result = await post("/auth/login/request", { email, password }); + // 화면이 뜰 때 기억한 「로그인 안 됨」(5 s)을 응답 뒤에 버림 — 안 버리면 대시보드 가드가 되튕김(309-1) + clearSessionCache(); + return result; } export function verifyLogin(email: string, otpCode: string): Promise { diff --git a/A06_Login/A06_Login_Router.py b/A06_Login/A06_Login_Router.py index e60f62f4a..162fc091c 100644 --- a/A06_Login/A06_Login_Router.py +++ b/A06_Login/A06_Login_Router.py @@ -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"]) diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 062cfcef8..654e249ec 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index 9218ab0d9..fe3346455 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts new file mode 100644 index 000000000..e73d2d505 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts @@ -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[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, + 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, + 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(); + 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 { + 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 => + 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 => { + 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), + ); + }, + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 82515ecfc..c56b848d1 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -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; diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 114a72f0e..c25e72392 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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; +} diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py new file mode 100644 index 000000000..cde050a88 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetClip.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py index eb44d6739..9d386804c 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py @@ -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, diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index d2b7132b0..eb85536ba 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -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( diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index 1760e03cd..db067e4ab 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -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" diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index b36c07fee..723e3f9d8 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -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, diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 2f86f503d..c0b468e71 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -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(); } diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index a91f14ddf..955af7888 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -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); } } diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 5c773c27a..2e4f384ee 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -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(); - -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("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> { 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 }>( `/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; diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index b822fb7bb..3c076ed39 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -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, ); diff --git a/B05_Profile/B05_Profile_Api_TerrainGrid.ts b/B05_Profile/B05_Profile_Api_TerrainGrid.ts index 14d5387c6..a689672d5 100644 --- a/B05_Profile/B05_Profile_Api_TerrainGrid.ts +++ b/B05_Profile/B05_Profile_Api_TerrainGrid.ts @@ -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 { +export function groundStageDeps( + projectId: string, +): Pick { return { grid: () => designTerrainGridNow(projectId), + serverOnly: () => calcOnServer("ground_sample"), async remote(points, terrain, signal) { const chunks: Array> = []; for (let i = 0; i < points.length; i += ELEVATION_CHUNK) { diff --git a/B05_Profile/B05_Profile_Engine_InitialProfile.py b/B05_Profile/B05_Profile_Engine_InitialProfile.py index 457b7f28b..7ecc75a21 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -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(): diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index fd2ce7e22..396cc8fc8 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -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( diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 4960428ae..4c91bf6dc 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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 diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 8d71fba47..388f627ff 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -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())) diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index 6ba3030ab..eee34ca91 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -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:` · 구조물 `structure:` · 시종점 `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) diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 79abc5471..7adb46a34 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -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장). 자동설계 diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index c33ce63a3..b1104b39e 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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, ) diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index bc894ebe2..7e3c62b1c 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -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 diff --git a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py index 78407b6e5..85309c903 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial_Initial.py @@ -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) diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index bbf439ac5..27faaa26c 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -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: diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 38b31f5d8..cd1ef7aed 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -44,6 +44,12 @@ interface CacheEntry { const cache = new Map(); +/** 지금 노선 한 벌만 — 노선 [저장]마다 새 노선 번호라 옛 칸이 쌓임(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; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts index 8d2f43114..fe9cf0958 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts @@ -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 { + if (await calcOnServer("corridor_mesh")) return null; const onMain = (): CorridorBuildResult | null => buildCorridor(crossSections, routePoints, designSamples); const target = getWorker(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 1680299ea..042f952c1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -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; @@ -453,7 +459,12 @@ export async function fetchDrainageLayers( DRAINAGE_LAYERS.map(async (layer) => { try { // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. - return [layer, await fetchCachedSheetLayer(projectId, layer)] as const; + const collection = await fetchCachedSheetLayer( + projectId, + layer, + scope ?? undefined, + ); + return [layer, collection] as const; } catch { return [layer, null] as const; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Settings.ts b/B05_Profile/B05_Profile_UI_Panel_Settings.ts index d66adf1f8..daf80e9aa 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Settings.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Settings.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css index b78246e7c..4f7cff4dc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -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) 밑 */ diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index b89a16345..7acac045c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -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(`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 {
임시 횡단 +
+ + + +
-
-
- - - -
`; +
`; const $ = (selector: string): T => root.querySelector(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); diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 5d8ece274..13880cf22 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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 diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 3dd442f14..7f137d4b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -43,6 +43,8 @@ export interface ProfileTableOptions { irregularStations?: IrregularStation[]; /** 현재 선택된 측점 id. 규칙/비정규 측점 모두 그 측점의 값 열을 테이블에 겹쳐 보여준다. */ selectedStationId?: string | null; + /** MC(곡선 가운데) 측점 누가거리(`toFixed(3)` 열쇠) — 측점 행에 「 (MC)」 · 크게 · 굵게 · 경고색(507-3 덧). */ + mcKeys?: ReadonlySet; /** 이어 공사 시작 기준 — 측점번호·누가거리 표시를 이만큼 더한다(내부 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), ), ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 520474528..86ad60483 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index b43a99946..effc05e50 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -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 { 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); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index a59ecb239..dea91d8fc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -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( - +
@@ -253,7 +249,7 @@ export function createRouteEditChrome( const minorContourButton = overlay.querySelector( '[data-act="contour-minor"]', )!; - const terrainSelect = overlay.querySelector('[data-act="terrain"]')!; + const terrainName = overlay.querySelector('[data-slot="terrain-name"]')!; const stationSnapButton = overlay.querySelector('[data-act="station-snap"]')!; bindFootprintToggle(overlay.querySelector('[data-act="footprint"]')!); bindMarkToggle(overlay.querySelector('[data-act="stations"]')!, "stations"); @@ -269,6 +265,6 @@ export function createRouteEditChrome( measureButton, minorContourButton, stationSnapButton, - terrainSelect, + terrainName, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index 41ad14506..0805c6e14 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -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, + 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; + /** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */ + line?: () => ReadonlyArray; +}; + +/** 노선 편집이 받는 도엽 — 노선 띠로 자른 것(509-1). `line` = 지금 노선(배경 준비 때 읽음). */ +export async function fetchRouteSheets( + projectId: string, + line: () => ReadonlyArray, +): Promise { + return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line }; +} + /** * 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선). - * 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다. + * 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의 + * 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다. */ export async function loadRouteEditBackground( projectId: string, - drainage: { - meta: VWorldMeta; - layers: ReadonlyArray; - }, + 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 !== "도엽_등고선") diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index e53421e4c..6b637970b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -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 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts index f68879dce..f99e4050e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts @@ -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()); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 42ab19b74..a2ed117c6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -180,6 +180,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar { ); } + // 가둠 판(도구줄 · 횡단 판 · 종단 판)이 열리고 닫히고 크기가 바뀌면 다시 가둠(505-3) · 편집 창이 닫히면 끊음 + const watched = new Set(); + 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) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index b1cf1376a..c6dcf7fd7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -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 { 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, ); }, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 0fa86ec1c..8ffaf9250 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -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). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts index c7db6c03d..98a82b7dd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts @@ -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); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 38013b006..40edb8fbd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 9c8b288ad..a58cbe090 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -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 = { 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; + /** 고를 수 있는 지형(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 {
+
diff --git a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx index 046c48930..c99d8e61c 100644 --- a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx @@ -19,10 +19,7 @@ export const QuickAccessBar: FC = () => { key={command.id} hidden={QUICK_ACCESS_HIDDEN.includes(id)} title={`${command.label} (${command.id})`} - onClick={(event) => { - event.currentTarget.blur(); - runCommand(command); - }} + onClick={() => runCommand(command)} > {command.glyph} diff --git a/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx index 4b77e6555..11555c878 100644 --- a/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx +++ b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx @@ -7,7 +7,7 @@ import type { TextEntity } from '../entities/TextEntity'; import { LINE_TYPES, LINE_WIDTHS, - repaint, + paintNowCommitLater, restyle, selectionLineStyle, styleTargets, @@ -15,7 +15,6 @@ import { import { getActiveLayerId, getActiveTextStyle, - getEntities, getLayers, getSelectedEntities, setActiveLayerId, @@ -23,29 +22,10 @@ import { setActiveLineDash, setActiveLineWidth, setActiveTextStyle, - setEntities, } from '../state'; const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace']; -/** - * 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면 - * 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 첫 변경에 사본을 끼우고 끄는 동안에는 - * 그 사본만 칠하고, 멎은 뒤 한 번만 되돌리기·통지에 싣는다(488 — Ctrl+Z 한 번에 옛 색). - */ -const COLOR_COMMIT_DELAY_MS = 350; -let colorCommitTimer: ReturnType | undefined; -let painting: Entity[] | null = null; -function paintNowCommitLater(targets: Entity[], change: (entity: Entity) => void): void { - if (painting) repaint(painting, change); - else painting = restyle(targets, change, false); // 화면만 - clearTimeout(colorCommitTimer); - colorCommitTimer = setTimeout(() => { - painting = null; - setEntities([...getEntities()], true); // 멎었다 — 이번 한 번만 실어 보낸다 - }, COLOR_COMMIT_DELAY_MS); -} - /** 고른 선 있으면 그것만 바꿈(488 — 그리기 기본값 그대로) · 없으면 그리기 기본값만 */ const selectedLines = () => styleTargets(getSelectedEntities()); diff --git a/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts b/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts index fa9b60bcd..cc5e06fa0 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/selection-style.ts @@ -106,3 +106,22 @@ export function repaint(copies: Entity[], change: (entity: Entity) => void): voi setEntities([...getEntities()], false); notifyWindow(HtmlEvent.UPDATE_STATE); // 견본 색도 따라감 } + +/** + * 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면 + * 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 첫 변경에 사본을 끼우고 끄는 동안에는 + * 그 사본만 칠하고, 멎은 뒤 한 번만 되돌리기·통지에 싣는다(488 — Ctrl+Z 한 번에 옛 색). + * 리본 특성 · 특성 판 색 칸이 같이 씀(506-3). + */ +const COLOR_COMMIT_DELAY_MS = 350; +let colorCommitTimer: ReturnType | undefined; +let painting: Entity[] | null = null; +export function paintNowCommitLater(targets: Entity[], change: (entity: Entity) => void): void { + if (painting) repaint(painting, change); + else painting = restyle(targets, change, false); // 화면만 + clearTimeout(colorCommitTimer); + colorCommitTimer = setTimeout(() => { + painting = null; + setEntities([...getEntities()], true); // 멎었다 — 이번 한 번만 실어 보낸다 + }, COLOR_COMMIT_DELAY_MS); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-touch.css b/B07_DesignDetail/openwebcad/src/inputController/input-touch.css deleted file mode 100644 index e60591156..000000000 --- a/B07_DesignDetail/openwebcad/src/inputController/input-touch.css +++ /dev/null @@ -1,29 +0,0 @@ -/* 손가락 기기 명령 단추(기기대응 4-1 · `input-touch.ts`) — 자판 없이 Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅. - 자판 없는 기기(손가락이 주 입력 · 마우스 스침 없음)만 보임 — 터치 노트북엔 안 보임(요구 280-6). 그림 면 오른쪽 가운데에 세로로. */ -.cad-touch-bar { - display: none; -} - -@media (hover: none) and (pointer: coarse) { - .cad-touch-bar { - position: fixed; - top: 50%; - right: 8px; - z-index: 50; - display: flex; - flex-direction: column; - gap: 6px; - transform: translateY(-50%); - } - - .cad-touch-bar > button { - width: 44px; - height: 44px; - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-surface-raised); - color: var(--color-text); - font-size: 18px; - box-shadow: var(--shadow-sm); - } -} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts b/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts index 432cf5b54..75cc9752e 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts @@ -2,7 +2,6 @@ import { Point } from '@flatten-js/core'; import { setTouchPickPx } from '../helpers/pick-radius.ts'; import { getCanvas, getScreenCanvasDrawController } from '../state.ts'; import type { InputController } from './input-controller.ts'; -import './input-touch.css'; /** * 손가락 CAD(기기대응 4-1) — 마우스 코드는 그대로 두고 손가락만 여기서 번역한다. @@ -11,7 +10,6 @@ import './input-touch.css'; * · 한 손가락 끌기 = 화면 옮기기 · 두 손가락 = 벌려 확대 + 같이 끌어 옮기기 * · 길게 누름(450ms) = 정밀 고르기 — 손가락 위 70px 에 십자선 · 끌어 맞추고 떼면 그 자리 클릭 * · 고름 반경 = 접촉 크기(최소 24px) · 마우스로 누르면 원래 반경 - * · 자판 없이 쓰는 명령 단추(Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅) — 손가락 기기에서만 보임(App.css) */ const LONG_MS = 450; const SLOP_PX = 10; @@ -115,30 +113,4 @@ export function attachTouch(ctl: InputController): void { }; canvas.addEventListener('pointerup', (e) => lift(e, false)); canvas.addEventListener('pointercancel', (e) => lift(e, true)); - - mountTouchBar(ctl); -} - -/** 자판 없는 명령 단추 — 단축키와 같은 길(`handleKeyStroke`)로 보낸다 */ -function mountTouchBar(ctl: InputController): void { - const bar = document.createElement('div'); - bar.className = 'cad-touch-bar'; - const keys: [string, string, KeyboardEventInit][] = [ - ['Esc', '취소(Esc)', { key: 'Escape' }], - ['↵', '확정(Enter)', { key: 'Enter' }], - ['🗑', '지움(Delete)', { key: 'Delete' }], - ['↶', '되돌리기(Ctrl+Z)', { key: 'z', code: 'KeyZ', ctrlKey: true }], - ['↷', '다시(Ctrl+Y)', { key: 'y', code: 'KeyY', ctrlKey: true }], - ['⌖', '스냅 켬/끔(F3)', { key: 'F3' }], - ]; - for (const [label, title, init] of keys) { - const b = document.createElement('button'); - b.type = 'button'; - b.textContent = label; - b.title = title; - b.setAttribute('aria-label', title); - b.addEventListener('click', () => ctl.handleKeyStroke(new KeyboardEvent('keydown', init))); - bar.append(b); - } - document.body.append(bar); } diff --git a/B07_DesignDetail/openwebcad/src/integration/host-burger.css b/B07_DesignDetail/openwebcad/src/integration/host-burger.css index 3fd4af82b..0609ce39e 100644 --- a/B07_DesignDetail/openwebcad/src/integration/host-burger.css +++ b/B07_DesignDetail/openwebcad/src/integration/host-burger.css @@ -1,4 +1,5 @@ -/* 앱 ☰ 자리 비키기(기기대응 7-2) — 값은 host-burger.ts 가 적음 · 틀 없이 띄우면 0 */ -html .cad-ribbon-tabs { +/* 앱 ☰ 자리 비키기(기기대응 7-2) — 값은 host-burger.ts 가 적음 · 틀 없이 띄우면 0 · ☰ 높이(44)가 탭 줄 아래 도구 줄까지 걸침 */ +html .cad-ribbon-tabs, +html .cad-ribbon-panels { padding-left: max(8px, var(--host-burger-pad, 0px)); } diff --git a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx index 954404559..d3ec81f83 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx +++ b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx @@ -35,11 +35,7 @@ const CommandButton: FC = ({ command, size, activeTool, onRu title={`${command.label}${command.aliases?.length ? ` (${command.aliases[0]})` : ''}${ command.hint ? ` — ${command.hint}` : '' }`} - onClick={(event) => { - // 초점 테두리가 남아 「선택」과 둘이 켜진 듯 보이지 않게(487) - event.currentTarget.blur(); - onRun(command); - }} + onClick={() => onRun(command)} > {command.glyph} {command.label} @@ -139,8 +135,7 @@ const Panel: FC<{ type="button" key={command.id} title={command.hint} - onClick={(event) => { - event.currentTarget.blur(); + onClick={() => { setOverflowAt(null); onRun(command); }} @@ -205,7 +200,16 @@ export const Ribbon: FC = ({ activeTool, widgets, onCommandRun }) = }; return ( -