feat(509-5 · 509-2 B05 추천 지형): 분석 지형 고르기 = 추천 노선 창 안 · 지형마다 추천 미리 셈 · [적용] = 창 지형이 설계 지형 · 평면은 지형 이름만 · 설계 지형 격자 하나(수치지형도 적용이면 그 격자)

- 전처리 미리 셈이 고를 수 있는 지형마다(설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만) 다섯 갈래 + 초기 종단
- 창 지형 바꿈 = 그 지형 저장본만 · 없으면 409 안내(+[재계산]) · 기본값 판정에서 지형 뺌
- POST /route/initial terrain = 창 지형 · [적용] draft.terrain = 창 지형 · 손 [저장]은 지형 안 바꿈(서버도 as_initial 일 때만)
- 평면 분석 지형 고르기 · 화면 취향(routeedit-terrain) 걷음 · 지반고 · 묶음 초안 = 설계 지형(null) · 적용 뒤 받아 둔 격자 버림
- 시험 test_509_5 · test_509_2 · 걷은 고르기를 보던 옛 시험 새 동작으로(352_3 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:41:20 +09:00
co-authored by Claude Opus 5.5
parent 23fd2f6f8d
commit 4eec3a4b37
30 changed files with 441 additions and 169 deletions
+8 -35
View File
@@ -6,51 +6,24 @@
* POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial)
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
*
* 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
* 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음.
* 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 지형 고르기는 추천 창 안 · [적용]이
* 그 지형을 설계 지형으로(509-5).
*
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { readState, writeState } from "../A00_Common/b_page_state";
import { heightAt } from "../common_util/common_util_surface_sampler";
import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import type { RouteReset } from "./logic/B05_Profile_Route_Reset";
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
/** 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. 고르기는 추천 노선 창 안(509-5). */
export type AnalysisTerrain = "las" | "sheet";
/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */
const terrainContext = new Map<string, { choosable: boolean; design: AnalysisTerrain }>();
export const setTerrainContext = (
projectId: string,
choosable: boolean,
design: AnalysisTerrain,
): void => void terrainContext.set(projectId, { choosable, design });
/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 ·
* 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다).
* 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */
export const analysisTerrain = (
projectId: string,
fallback: AnalysisTerrain = "las",
): AnalysisTerrain => {
const context = terrainContext.get(projectId);
if (context && !context.choosable) return context.design;
const saved = readState<string>("routeedit-terrain", projectId);
return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback);
};
/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */
export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void =>
writeState("routeedit-terrain", terrain, projectId);
/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 고르기를 띄운다)과 지금 설계 지형. */
/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 추천 창에 고르기)과 지금 설계 지형. */
export async function fetchTerrainSources(
projectId: string,
): Promise<{ sources: AnalysisTerrain[]; design: AnalysisTerrain }> {
@@ -216,14 +189,14 @@ export async function replanRoute(
*
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다.
* 설계 지형 격자를 받아 두었고 분석 지형이 그것과 같으면 **브라우저 표본**(서버와 같은 값 ·
* PLAN 344-1) — 아니면 서버에 물음. 자릿수는 서버 응답과 같게 mm. */
* 설계 지형 격자를 받아 두었으면 **브라우저 표본**(서버와 같은 값 · PLAN 344-1) — 아니면
* 서버에 물음. 자릿수는 서버 응답과 같게 mm. */
export async function fetchRouteElevations(
projectId: string,
points: Array<[number, number]>,
): Promise<Array<number | null>> {
const local = await loadDesignTerrainGrid(projectId);
if (local && analysisTerrain(projectId) === local.design) {
if (local) {
return points.map(([x, y]) => {
const z = heightAt(local.grid, x, y);
return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000;
@@ -231,7 +204,7 @@ export async function fetchRouteElevations(
}
const payload = await requestJson<{ z: Array<number | null> }>(
`/projects/${projectId}/route/elevations`,
{ method: "POST", body: JSON.stringify({ points, terrain: analysisTerrain(projectId) }) },
{ method: "POST", body: JSON.stringify({ points }) },
60000,
);
return payload.z;
+5 -3
View File
@@ -2,14 +2,14 @@
* B05_Profile_Api_RouteInitial.ts
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
*
* POST /projects/{id}/route/initial { mode, weights?, criteria? } → 노드 · 비교값 · 공통 제약 기준값
* POST /projects/{id}/route/initial { mode, weights?, criteria?, terrain? } → 노드 · 비교값 · 공통 제약 기준값
*
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
* 받은 비교값·기준값을 보이기만 한다. 모양은 계획서 20 「API」 줄(서버 `initial_route`) —
* 답을 펴는 자리는 `normalizeRouteInitial`(`_RouteEdit_Initial_Model`) 한 곳이다.
* ========================================================================== */
import { analysisTerrain, requestJson } from "./B05_Profile_Api_Replan";
import { requestJson, type AnalysisTerrain } from "./B05_Profile_Api_Replan";
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
@@ -44,6 +44,8 @@ export interface RouteInitialRequest {
with_profile?: boolean;
/** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */
recompute?: boolean;
/** 창에서 고른 지형(509-5) — 지형마다 미리 셈 저장본 · 안 보내면 설계 지형. */
terrain?: AnalysisTerrain;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
@@ -60,7 +62,7 @@ export async function fetchRouteInitial(
`/projects/${projectId}/route/initial`,
{
method: "POST",
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
body: JSON.stringify(request),
},
INITIAL_TIMEOUT_MS,
);
+7 -1
View File
@@ -17,7 +17,7 @@ import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_s
import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround";
export interface DesignTerrainGrid {
/** 이 격자의 지형 — 편집 창 분석 지형이 이것과 같을 때만 씀. */
/** 이 격자의 지형 — 계산 묶음 노선 초안 지형(null = 설계)이 이것과 같을 때만 씀. */
design: "las" | "sheet";
grid: DtmGrid;
}
@@ -65,6 +65,12 @@ export function loadDesignTerrainGrid(projectId: string): Promise<DesignTerrainG
return pending;
}
/** 설계 지형이 바뀜(추천 [적용] · 509-5) — 받아 둔 격자를 버림 · 다음 부름에 새 지형 격자를 받음. */
export function forgetDesignTerrainGrid(projectId: string): void {
loaded.delete(projectId);
ready.delete(projectId);
}
/** 지금 쓸 수 있는 격자 — 받는 중이면 null 을 돌려주고 받기를 띄움(기다리지 않음). */
export function designTerrainGridNow(projectId: string): DesignTerrainGrid | null {
if (!ready.has(projectId)) void loadDesignTerrainGrid(projectId);
@@ -127,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):
+1 -1
View File
@@ -637,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,
)
+47 -21
View File
@@ -6,6 +6,7 @@
import asyncio
import functools
import itertools
import json
import logging
import time
@@ -26,7 +27,7 @@ logger = logging.getLogger(__name__)
# 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜)
_RUNNING: dict[str, dict[str, Any]] = {}
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다."
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다."
@replan.router.post("/{project_id}/route/initial", response_model=None)
@@ -249,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).
@@ -260,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)
@@ -276,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, precompute=True)
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)
+21 -14
View File
@@ -13,7 +13,7 @@
GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag)
분석 지형(`terrain`, PLAN 133-1) — las = 라이다 · sheet = 수치지형도 · None = 지금 설계 지형.
모달 분석만 고른 지형을 쓴다 — 설계 지형(확정)은 [확인] 때만 바뀜(133-2 · `/route/replan`).
고르기는 추천 노선 창 안(509-5) — 설계 지형(확정)은 추천 [적용] 때만 바뀜(`/route/replan`).
"""
import asyncio
@@ -116,11 +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:
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김.
설계 지형 아닌 쪽은 DB 완료 모델이 있어야 둠 — [확인]이 그 모델로 바꾸므로(505-2 5d)."""
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
"""추천 창이 고를 수 있는 분석 지형(509-5 · 둘이면 고르기) · 설계 지형 · 설계 지형 격자 자리."""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
@@ -132,14 +128,7 @@ 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)
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)
sources = await choosable_terrains(connection, project_id, params, models_dir)
return {
"status": "success",
"project_id": str(project_id),
@@ -149,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:
+12 -37
View File
@@ -31,11 +31,8 @@ import {
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 ⑦). */
@@ -267,6 +263,7 @@ export async function mountRouteEditor(
planned: () => planned,
planMinRadiusM: () => minRadiusM,
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
terrains: () => terrainSources,
});
function resize(): void {
@@ -447,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();
@@ -587,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,
@@ -633,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,
});
@@ -670,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();
+14 -5
View File
@@ -6,7 +6,8 @@
* `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B).
* · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지
* 한 번에 · 다 저장한 뒤 `routeSaved` · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정).
* · 추천 [적용] = `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();
@@ -191,18 +192,26 @@ export async function saveRecommendedRoute(
nodes: RouteNodes,
initial: unknown,
balancedProfile: boolean,
terrain: string | null = null,
): Promise<boolean> {
const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null };
showLoadingOverlay();
try {
const saved = await saveRouteDraft(projectId, {
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장
draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }),
// 창에서 고른 지형 = 설계 지형(509-5 · 서버 `switch_design_terrain`)
draft: routeDraftOf(nodes, {
...settings,
stationSnap: true,
stationSnapStep: null,
terrain,
}),
initial,
balanced_profile: balancedProfile,
as_initial: true,
});
clearDrafts(projectId);
forgetDesignTerrainGrid(projectId); // 지형이 바뀌었으면 새 지형 격자로(509-2)
await reloadSavedPlusStations(projectId); // 페이지를 다시 세워도 남는 옛 저장분(493-5)
sources.get(projectId)?.saved();
const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0);
+6 -10
View File
@@ -58,7 +58,8 @@ export interface RouteEditChrome {
/** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */
stationSnapButton: HTMLButtonElement;
/** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */
terrainSelect: HTMLSelectElement;
/** 설계 지형 이름(509-5) — 고르기는 추천 창 안. */
terrainName: HTMLElement;
}
/** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */
@@ -119,13 +120,8 @@ export function createRouteEditChrome(
</select>
</label>
<i class="b05-route__view-separator" aria-hidden="true"></i>
<label class="b05-routeedit__terrain" hidden
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
<select class="ui-select ui-select--sm" data-act="terrain">
<option value="las">라이다</option>
<option value="sheet">수치지형도</option>
</select>
</label>
<span class="b05-routeedit__terrain" data-slot="terrain-name" hidden
title="설계 지형 — 바꾸기는 [노선 추천] 창에서 지형을 골라 [적용]"></span>
</div>
<div class="b05-routeedit__hint">
<div class="b05-routeedit__hint-body">
@@ -253,7 +249,7 @@ export function createRouteEditChrome(
const minorContourButton = overlay.querySelector<HTMLButtonElement>(
'[data-act="contour-minor"]',
)!;
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
const terrainName = overlay.querySelector<HTMLElement>('[data-slot="terrain-name"]')!;
const stationSnapButton = overlay.querySelector<HTMLButtonElement>('[data-act="station-snap"]')!;
bindFootprintToggle(overlay.querySelector<HTMLButtonElement>('[data-act="footprint"]')!);
bindMarkToggle(overlay.querySelector<HTMLButtonElement>('[data-act="stations"]')!, "stations");
@@ -269,6 +265,6 @@ export function createRouteEditChrome(
measureButton,
minorContourButton,
stationSnapButton,
terrainSelect,
terrainName,
};
}
@@ -17,7 +17,11 @@
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import {
fetchRoutePlan,
type AnalysisTerrain,
type RoutePlanResponse,
} from "./B05_Profile_Api_Replan";
import {
fetchPrecomputeProgress,
fetchRouteInitial,
@@ -57,6 +61,8 @@ export interface InitialPanelParams {
planMinRadiusM: () => number;
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
apply: (snapshot: RouteEditSnapshot, message: string) => void;
/** 추천 창이 고를 지형 · 지금 설계 지형(509-5) — 못 읽었으면 null(설계 지형 하나). */
terrains: () => { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null;
}
export interface InitialPanel {
@@ -126,6 +132,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
terrain: AnalysisTerrain,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
@@ -133,8 +140,9 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
...(criteria ? { criteria } : {}),
with_profile: withProfile,
...(recompute ? { recompute: true } : {}),
terrain,
});
return normalizeRouteInitial(response, logic, weights, criteria, withProfile);
return { ...normalizeRouteInitial(response, logic, weights, criteria, withProfile), terrain };
}
recommendButton.addEventListener("click", () => {
@@ -158,6 +166,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: params.planned().map(([x, y]) => [x, y]),
request,
terrains: params.terrains()?.sources ?? [],
terrain: params.terrains()?.design ?? "las",
progress: () => fetchPrecomputeProgress(projectId),
confirmText: () => applyResetText(projectId),
onApply: (picked) => {
@@ -179,6 +189,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
},
choiceWithProfile(picked),
picked.terrain ?? null,
);
},
});
@@ -215,6 +215,8 @@ export interface InitialChoice {
/** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */
endsGradePct?: number | null;
endsOverLimit?: boolean;
/** 추천 창에서 고른 지형(509-5) — [적용]이 설계 지형으로. 옛 캐시면 없음(설계 지형 그대로). */
terrain?: "las" | "sheet";
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
@@ -13,6 +13,8 @@
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
* 지형 고르기(509-5 · 라이다 · 수치지형도 둘일 때) — 바꾸면 그 지형 미리 셈 저장본 · 없으면 안내 + [재계산] ·
* [적용]하면 그 지형이 설계 지형(초기값도 그 지형으로).
* 적용될 초기 종단(476) — 서버가 [적용]과 같은 코드로 세운 종단을 같은 그림에 선 · 한 줄로 더한다
* (끔이어도) · [적용]은 「종단 · 횡단 손 입력이 모두 지워짐」 확인 뒤(요구 277).
* ========================================================================== */
@@ -25,6 +27,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
import type {
PrecomputeProgress,
RouteInitialCriteria,
@@ -125,6 +128,8 @@ const CRITERIA_FIELD: Record<
},
};
const TERRAIN_NAME: Record<AnalysisTerrain, string> = { las: "라이다", sheet: "수치지형도" };
export interface RecommendParams {
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
criteria: RouteInitialCriteria | null;
@@ -148,7 +153,11 @@ export interface RecommendParams {
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
terrain: AnalysisTerrain,
) => Promise<InitialChoice>;
/** 고를 수 있는 지형(509-5) — 둘이면 창에 고르기 · 처음 값 = 지금 설계 지형. */
terrains: AnalysisTerrain[];
terrain: AnalysisTerrain;
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
onApply: (choice: InitialChoice) => void;
/** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */
@@ -169,6 +178,9 @@ export function openRecommendModal(params: RecommendParams): void {
let preview: InitialChoice | null = null;
/** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */
let profileOn = params.withProfile;
/** 창 지형(509-5) — 바꾸면 그 지형 저장본 · [적용]이 설계 지형으로. */
let terrain = params.terrain;
const choosable = params.terrains.includes("las") && params.terrains.includes("sheet");
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
let dirty = false;
@@ -211,6 +223,12 @@ export function openRecommendModal(params: RecommendParams): void {
<div class="b05-routerec__bars" data-slot="bars"></div>
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<label class="b05-routerec__terrain" data-terrain-row hidden
title="추천 · 초기 종단을 셀 지형 — [적용]하면 이 지형이 설계 지형(평면 · 종단 · 횡단)">지형
<select class="ui-select ui-select--sm" data-act="terrain">
<option value="las">라이다</option>
<option value="sheet">수치지형도</option>
</select></label>
<div class="b05-routerec__criteria" data-slot="criteria"></div>
<label class="b05-routerec__target" data-target-row hidden
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
@@ -336,6 +354,9 @@ export function openRecommendModal(params: RecommendParams): void {
rerun.title = "지금 칸 값으로 서버 저장본을 지우고 새로 셈";
rerun.before(profileToggle);
const stateEl = q<HTMLElement>("[data-state]");
const terrainSelect = q<HTMLSelectElement>('[data-act="terrain"]');
q<HTMLElement>("[data-terrain-row]").hidden = !choosable;
terrainSelect.value = terrain;
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
bindRecommendHelp(body);
@@ -420,6 +441,7 @@ export function openRecommendModal(params: RecommendParams): void {
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
// 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421)
rerun.disabled = busy || invalid.size > 0;
terrainSelect.disabled = busy;
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(profileOn));
profileToggle.classList.toggle("is-active", profileOn);
@@ -498,7 +520,9 @@ export function openRecommendModal(params: RecommendParams): void {
preview = null;
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
const started = Date.now();
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}`;
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}${
choosable ? ` · ${TERRAIN_NAME[terrain]}` : ""
}`;
/** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */
let behind = "";
const tick = (): void => {
@@ -529,6 +553,7 @@ export function openRecommendModal(params: RecommendParams): void {
Object.keys(criteria).length ? criteria : null,
withProfile,
recompute,
terrain,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
@@ -617,6 +642,10 @@ export function openRecommendModal(params: RecommendParams): void {
});
});
rerun.addEventListener("click", () => void run(true));
terrainSelect.addEventListener("change", () => {
terrain = terrainSelect.value === "sheet" ? "sheet" : "las";
void run(); // 그 지형 저장본 — 없으면 안내 · [재계산](509-5)
});
applyButton.addEventListener("click", () => {
const picked = preview;
if (!picked) return;
@@ -372,6 +372,19 @@
font-weight: 600;
}
/* 지형 고르기(509-5) — 이름 · 고르기 한 줄. */
.b05-routerec__terrain {
display: grid;
grid-template-columns: minmax(0, 1fr) 7em;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-body);
}
.b05-routerec__terrain[hidden] {
display: none;
}
/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */
.b05-routerec__criteria {
display: grid;