Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-31 21:34:12 +09:00
51 changed files with 1205 additions and 2497 deletions
-1
View File
@@ -546,7 +546,6 @@ export async function mountProfileView(
contourIntervalM: latestResponse.surface_params.contour_interval_m,
smooth: latestResponse.surface_params.smooth,
tools: [view3d.modal.button],
recommend: panel.recommend,
// 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
draft: routeDraftBridge(activeProjectId, profilePanel),
});
+12 -3
View File
@@ -2,7 +2,7 @@
* map_toolbar.ts
* 공용 지도 도구줄 표(PLAN 624-2 · 요구 530 「상단 아이콘 셋 같게 · 필요 없는 것 숨김」) — 예정노선(D03) · B05 평면이 같이 씀.
* 한 줄 = 열쇠(`ui_template_map_tools` 단추 이름 · 아이콘 · 마우스 글은 거기) · 할 일 · 보일 페이지.
* 묶음 = 나눔선 · 차례 = 도구줄 차례 · 단추 아닌 칸(간격 고르기 · 지형 칩)은 이름을 여기 둠.
* 묶음 = 나눔선 · 차례 = 도구줄 차례 · 단추 아닌 칸(간격 고르기 · 추천 노선 보기 · 지형 칩)은 이름을 여기 둠.
* 누름 동작 · 페이지 문구는 페이지 몫 — 만든 요소를 `parts` 로 넘기면 그 자리에 · 안 넘긴 단추는 기본 단추(돌리기 둘 = 공용 돌림이 `data-act` 로 찾음).
* 거리 재기 = 단추 자리만 같고 동작은 페이지 몫(결정 A⑩) · 지형 칩 = `map_terrain_chip`.
* 쓰는 법 = `createMapTools({ label, hint, groups: mapToolGroups("d03", { undo: undoButton, terrain: chip.root }) })`.
@@ -11,7 +11,7 @@
import type { MapToolAct } from "@ui/ui_template_map_tools";
import type { MapPage } from "./map_layer_list";
export type MapToolKey = MapToolAct | "snap-step" | "terrain";
export type MapToolKey = MapToolAct | "snap-step" | "recommend" | "terrain";
export interface MapToolRow {
key: MapToolKey;
@@ -45,10 +45,19 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [
{ key: "station-snap", does: "곡선 가운데(MC)를 간격 배수에 맞춤", pages: B05 },
{ key: "snap-step", name: "측점 스냅 간격", does: "스냅 간격 고르기", pages: B05 },
],
[
{
key: "recommend",
name: "추천 노선 보기",
does: "노선 추천 저장본 선 하나만 보기 · 끔(626-1 · 고르기 = 페이지 몫)",
pages: B05,
},
],
[{ key: "terrain", name: "지형 칩", does: "지형 원천 보기 · 고르기", pages: BOTH }],
];
const isAct = (key: MapToolKey): key is MapToolAct => key !== "snap-step" && key !== "terrain";
const isAct = (key: MapToolKey): key is MapToolAct =>
key !== "snap-step" && key !== "recommend" && key !== "terrain";
/** 그 페이지 도구줄 묶음 — 넘긴 요소 · 기본 단추(단추 열쇠만) · 빈 묶음 뺌. */
export function mapToolGroups(
@@ -245,3 +245,7 @@ async def read_surface_grid(
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
grid_path = await asyncio.to_thread(dtm_grid_file, npz_path)
return cached_file_response(request, grid_path, "application/octet-stream")
# `POST /route/design-terrain`(626-1 지형 칩) = 나눈 파일에서 같은 `router` 에 붙음 — 끝에서 등록.
from B05_Profile import B05_Profile_Router_TerrainSwitch # noqa: E402,F401
@@ -0,0 +1,78 @@
"""B05 설계 지형 바꿈(PLAN 626-1 · 요구 530 · 결정 A④ · D2) — 평면 도구줄 지형 칩.
`POST /api/projects/{id}/route/design-terrain` 본문 `{terrain: "las" | "sheet"}` →
설계 지형만 바꾸고(`switch_design_terrain`) 노선 저장 체인을 다시 탐
(종단 · 횡단 · 배수 · 관 · 구조물 따라감 ·
계획고 옮김 없음 = 옛 · 새 계획 폴리라인 같음). 노선 · 노드 · 곡선표 = 지금 저장본 그대로(안 씀).
초기값(`initial_snapshot/`) · 추천 저장본 · 예정노선 안 씀(옛 추천 [적용] 길 `as_initial` 대신).
[저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(`common_util_save_guard`) · 같은 지형 = 바꿀 것 없음.
"""
import asyncio
import logging
from typing import Literal
from uuid import UUID
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B05_Profile.B05_Profile_Router_Terrain import router
from common_util.common_util_surface_confirmation import (
design_terrain,
get_surface_confirmation_params,
)
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
class DesignTerrainRequest(BaseModel):
terrain: Literal["las", "sheet"]
@router.post("/{project_id}/route/design-terrain", response_model=None)
async def switch_route_design_terrain(
project_id: UUID, body: DesignTerrainRequest
) -> dict | JSONResponse:
"""설계 지형을 바꾸고 뒤 단계를 모두 다시 셈 — 노선 값 · 초기값은 그대로."""
from B05_Profile.B05_Profile_Router_Replan import (
_PROJECT_PATH_MISSING,
_planned_line,
_project_paths,
_recompute,
)
from B06_Section.B06_Section_Router_DesignSave import _restore
from common_util import common_util_save_guard as guard
lock = guard.project_lock(str(project_id))
async with lock:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
pool = get_db_pool()
async with pool.acquire() as connection:
now = design_terrain(await get_surface_confirmation_params(connection, str(project_id)))
if now == body.terrain:
return {"status": "success", "design": now, "changed": False}
rows = await guard.snapshot_rows(connection, str(project_id), surface=True)
files = await asyncio.to_thread(
guard.snapshot_storage, project_root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE
)
try:
line = await asyncio.to_thread(_planned_line, project_root)
result = await _recompute(
project_id, project_root, stored_path, None, body.terrain, (line, line), False
)
if "error" in result:
await _restore(project_id, rows, files)
return JSONResponse(
status_code=409, content={"status": "error", "message": result["error"]}
)
except Exception:
await _restore(project_id, rows, files)
raise
finally:
files.discard()
logger.info("설계 지형 바꿈: project_id=%s %s → %s", project_id, now, body.terrain)
return {"status": "success", "design": body.terrain, "changed": True, **result}
+1 -5
View File
@@ -23,7 +23,6 @@ import {
parseOptional,
type RoutePanelValues,
} from "./B05_Profile_UI_Panel_Fields";
import { createRecommendPart } from "./B05_Profile_UI_Panel_Recommend";
import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings";
import { createViewControls } from "./B05_Profile_UI_Panel_View";
import { pageSettingsOf, writePageSettings } from "./B05_Profile_Api_PageSettings";
@@ -82,7 +81,6 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
endOffsets: () => shared.profile?.endOffsets() ?? null,
onEndOffsetChange: (end, value) => void shared.profile?.setEndOffset(end, value),
});
const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4)
const {
contourInterval,
gradeClass,
@@ -162,13 +160,11 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
// 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 ·
// [확정]은 한 벌이 두 보기 몫을 모아 낸다.
Object.assign(shared.parts, { settings, recommend });
Object.assign(shared.parts, { settings });
sharedPanel(shared);
return {
viewControls,
/** 「노선 추천」 칸 요소 — 노선 편집기(`_RouteEdit_Initial`)에 넘긴다. */
recommend,
/** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */
structures: structures as StructuresSection,
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */
@@ -1,25 +0,0 @@
/* =============================================================================
* B05_Profile_UI_Panel_Recommend.ts
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」.
* 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창)은 편집기의 `_RouteEdit_Initial` 이 이 요소를
* 받아 건다 — 여기는 생김새만. 칸 [초기화]는 지움(418) — 되돌림은 좌측 아래 [초기화] 하나.
* ========================================================================== */
export interface RecommendPart {
recommendButton: HTMLButtonElement;
nowLine: HTMLElement;
/** 칸 몸 — 화면 순서대로. */
nodes: HTMLElement[];
}
export function createRecommendPart(): RecommendPart {
const nowLine = document.createElement("span");
nowLine.className = "b05-routeedit__rec-now";
const actions = document.createElement("div");
actions.className = "b05-route__rec-actions";
actions.innerHTML = `
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다 — [적용]은 바로 저장 · 새 초기값">노선 추천</button>`;
const recommendButton = actions.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
return { recommendButton, nowLine, nodes: [nowLine, actions] };
}
+4 -17
View File
@@ -1,18 +1,17 @@
/* =============================================================================
* B05_Profile_UI_Panel_Sections.ts
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 노선 추천.
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정.
* 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
*
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 노선 추천 > 아래 고정 영역.
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역.
* 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) ·
* 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문) · 그 자리에
* 「노선 추천」(345-4 · 편집 머리에서 옮김 · 재료 `_Panel_Recommend`).
* 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문) ·
* 「노선 추천」 칸은 걷음(626-1 — 추천 = 평면 도구줄 미리보기 · 지형 바꿈 = 지형 칩).
* 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` 가 만들어 `ctx.parts` 로 건넨다.
* ========================================================================== */
import "../A00_Common/design/design_sections";
import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel";
import type { RecommendPart } from "./B05_Profile_UI_Panel_Recommend";
import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings";
registerPanelSection({
@@ -26,15 +25,3 @@ registerPanelSection({
return frame.root;
},
});
registerPanelSection({
id: "route-recommend",
title: "노선 추천",
views: ["profile"],
order: 30,
build: (ctx, view) => {
const frame = createSectionFrame(view, "노선 추천");
frame.body.append(...part<RecommendPart>(ctx, "recommend").nodes);
return frame.root;
},
});
+11 -21
View File
@@ -64,7 +64,7 @@ import {
routeLocksOf,
writeRouteLocks,
} from "./B05_Profile_UI_RouteEdit_Lock";
import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial";
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
bindHistoryControls,
@@ -98,8 +98,6 @@ export interface RouteEditOptions {
smooth?: boolean;
/** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */
tools?: HTMLElement[];
/** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */
recommend: InitialPanelParams["controls"];
draft?: draftLink.RouteDraftBridge; // 계산 묶음 다리(346-1 · `_Draft`)
}
@@ -115,9 +113,10 @@ export async function mountRouteEditor(
options: RouteEditOptions,
): Promise<void> {
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome(parent, options.tools, host);
const chrome = createRouteEditChrome(parent, projectId, options.tools, host);
const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome;
const { minorContourButton, terrainName, stationSnapButton } = chrome;
const { minorContourButton, stationSnapButton } = chrome;
chrome.onRedraw(() => draw()); // 겹 켬끔 · 추천 보기 = 장면 새로(624-3)
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
@@ -162,10 +161,8 @@ export async function mountRouteEditor(
let meta: VWorldMeta | null = null;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
let contours: RouteEditContours | null = null;
/** 지금 설계 지형 — 편집 분석이 쓰는 지형 · 바꾸기는 추천 창 [적용](509-5). */
/** 지금 설계 지형 — 편집 분석이 쓰는 지형 · 바꾸기는 도구줄 지형 칩(626-1 · `_Terrain`). */
let designTerrain: AnalysisTerrain = "las";
/** 추천 창이 고를 지형 — `/route/terrain-sources` 를 읽은 뒤. */
let terrainSources: { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null = null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
@@ -263,15 +260,8 @@ export async function mountRouteEditor(
curveBar.destroy();
overlay.remove();
};
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
const initial = createInitialPanel({
controls: options.recommend,
projectId,
planned: () => planned,
planMinRadiusM: () => minRadiusM,
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
terrains: () => terrainSources,
});
/** 지금 추천 — [저장]에 실을 값 · 걸음 짝(계획서 20 · 추천 고르기는 걷음 626-1). */
const initial = createInitialPanel({ projectId });
function resize(): void {
if (closed || !overlay.isConnected) return;
@@ -322,12 +312,14 @@ export async function mountRouteEditor(
pxPerMeter: pxPerMeter(),
hasMeta: meta !== null,
contours,
contourHidden: !chrome.layers.own().contour, // 624-3 겹 줄 등고선 끔
otherSheets,
pickedContour,
contourStepM: contourStepM(),
rotationRad: rotation.radians(),
uprightRad: rotation.uprightRad(),
minorContours: showMinorContours() ? (contours?.minor?.() ?? null) : null,
underRoute: (ctx) => chrome.preview.draw(ctx, toScreen), // 추천 노선 보기(626-1)
overStations: (ctx) =>
slope.draw(ctx, {
line: plannedLine.length ? plannedLine : planned,
@@ -636,11 +628,9 @@ export async function mountRouteEditor(
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
// 설계 지형 이름만(509-5) — 고르기는 추천 창 안 · 둘 다 있을 때만 이름을 보임.
// 설계 지형 = 도구줄 지형 칩(626-1) — 없는 지형은 막힘 · 바꾸면 확인창 · 뒤 단계 다시 셈.
designTerrain = sources?.design ?? "las";
terrainSources = sources;
terrainName.hidden = !(sources?.sources.includes("las") && sources.sources.includes("sheet"));
terrainName.textContent = `지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"}`;
chrome.terrain.load(sources);
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
let sheets: SheetLayers = drainage;
+5 -55
View File
@@ -6,18 +6,18 @@
* `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B).
* · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지
* 한 번에 · 다 저장한 뒤 `routeSaved` · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정) ·
* 창에서 고른 지형이 설계 지형(509-5 · 손 [저장]은 지형 안 바꿈).
* · 옛 추천 [적용](`as_initial` · 지형 바꿈)의 화면 부름은 걷음(626-1 · 서버 길 · 시험은 남김) —
* 지형 바꿈 = 도구줄 지형 칩(`_Terrain` · `/route/design-terrain`).
* 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나).
* 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽).
* ========================================================================== */
import { clearDrafts, clearResults, clearState, readState } from "../A00_Common/b_page_state";
import { clearResults, clearState, readState } from "../A00_Common/b_page_state";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import { useDesignStore } from "../A00_Common/design/design_store";
import { commitRouteFacilities } from "../A00_Common/design/design_pipeline_page";
import { currentRoute, remountRoute } from "../A00_Common/router";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { showToast } from "@ui/ui_template_elements";
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { routeCarryInputs } from "../B06_Section/B06_Section_Station_Keys";
import {
@@ -31,15 +31,12 @@ 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,
type RouteReplanResponse,
type RouteSaveBody,
} from "./B05_Profile_Api_Replan";
import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
import { routeResetNotice } from "./logic/B05_Profile_Route_Reset";
type Vertex = [number, number];
type RouteSettings = SnapSetting & { terrain: string | null };
@@ -104,7 +101,7 @@ export function bindRouteApply(params: RouteApplyParams): () => void {
return {
draft: routeDraftOf(nodes, settings()),
initial: params.initial.current(),
// 손으로 고친 노선 = 계획고 옮김만(344-2 · 349-0v) — 균형 종단은 추천 [적용](`saveRecommendedRoute`)만.
// 손으로 고친 노선 = 계획고 옮김만(344-2 · 349-0v) — 균형 종단은 서버 추천 [적용](`as_initial`) 길만.
};
},
settings,
@@ -181,50 +178,3 @@ export async function rebuildAfterRouteSave(projectId: string): Promise<void> {
invalidateSectionDetail(projectId);
remountRoute(currentRoute());
}
/**
* 추천 [적용] — 받은 노드로 서버 노선을 바로 저장하고(`as_initial` · 초기값 = 추천 노선) 페이지를 다시
* 세운다. 잠금 막 = 「노선 재탐색」 자리 하나. 새 초기값에서 다시 시작하므로 초안은 비운다([초기화]와 같음).
* 실패하면 false — 서버가 옛 노선으로 되살렸고 화면 노선은 그대로.
*/
export async function saveRecommendedRoute(
projectId: string,
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)로 저장
// 창에서 고른 지형 = 설계 지형(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);
showToast(`추천 노선을 저장했습니다 — 새 초기값입니다.${reset ? ` ${reset}` : ""}`, "success");
await rebuildAfterRouteSave(projectId);
return true;
} catch (error) {
showToast(
`추천 노선 저장에 실패했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
"error",
);
return false;
} finally {
hideLoadingOverlay();
}
}
+78 -52
View File
@@ -1,9 +1,13 @@
import { createIconButton } from "@ui/ui_template_elements";
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
import { mapToolGroups } from "../A00_Common/map/map_toolbar";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks";
import { createRouteEditGl } from "./B05_Profile_UI_RouteEdit_Gl";
import { createRouteEditGl, type RouteEditGl } from "./B05_Profile_UI_RouteEdit_Gl";
import { createRouteEditLayers, type RouteEditLayers } from "./B05_Profile_UI_RouteEdit_Layers";
import { createRecPreview, type RecPreview } from "./B05_Profile_UI_RouteEdit_RecPreview";
import { createRouteTerrain, type RouteTerrain } from "./B05_Profile_UI_RouteEdit_Terrain";
import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift";
/* =============================================================================
@@ -16,14 +20,15 @@ import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoL
* **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
* 도구줄 차례(366-2 · 381-2) — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 측점 · 기울기 · 절/성토선 · 거리 재기 |
* 측점 스냅 · 간격 | 분석 지형. 단추는 공용 지도 도구줄(568-2 · `ui_template_map_tools`) 아이콘 — 긴 설명은 마우스 글.
* 도구줄 차례 = 공용 표(624-3 · `A00_Common/map/map_toolbar` 「b05」) — [?] | ↶ ↷ | 3D ⟲ ⟳ | 1m 등고선 · 측점 · 기울기 ·
* 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` ·
* 끝 = 그래픽 카드 단추) · 범례 = 상태 글판 위. 단추는 공용 지도 도구줄(568-2 · `ui_template_map_tools`) 아이콘 — 긴 설명은 마우스 글.
*
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
* 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지
* [저장]이 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
* 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측
* 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`).
* 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. 옛 [노선 추천] 모달 · 좌측 「노선 추천」 칸은 걷음(626-1 ·
* 추천 = 도구줄 미리보기 · 지형 바꿈 = 지형 칩).
* ========================================================================== */
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
@@ -59,9 +64,14 @@ export interface RouteEditChrome {
minorContourButton: HTMLButtonElement;
/** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */
stationSnapButton: HTMLButtonElement;
/** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */
/** 설계 지형 이름(509-5) — 고르기는 추천 창 안. */
terrainName: HTMLElement;
/** 설계 지형 칩(626-1) — 지형 읽은 뒤 `load`. */
terrain: RouteTerrain;
/** 추천 노선 미리보기(626-1) — 그리기 안에서 `draw`. */
preview: RecPreview;
/** 공용 지도 겹(624-3). */
layers: RouteEditLayers;
/** 장면 새로 그리기(겹 켬끔 · 추천 보기 뒤) — 편집기가 건다(없으면 GL 마지막 장면). */
onRedraw: (draw: () => void) => void;
}
/** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */
@@ -85,6 +95,7 @@ let infoCollapsed = false;
/** 뼈대를 만들어 `parent` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
export function createRouteEditChrome(
parent: HTMLElement,
projectId: string,
tools: HTMLElement[] = [],
host?: { onDispose: (fn: () => void) => void },
): RouteEditChrome {
@@ -114,7 +125,7 @@ export function createRouteEditChrome(
<div class="b05-routeedit__busy" hidden><span></span></div>
<aside class="b05-routeedit__side-wrap"></aside>`;
parent.append(overlay);
// 스냅 간격 고르기 · 설계 지형 이름 칩 — 단추 아님(도구줄에 요소로 끼움).
// 스냅 간격 고르기 — 단추 아님(도구줄에 요소로 끼움).
const extras = document.createElement("div");
extras.innerHTML = `
<label class="b05-routeedit__terrain b05-routeedit__snap-step"
@@ -123,52 +134,62 @@ export function createRouteEditChrome(
<option value="10">10 m</option>
<option value="station">측점 No.</option>
</select>
</label>
<span class="b05-routeedit__terrain" data-slot="terrain-name" hidden
title="설계 지형 — 바꾸기는 [노선 추천] 창에서 지형을 골라 [적용]"></span>`;
const [snapStep, terrainName] = extras.children as unknown as [HTMLElement, HTMLElement];
</label>`;
const snapStep = extras.firstElementChild as HTMLElement;
const terrain = createRouteTerrain(projectId);
let gl: RouteEditGl | null = null;
let redrawScene: (() => void) | null = null;
const redraw = (): void => (redrawScene ? redrawScene() : gl?.redraw());
const preview = createRecPreview({
projectId,
terrain: terrain.design,
redraw,
});
const mapTools = createMapTools({
label: "평면 도구",
hint: { lines: HINT_LINES, open: hintOpen, onToggle: (open) => (hintOpen = open) },
groups: [
[
mapToolButton("undo", { title: "되돌리기 (Ctrl+Z)", disabled: true }),
mapToolButton("redo", { title: "다시하기 (Ctrl+Y)", disabled: true }),
],
tools, // 부른 쪽 단추([3D])
[
"rotate-ccw",
"rotate-cw",
mapToolButton("contour-minor", {
title: "1m 등고선 — 1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)",
disabled: true,
}),
mapToolButton("stations", {
title: "측점 — 측점 눈금 · 번호를 평면에 보이기 · 숨기기 (시점 · 종점 이름표는 늘 보임)",
}),
mapToolButton("slopes", {
title: "기울기 — 측점 사이 기울기(%)를 평면에 보이기 · 숨기기 (확대해야 보임)",
}),
mapToolButton("footprint", {
title: "절/성토선 — 도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 청록)을 평면에 겹칩니다",
}),
mapToolButton("measure", {
title: "거리 재기 — 노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)",
}),
],
[
mapToolButton("station-snap", {
title:
"측점 스냅 — 켜면 고친 곡선의 가운데(MC)를 고른 간격 배수에 맞춥니다 — 일반측점(20 m)은 그대로 · MC 가 일반측점 위가 아니면 「No.N+10 (MC)」 특별측점을 더함 · 손대지 않은 곡선은 그 자리 유지 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장",
}),
snapStep,
],
[terrainName],
],
groups: mapToolGroups("b05", {
undo: mapToolButton("undo", { title: "되돌리기 (Ctrl+Z)", disabled: true }),
redo: mapToolButton("redo", { title: "다시하기 (Ctrl+Y)", disabled: true }),
...(tools[0] ? { view3d: tools[0] } : {}), // 부른 쪽 단추([3D])
"contour-minor": mapToolButton("contour-minor", {
title: "1m 등고선 — 1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)",
disabled: true,
}),
stations: mapToolButton("stations", {
title: "측점 — 측점 눈금 · 번호를 평면에 보이기 · 숨기기 (시점 · 종점 이름표는 늘 보임)",
}),
slopes: mapToolButton("slopes", {
title: "기울기 — 측점 사이 기울기(%)를 평면에 보이기 · 숨기기 (확대해야 보임)",
}),
footprint: mapToolButton("footprint", {
title: "절/성토선 — 도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 청록)을 평면에 겹칩니다",
}),
measure: mapToolButton("measure", {
title: "거리 재기 — 노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)",
}),
"station-snap": mapToolButton("station-snap", {
title:
"측점 스냅 — 켜면 고친 곡선의 가운데(MC)를 고른 간격 배수에 맞춥니다 — 일반측점(20 m)은 그대로 · MC 가 일반측점 위가 아니면 「No.N+10 (MC)」 특별측점을 더함 · 손대지 않은 곡선은 그 자리 유지 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장",
}),
"snap-step": snapStep,
recommend: preview.root,
terrain: terrain.root,
}),
});
mapTools.root.classList.add("b05-routeedit__tools");
mapTools.hint!.classList.add("b05-routeedit__hint"); // 가이드 촬영 · 엿보기 숨김이 잡음
overlay.querySelector('[data-slot="map-tools"]')!.replaceWith(mapTools.root);
// 공용 지도 겹(624-3) — 도구줄 아래 줄 · 범례는 상태 글판 맨 위
const wrap = overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!;
const layers = createRouteEditLayers({
projectId,
wrap,
onGl: () => gl?.toggle(),
redraw,
});
const toolRows = document.createElement("div");
toolRows.className = "b05-routeedit__tools";
toolRows.append(mapTools.root, layers.bar);
overlay.querySelector('[data-slot="map-tools"]')!.replaceWith(toolRows);
// 횡단 미리보기 = 패널 양식(382-1 · 손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 자리는 지도 위 덮개 그대로 ·
// 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 열림 · 닫힘은 손잡이만(513-2) · 잰 값 창이 판을 비킴(--b05-side-open).
@@ -193,6 +214,7 @@ export function createRouteEditChrome(
// 상태 · 범례 접기 — 접으면 한 줄 「계획노선 N m」(상태 글에서 읽음 · 345-5 덧 3).
const info = overlay.querySelector<HTMLElement>(".b05-routeedit__info")!;
info.prepend(layers.legend);
const infoToggle = info.querySelector<HTMLButtonElement>('[data-act="info-toggle"]')!;
const summary = info.querySelector<HTMLElement>(".b05-routeedit__summary")!;
const setInfo = (collapsed: boolean): void => {
@@ -249,7 +271,8 @@ export function createRouteEditChrome(
const measureButton = mapTools.button("measure")!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
const minorContourButton = mapTools.button("contour-minor")!;
createRouteEditGl(canvas, minorContourButton, host); // GL 바탕(589-2 · 그리기가 `routeGlOf` 로 찾음)
gl = createRouteEditGl(canvas, layers, host); // GL 바탕(589-2 · 그리기가 `routeGlOf` 로 찾음)
Object.assign(canvas, { mapProbe: { layers: layers.state, preview: preview.state } }); // 검증 창구
const stationSnapButton = mapTools.button("station-snap")!;
bindFootprintToggle(mapTools.button("footprint")!);
bindMarkToggle(mapTools.button("stations")!, "stations");
@@ -265,6 +288,9 @@ export function createRouteEditChrome(
measureButton,
minorContourButton,
stationSnapButton,
terrainName,
terrain,
preview,
layers,
onRedraw: (draw) => (redrawScene = draw),
};
}
+29 -24
View File
@@ -5,13 +5,14 @@
* 캔버스와 같은 자리 · 같은 솎기(간격 배수 · 1 m 선 켬 조건) · 노선 둘레 띠 자르기(지도 칸 clip-path) ·
* 하천 = LAS 등고선이 있는 데까지(자료를 상자로 자름 · 0-9 ⑮).
* 고른 등고선 · 등고 글 · 노선 · 꼭짓점 · 곡선 · 측점 · 재기 · 끌기 = 위 캔버스 그대로 · 집기 판정 그대로.
* 공용 지도 겹(624-3) = `_Layers` — 등고선 · 하천 켬끔은 그 겹 줄 · 그 밖 겹은 띠로 안 자르는 둘째 칸(같이 따라감) ·
* 켬끔 단추 = 겹 줄 끝 그래픽 카드 단추(`toggle`).
* ========================================================================== */
import type { FilterSpecification } from "maplibre-gl";
import { createIconButton } from "@ui/ui_template_elements";
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { createTmGl, GL_TOGGLE_TITLE, lineSpecs } from "../B04_PreProcess/B04_PreProcess_UI_MapGl";
import { createTmGl, lineSpecs } from "../B04_PreProcess/B04_PreProcess_UI_MapGl";
import {
createTmNormalizer,
normalizedToScreen,
@@ -23,6 +24,7 @@ import {
preparedToGeoJson,
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import type { RouteEditLayers } from "./B05_Profile_UI_RouteEdit_Layers";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
@@ -38,6 +40,10 @@ const stepFilter = (step: number): FilterSpecification =>
export interface RouteEditGl {
/** 그리기마다 — 켜져 있으면 화면을 따라가고 참(캔버스는 바탕을 쉼) · `again` = 장면 다시 그리기(켬끔 뒤). */
follow: (scene: RouteEditScene, bandM: number, again: () => void) => boolean;
/** GL ↔ 옛 캔버스(겹 줄 그래픽 카드 단추). */
toggle: () => void;
/** 마지막 장면 다시 그리기(겹 켬끔 뒤). */
redraw: () => void;
dispose: () => void;
}
@@ -46,10 +52,10 @@ const BY_CANVAS = new WeakMap<HTMLCanvasElement, RouteEditGl>();
export const routeGlOf = (canvas: HTMLCanvasElement): RouteEditGl | undefined =>
BY_CANVAS.get(canvas);
/** `canvas` = 편집 캔버스(칸 = 그 부모) · `after` = 켬끔 단추를 그 뒤에(도구줄) · `host` = 떠날 때 버림. */
/** `canvas` = 편집 캔버스(칸 = 그 부모) · `layers` = 공용 지도 겹(624-3) · `host` = 떠날 때 버림. */
export function createRouteEditGl(
canvas: HTMLCanvasElement,
after: HTMLElement,
layers: RouteEditLayers,
host?: { onDispose: (fn: () => void) => void },
): RouteEditGl {
const wrap = canvas.parentElement!;
@@ -60,14 +66,7 @@ export function createRouteEditGl(
let normalizer: Normalizer | null = null;
let toLonLat: ((nx: number, ny: number) => [number, number]) | null = null;
let minorShown: PreparedLayer | null = null;
const button = createIconButton({
icon: "gauge",
title: GL_TOGGLE_TITLE,
size: 32,
onClick: () => tm.toggle(),
});
button.dataset.act = "gl";
after.after(button);
let synced: [boolean, VWorldMeta | null] = [false, null];
const tm = createTmGl<Key>({
parent: wrap,
specs: () => [
@@ -90,7 +89,9 @@ export function createRouteEditGl(
},
});
const mark = (on: boolean): void => {
button.setAttribute("aria-pressed", String(on));
synced = [on, meta];
layers.sync(on, meta);
if (!on) layers.park();
// 바탕칠 = 칸(지도 칸은 띠로 잘림 · 캔버스는 투명)
wrap.style.background = on ? "var(--draw-surface)" : "";
};
@@ -132,12 +133,17 @@ export function createRouteEditGl(
redraw = again;
const container = tm.gl.container();
const at = scene.contours?.meta;
if (at && (synced[0] !== tm.on() || synced[1] !== at)) {
synced = [tm.on(), at]; // 겹 줄 · 범례 = 켬끔 · 범위가 바뀔 때만
layers.sync(...synced);
}
if (!tm.on() || !at || !container) return false;
if (shown[0] !== scene.contours || shown[1] !== scene.otherSheets) {
shown = [scene.contours, scene.otherSheets];
setBackground(at, scene.contours, scene.otherSheets);
}
if (!meta || !normalizer) return false;
const own = layers.own();
const { view } = scene;
const { gl } = tm;
const minor =
@@ -146,9 +152,9 @@ export function createRouteEditGl(
minorShown = scene.minorContours;
gl.setData("minor", preparedToGeoJson(scene.minorContours!, toLonLat));
}
gl.setVisible("sheets", true);
gl.setVisible("contour", true);
gl.setVisible("minor", minor);
gl.setVisible("sheets", own.stream);
gl.setVisible("contour", own.contour);
gl.setVisible("minor", minor && own.contour);
if (scene.contourStepM !== step) {
step = scene.contourStepM;
if (gl.map()?.getLayer("contour-line"))
@@ -167,13 +173,10 @@ export function createRouteEditGl(
cx + (x - cx) * cos - (y - cy) * sin,
cy + (x - cx) * sin + (y - cy) * cos,
];
tm.follow(
meta,
normalizer,
(nx, ny) => turn(...normalizedToScreen(view, nx, ny)),
view.width,
view.height,
);
const toScreen = (nx: number, ny: number): [number, number] =>
turn(...normalizedToScreen(view, nx, ny));
tm.follow(meta, normalizer, toScreen, view.width, view.height);
layers.follow(meta, normalizer, toScreen, view.width, view.height);
// 노선 둘레 띠만(캔버스 자르기와 같은 네모 · 돌림 · 칸 안 px)
const line = (scene.plannedLine.length ? scene.plannedLine : scene.planned) as Vertex[];
const band = scene.hasMeta ? contourBandRect(line, scene.toScreen, bandM) : null;
@@ -197,7 +200,9 @@ export function createRouteEditGl(
: "";
return true;
},
dispose: tm.dispose,
toggle: () => tm.toggle(),
redraw: () => redraw?.(),
dispose: () => (layers.park(), tm.dispose()),
};
BY_CANVAS.set(canvas, api);
host?.onDispose(api.dispose);
+10 -152
View File
@@ -1,68 +1,31 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 →
* 모달(`_Recommend`) · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
* 밖(`_Panel_Recommend`)에서 받는다.
* 계획노선 편집의 **지금 추천 이음**(계획서 20 · 23-2 · 626-1) — [저장]에 실을 지금 추천 · 편집 걸음 짝.
*
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. 칸 [초기화]는 없음(418) —
* 되돌림은 좌측 아래 [초기화](`design_reset` · 마지막 [적용] 노선). 손으로 고친 노선의 다시 계산은
* 페이지 [저장]에서(`_Apply` · 346-3) —
* 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 ·
* 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
*
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 ·
* `initial_choice.json` 에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder 는 그 칸을 처음 비운 채
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* 추천 고르기(옛 좌측 「노선 추천」 칸 · 모달 · [적용] = `as_initial`)는 걷음(626-1 · 요구 530 · 결정 D1 · D8) —
* 추천은 평면 도구줄 미리보기(`_RecPreview`)만 · 초기값 = 예정노선 추적(자동설계 체인 · [초기화] 그대로).
* 지금 추천 = 서버가 적어 둔 것(`current_initial` · 없으면 예정노선 추적) · 옛 캐시(`routeedit-initial`)는
* 서버와 같을 때만 남음 · [저장]은 그 값 그대로 실음(서버 `initial_choice.json`).
* ========================================================================== */
import {
fetchRoutePlan,
type AnalysisTerrain,
type RoutePlanResponse,
} from "./B05_Profile_Api_Replan";
import {
fetchPrecomputeProgress,
fetchRouteInitial,
type RouteInitialCriteria,
type RouteInitialLogic,
type RouteInitialWeights,
import type {
RouteInitialCriteria,
RouteInitialLogic,
RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
choiceWithProfile,
DEFAULT_WEIGHTS,
NO_WEIGHT_LOCKS,
normalizeRouteInitial,
readInitialChoice,
sameBase,
serverBase,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
type InitialChoice,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend";
import { saveRecommendedRoute } from "./B05_Profile_UI_RouteEdit_Apply";
import { applyResetText } from "./B05_Profile_UI_RouteReset";
export interface InitialPanelParams {
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
controls: {
recommendButton: HTMLButtonElement;
nowLine: HTMLElement;
};
projectId: string;
/** 지금 편집기 노드 — 추천 창이 「지금 노선」으로 같이 그린다. */
planned: () => Array<[number, number]>;
/** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */
planMinRadiusM: () => number;
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
apply: (snapshot: RouteEditSnapshot, message: string) => void;
/** 추천 창이 고를 지형 · 지금 설계 지형(509-5) — 못 읽었으면 null(설계 지형 하나). */
terrains: () => { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null;
}
export interface InitialPanel {
@@ -87,120 +50,17 @@ export interface InitialPanel {
keep: () => void;
}
const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot;
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const { projectId } = params;
const { recommendButton, nowLine } = params.controls;
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
let openingChoice = readInitialChoice(projectId);
let choice: InitialChoice | null = openingChoice;
/** 노드 없이 이름만 아는 현재 추천 — 서버가 적어 둔 것(없으면 예상노선 추적). */
let base: InitialBase = { logic: "follow", weights: null, criteria: null };
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
const defaults: Partial<Record<CriteriaKey, number>> = {};
let opening: RouteEditSnapshot | null = null;
let busy = false;
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
const weightMemory = {
weights: null as RouteInitialWeights | null,
locks: { ...NO_WEIGHT_LOCKS },
};
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕). 창이 사는 동안 안 바뀐다. */
let planRead: Promise<RoutePlanResponse> | null = null;
function loadPlan(): Promise<RoutePlanResponse> {
planRead ??= fetchRoutePlan(projectId).catch((error) => {
planRead = null; // 다음에 다시 읽는다.
throw error;
});
return planRead;
}
function render(): void {
const now = choice ?? base;
nowLine.textContent = `현재 추천: ${choiceLabel(now.logic, now.weights)}`;
recommendButton.disabled = busy || !opening;
}
/** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */
async function request(
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
terrain: AnalysisTerrain,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(weights ? { weights } : {}),
...(criteria ? { criteria } : {}),
with_profile: withProfile,
...(recompute ? { recompute: true } : {}),
terrain,
});
return { ...normalizeRouteInitial(response, logic, weights, criteria, withProfile), terrain };
}
recommendButton.addEventListener("click", () => {
void (async () => {
if (busy || !opening) return;
busy = true;
render();
const plan = await loadPlan().catch(() => null); // 지도 바탕만 못 깔 뿐 추천은 된다.
busy = false;
if (!recommendButton.isConnected) return; // 그 사이 창이 닫혔다.
render();
openRecommendModal({
criteria: choice?.criteriaOverrides ?? base.criteria,
defaults,
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
logic: (choice ?? base).logic,
// 적용한 추천의 값 · 없으면 켬(421 — 옛 기록의 예상노선 추적도 켬에서 시작)
withProfile: choice?.withProfile ?? base.withProfile ?? true,
planMinRadiusM: params.planMinRadiusM(),
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) => {
choice = picked;
writeInitialChoice(projectId, choice);
render();
params.apply(
copy(picked.snapshot),
`노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 서버에 저장합니다.`,
);
// 초기값 = 추천 노선(346-3) — 바로 저장 · 실패하면 평면에 실린 채 남아 [저장]이 다시 보냄.
void saveRecommendedRoute(
projectId,
picked.snapshot,
{
mode: picked.logic,
weights: picked.weights,
criteria: picked.criteriaOverrides,
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
},
choiceWithProfile(picked),
picked.terrain ?? null,
);
},
});
})();
});
render();
return {
ready(snapshot, server) {
opening = copy(snapshot);
ready(_snapshot, server) {
// 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다.
if (server) {
@@ -209,7 +69,6 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
openingChoice = choice;
writeInitialChoice(projectId, choice);
}
render();
},
current: () => {
const now = choice ?? base;
@@ -227,7 +86,6 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
choice = tag as InitialChoice | null;
writeInitialChoice(projectId, choice);
render();
},
cancel: () => writeInitialChoice(projectId, openingChoice),
keep: () => writeInitialChoice(projectId, choice),
@@ -0,0 +1,412 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Layers.ts
* 평면 노선 편집 지도 겹(PLAN 624-3 · 요구 530 · 결정 A② · A③ · A⑦ · A⑨) — 공용 겹 줄 · 범례(`A00_Common/map`)를 B05 평면에.
* 겹 목록 · 처음 켬 · 취향 = 공용 페이지 표 「b05」(띠 · 올린 임도망도 숨김 · 처음 = 등고선 · 하천만).
* 등고선 · 하천 = B05 자기 자료(LAS 5 m · 1 m · 도엽 · 집기 · 자물쇠 그대로 · `_Gl` 이 그림 · 노선 띠 300 m 자르기는 이 둘만) ·
* 켬끔은 GL 에서만(옛 캔버스 = 늘 보임).
* 그 밖 겹 = GL 만(A⑦) · 띠로 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹이 있을 때만 섬) · 꼴 = 공용 `mapLayerSpecs` ·
* 카메라 = `_Gl` 과 같은 화면 따라가기(`glCameraOf`).
* 받기 = 켤 때 한 번(서버 `/plan/…` · 624-1 임도 프로젝트 · 지형 = 설계 지형) · 도엽 선 겹 = 프로젝트 범위 도엽 조각 ·
* 실패 = 그 겹만 끔 + 알림 · 범례 = 켠 겹의 받은 자료 줄(공용 `legendRows`) · 급경사 ⚙ · 위성 밝기.
* 부르는 곳 = `_Chrome`(겹 줄 · 범례 자리) · `_Gl`(따라가기 · 켬끔).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { createGlMap, GL_FONT, satelliteTileSource, type GlData } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { Normalizer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { glCameraOf } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame";
import { forestSources, soilColor, tileOnly } from "../A00_Common/map/map_forest_layers";
import {
fetchPlanCadastral,
fetchPlanLandslideZones,
fetchPlanLines,
fetchPlanRaster,
fetchPlanSheetTile,
fetchPlanSheetTiles,
fetchPlanSoils,
fetchPlanZeroOrder,
fetchPlanZones,
type GeoJsonCollection,
} from "../A00_Common/map/map_layer_api";
import { createMapLayerBar } from "../A00_Common/map/map_layer_bar";
import {
FOREST_ROAD_KINDS,
WATER_KEYS,
glImage,
glTagged,
loadImage,
shadeImage,
} from "../A00_Common/map/map_layer_data";
import {
LAYERS,
LINE_LAYERS,
ZONE_COLORS,
type MapLayerKey,
} from "../A00_Common/map/map_layer_list";
import { createMapLegend, legendRows, type LegendData } from "../A00_Common/map/map_legend";
import { MAP_THEME_NAMES, mapLayerSpecs } from "../A00_Common/map/map_layer_specs";
import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light";
import {
openSlopeBands,
paintSlope,
readSlopeBands,
slopeGear,
slopeValues,
writeSlopeBands,
} from "../A00_Common/map/map_slope_bands";
/** B05 자기 자료로 그리는 겹(`_Gl` · 띠 자르기). */
const OWN: readonly MapLayerKey[] = ["contour", "stream"];
const isOwn = (key: MapLayerKey): boolean => OWN.includes(key);
/** 겹 → 도엽 조각 이름(서버 `TILE_NAMES` · D03 `SHEET_TILE_NAMES` 와 같음). */
const SHEET_NAMES: Partial<Record<MapLayerKey, string[]>> = {
water: WATER_KEYS,
road: ["도로중심선"],
forestRoad: ["도로중심선"],
building: ["건물"],
place: ["지명"],
};
/** 도엽이 이보다 많으면 선 겹 숨김(D03 `FULL_MAX` 와 같음). */
const SHEET_MAX = 12;
/** 지도 칸 둘레 여유(px · `.ui-gl-map` inset · B04 `createTmGl` 과 같음). */
const GL_MARGIN_PX = 8;
export interface RouteEditLayers {
/** 겹 줄(도구줄 아래 줄). */
bar: HTMLElement;
/** 켠 겹 범례(상태 글판 안). */
legend: HTMLElement;
/** B05 자기 겹 켬끔 — 등고선 · 하천을 그리나(옛 캔버스 = 늘 참). */
own: () => { contour: boolean; stream: boolean };
/** GL 켬끔 · 지도 범위가 바뀜 — 단추 · 범례 맞춤. */
sync: (glOn: boolean, meta: VWorldMeta | null) => void;
/** 그리기마다(GL 켬일 때만) — 켠 겹이 있으면 둘째 칸을 세우고 화면을 따라감. */
follow: (
meta: VWorldMeta,
normalizer: Normalizer,
toScreen: (nx: number, ny: number) => [number, number],
width: number,
height: number,
) => void;
/** GL 끔 · 떠남 — 둘째 칸 버림. */
park: () => void;
/** 검증 창구 — 켬끔 · 받은 겹 · 둘째 칸 · 겹마다 그린 지물 수. */
state: () => {
on: Record<MapLayerKey, boolean>;
loaded: MapLayerKey[];
map: boolean;
gl: Record<string, number>;
sheets: number;
};
}
export function createRouteEditLayers(opts: {
projectId: string;
/** 지도 칸(둘째 GL 칸을 맨 앞에 둠). */
wrap: HTMLElement;
/** 그래픽 카드 단추 — `_Gl` 켬끔. */
onGl: () => void;
/** 장면 다시 그리기. */
redraw: () => void;
}): RouteEditLayers {
const { projectId, wrap } = opts;
let glOn = false;
let meta: VWorldMeta | null = null;
let light = readSatLight();
const gl = createGlMap<MapLayerKey>({
specs: () => mapLayerSpecs(light, null),
fixed: { satellite: satelliteTileSource(projectId), ...forestSources(projectId) },
themeNames: MAP_THEME_NAMES,
onFail: (why) => showToast(`지도 겹을 그래픽 카드로 못 그림 — ${why}`, "info"),
});
const layerBar = createMapLayerBar({
page: "b05",
glOn,
onToggle: (key) => {
void ensure(key);
sync(glOn, meta);
opts.redraw();
},
onGl: opts.onGl,
});
const { on } = layerBar;
layerBar.root.classList.add("b05-routeedit__layers");
const legendBox = createMapLegend("b05-routeedit__maplegend");
const data: Partial<LegendData> = {};
const loaded = new Set<MapLayerKey>();
const pending = new Map<string, Promise<void>>();
let hillshade: { plain: GlData; shade: GlData } | null = null;
let size = "";
let enabled = false;
/** 급경사 범위(603 · D03 과 같은 취향) — 바꾸면 다시 칠함. */
let slopeBands = readSlopeBands();
let slopeValuesNow: ImageData | null = null;
const gear = slopeGear(() =>
openSlopeBands(slopeBands, (bands) => {
slopeBands = bands;
writeSlopeBands(bands);
void paintSlopeNow();
}),
);
async function paintSlopeNow(): Promise<void> {
const got = data.slope;
if (!got || !slopeValuesNow) return;
const bands = slopeBands;
const painted = await paintSlope(slopeValuesNow, bands);
if (bands !== slopeBands) return URL.revokeObjectURL(painted.url);
data.slope = { raster: got.raster, bands };
gl.setData("slope", glImage(got.raster, painted.url));
sync(glOn, meta);
opts.redraw();
}
const lightRow = createSatLightRow(light, (level) => {
light = level;
if (gl.map()?.getLayer("satellite"))
gl.map()!.setPaintProperty("satellite", "raster-brightness-max", level);
opts.redraw();
});
function sync(nextGl: boolean, nextMeta: VWorldMeta | null): void {
const first = !meta && nextMeta;
glOn = nextGl;
meta = nextMeta;
layerBar.sync({ ready: !!meta, glOn });
// 자기 겹(등고선 · 하천) = GL 에서만 켬끔 · 옛 캔버스는 늘 그림
for (const key of OWN) {
const button = layerBar.root.querySelector<HTMLButtonElement>(`[data-layer="${key}"]`);
if (!button || glOn) continue;
button.disabled = true;
button.title = `${LAYERS.find((def) => def.key === key)!.title} — 옛 그림(그래픽 카드 끔)엔 늘 보임`;
}
// 그 밖 겹 = GL 만(A⑦)
for (const def of LAYERS) {
if (isOwn(def.key)) continue;
gl.setVisible(def.key, on[def.key]);
const button = layerBar.root.querySelector<HTMLButtonElement>(`[data-layer="${def.key}"]`);
if (button && meta && !glOn) {
button.disabled = true;
button.title = `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임`;
}
}
if (hillshade) gl.setData("hillshade", on.satellite ? hillshade.shade : hillshade.plain);
const shown = glOn ? on : ({} as Record<MapLayerKey, boolean>);
legendBox.show(meta ? legendRows(shown, data) : [], glOn && on.satellite ? [lightRow] : []);
if (glOn && on.slope && data.slope) legendBox.root.firstElementChild?.append(gear);
if (first) void Promise.all(LAYERS.map((def) => ensure(def.key)));
}
/** 켠 겹 자료 한 번 — 실패 = 그 겹만 끔 + 알림(취향은 안 바꿈). */
function ensure(key: MapLayerKey): Promise<void> {
if (!meta || !on[key] || isOwn(key) || tileOnly(key) || key === "landslide") return done();
if (loaded.has(key)) return done();
const slot = key === "forestRoad" ? "road" : key;
if (loaded.has(slot as MapLayerKey)) return done();
const running = pending.get(slot);
if (running) return running;
const at = meta;
const job = fill(key, at)
.then(() => {
loaded.add(key);
loaded.add(slot as MapLayerKey);
sync(glOn, meta);
opts.redraw();
})
.catch((error: unknown) => {
for (const def of LAYERS)
if (def.key === key || (def.key === "forestRoad" ? "road" : def.key) === slot)
on[def.key] = false;
sync(glOn, meta);
opts.redraw();
const text = error instanceof Error ? error.message : String(error);
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
})
.finally(() => pending.delete(slot));
pending.set(slot, job);
return job;
}
const done = (): Promise<void> => Promise.resolve();
/** 프로젝트 범위 도엽 — 한 번 셈 · 조각은 이름마다 한 번. */
let sheetList: Promise<string[]> | null = null;
const sheetCache = new Map<string, Promise<GeoJsonCollection>>();
async function sheetCollection(names: string[], at: VWorldMeta): Promise<GeoJsonCollection> {
sheetList ??= fetchPlanSheetTiles(projectId, [
at.lon_min,
at.lat_min,
at.lon_max,
at.lat_max,
]).then((got) => {
if (got.count > SHEET_MAX) throw new Error(`도엽 ${got.count}장 — 넓어서 선 겹 숨김`);
return got.sheets;
});
const sheets = await sheetList.catch((error: unknown) => {
sheetList = null;
throw error;
});
const parts = await Promise.all(
sheets.flatMap((sheet) =>
names.map((name) => {
const key = `${sheet}/${name}`;
if (!sheetCache.has(key))
sheetCache.set(
key,
fetchPlanSheetTile(projectId, sheet, name).catch(() => ({ features: [] })),
);
return sheetCache.get(key)!;
}),
),
);
return { features: parts.flatMap((part) => part.features ?? []) };
}
async function fill(key: MapLayerKey, at: VWorldMeta): Promise<void> {
const names = SHEET_NAMES[key];
if (names) {
const collection = await sheetCollection(names, at);
if (key === "road" || key === "forestRoad") {
const small = (collection.features ?? []).filter((feature) =>
FOREST_ROAD_KINDS.has(String(feature.properties?.["도로구분"])),
);
data.road = { small: { features: small } };
return gl.setData("road", collection);
}
if (key === "water") data.water = collection;
if (key === "building") data.building = collection;
if (key !== "place") return gl.setData(key, collection);
const places = (collection.features ?? []).flatMap((feature) => {
const name = feature.properties?.["명칭"];
return feature.geometry?.type === "Point" && typeof name === "string" && name
? [{ type: "Feature", properties: { name }, geometry: feature.geometry }]
: [];
});
await document.fonts
.load(`600 24px "${GL_FONT}"`, places.map((p) => p.properties.name).join("") || "가")
.catch(() => null);
return gl.setData("place", { features: places });
}
if (key === "hillshade") {
const raster = await fetchPlanRaster(projectId, "hillshade");
const image = await loadImage(raster.image);
const plain = glImage(raster);
if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! };
return;
}
if (key === "slope") {
const raster = await fetchPlanRaster(projectId, "slope");
slopeValuesNow = slopeValues(await loadImage(raster.image));
data.slope = { raster, bands: slopeBands };
return paintSlopeNow();
}
if (key === "cadastral") return gl.setData("cadastral", await fetchPlanCadastral(projectId));
if (key === "zones") {
const zones = await fetchPlanZones(projectId);
data.zones = { summary: zones.summary };
return gl.setData(
"zones",
glTagged(zones, (props) =>
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
),
);
}
if (key === "soils") {
// 그림 = 조각(622-2) · 범례 = 받은 범위 요약
const soils = await fetchPlanSoils(projectId);
data.soils = {
kinds: soils.summary.map((soil) => ({ color: soilColor(soil.code) })),
summary: soils.summary,
};
return;
}
if (key === "landslideZones") {
const info = await fetchPlanLandslideZones(projectId);
data.landslideZones = { info };
return gl.setData("landslideZones", {
features: [
...(info.parcels.features ?? []),
...info.points.map((point) => ({
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
})),
],
});
}
if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
const kind = LINE_LAYERS[key].kind;
const slow = window.setTimeout(
() => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"),
600,
);
try {
const found = kind
? await fetchPlanLines(projectId, kind).then((lines) => ({
collection: lines as GeoJsonCollection,
...lines.summary,
}))
: await fetchPlanZeroOrder(projectId);
data[key] = { count: found.count, length_m: found.length_m };
return gl.setData(key, found.collection);
} finally {
window.clearTimeout(slow);
}
}
}
const wanted = (): boolean => glOn && LAYERS.some((def) => !isOwn(def.key) && on[def.key]);
function park(): void {
if (!enabled) return;
enabled = false;
gl.enable(null);
}
return {
bar: layerBar.root,
legend: legendBox.root,
own: () => ({ contour: !glOn || on.contour, stream: !glOn || on.stream }),
sync,
follow(at, normalizer, toScreen, width, height) {
if (!wanted() || width <= 0 || height <= 0) return park();
if (!enabled) {
enabled = gl.enable(wrap);
size = "";
if (!enabled) return;
}
const container = gl.container();
if (!container) return;
// 맨 밑(띠로 자른 B05 칸 아래) — 그 칸을 다시 켜면 앞에 붙으므로 그릴 때마다 맞춤
if (wrap.firstElementChild !== container) wrap.prepend(container);
const now = `${width}x${height}`;
if (now !== size) {
size = now;
container.style.width = `${width + 2 * GL_MARGIN_PX}px`;
container.style.height = `${height + 2 * GL_MARGIN_PX}px`;
gl.resize();
}
const camera = glCameraOf(at, normalizer, toScreen, width, height);
if (!camera) return;
gl.camera(camera.center, camera.zoom, camera.bearing);
container.style.transform = `matrix(${camera.css.join(", ")}, 0, 0)`;
},
park,
state: () => ({
on: { ...on },
loaded: [...loaded],
map: enabled,
gl: Object.fromEntries(
(gl.map()?.getStyle().layers ?? [])
.filter((layer) => layer.layout?.visibility !== "none")
.map((layer) => [
layer.id,
layer.type === "raster"
? 1
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
]),
),
sheets: sheetCache.size,
}),
};
}
@@ -0,0 +1,105 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_RecPreview.ts
* 평면 노선 추천 미리보기(PLAN 626-1 · 요구 530 · 결정 D1 · D7 · D8) — 평면 도구줄 아이콘 하나 + 아래 판
* (영선 · 토공 최소 · 기울기 최소 · 비중 계산 · 끔) · 하나만 보임 · 선만(위 캔버스 · 노선 아래 그림 · 집기 없음).
* 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 ·
* 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김).
* 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞).
* ========================================================================== */
import { createPickMenu } from "@ui/ui_template_context_menu";
import { showToast } from "@ui/ui_template_elements";
import { themeColor } from "@ui/ui_template_palette";
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
type Vertex = [number, number];
/** 드롭다운 넷(요구 530 · 「예상노선 추적」 = 초기값이라 뺌). */
const MODES = [
"gradeline",
"earthwork",
"gentle",
"weighted",
] as const satisfies readonly RouteInitialLogic[];
export interface RecPreview {
root: HTMLElement;
/** 노선 그리기 안(돌림 안) — 고른 선 하나. */
draw: (context: CanvasRenderingContext2D, toScreen: (vertex: Vertex) => [number, number]) => void;
/** 검증 창구 — 고른 갈래 · 선 점 수. */
state: () => { mode: string | null; points: number };
}
export function createRecPreview(opts: {
projectId: string;
/** 지금 설계 지형(저장본 고름 · D7). */
terrain: () => AnalysisTerrain;
redraw: () => void;
}): RecPreview {
const lines = new Map<string, Vertex[]>();
let shown: Vertex[] | null = null;
let turn = 0;
const menu = createPickMenu({
icon: "compass",
title: "추천 노선 보기 — 노선 추천 저장본 선 하나만 겹쳐 봄(끔 = 안 보임 · 노선은 안 바뀜)",
items: MODES.map((mode) => ({ value: mode, label: LOGIC_LABEL[mode] })),
offLabel: "끔",
onPick: (mode) => void pick(mode as RouteInitialLogic | null),
});
menu.root.dataset.act = "recommend";
async function pick(mode: RouteInitialLogic | null): Promise<void> {
const mine = ++turn;
shown = null;
opts.redraw();
if (!mode) return;
const key = `${mode}|${opts.terrain()}`;
try {
if (!lines.has(key)) {
const body = (await fetchRouteInitial(opts.projectId, {
mode,
with_profile: true,
terrain: opts.terrain(),
})) as { planned?: number[][] };
const planned = (body.planned ?? []).map(([x, y]) => [x, y] as Vertex);
if (planned.length < 2) throw new Error("추천 선이 비었음");
lines.set(key, planned);
}
if (mine !== turn) return;
shown = lines.get(key)!;
opts.redraw();
} catch (error) {
if (mine !== turn) return;
menu.set(null);
const text = error instanceof Error ? error.message : String(error);
showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info");
}
}
return {
root: menu.root,
draw(context, toScreen) {
if (!shown) return;
context.save();
context.setLineDash([10, 6]);
context.lineJoin = "round";
context.beginPath();
shown.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index) context.lineTo(x, y);
else context.moveTo(x, y);
});
// 지도 위 그림 색(테마 안 따름) + 검은 테 — 위성 · 어두운 바탕 모두 보임
context.strokeStyle = "rgba(0, 0, 0, 0.6)";
context.lineWidth = 5;
context.stroke();
context.strokeStyle = themeColor("--draw-map-sketch", "#ffe94d");
context.lineWidth = 3;
context.stroke();
context.restore();
},
state: () => ({ mode: menu.value(), points: shown?.length ?? 0 }),
};
}
@@ -1,690 +0,0 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend.ts
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 ·
* 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 ·
* 못 지킨 구간 · 오른쪽 위 [적용] · [닫기].
* 종단은 「종단 반영」 단추가 정한다(421 · 기본 켬 · 다섯 갈래) — 누르면 바로 다시 셈 · [적용] 균형 종단도 이 값.
* [재계산] = 저장본 없음(409) 안내 줄 옆 · 칸을 바꾼 뒤 종단 반영 오른쪽에만(508-2) — 서버 저장본을 지우고 새로 셈(`recompute`).
*
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 노선 편집의 노선은
* 건드리지 않는다 — [적용]을 누르면 `onApply` 로 바로 서버 저장(새 초기값 · 346-3), [닫기]는
* 미리 보기를 버린다.
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
* 지형 고르기(509-5 · 라이다 · 수치지형도 둘일 때) — 바꾸면 그 지형 미리 셈 저장본 · 없으면 안내 + [재계산] ·
* [적용]하면 그 지형이 설계 지형(초기값도 그 지형으로).
* 적용될 초기 종단(476) — 서버가 [적용]과 같은 코드로 세운 종단을 같은 그림에 선 · 한 줄로 더한다
* (끔이어도) · [적용]은 「종단 · 횡단 손 입력이 모두 지워짐」 확인 뒤(요구 277).
* ========================================================================== */
import { openModal } from "@ui/ui_template_modal";
import {
createButton,
createInputField,
showConfirmDialog,
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,
RouteInitialLogic,
RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import {
choiceLabel,
criteriaLine,
CRITERIA_KEYS,
LOGIC_LABEL,
metricsLines,
TARGET_GRADE_KEY,
canLockWeight,
derivedWeightKey,
inputWeight,
moveWeight,
relaxedLine,
SIMILAR_HINT,
similarLine,
violationsLine,
brokenItems,
endsLine,
groundGradeLine,
groundGradeTitle,
violationText,
WEIGHT_KEYS,
type CriteriaKey,
type InitialChoice,
type InitialCriteria,
type MapLayer,
type WeightLocks,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
import {
bindRecommendHelp,
recommendHelpHtml,
syncRecommendHelp,
} from "./B05_Profile_UI_RouteEdit_Recommend_Help";
import {
balanceLine,
candidatesTable,
initialLine,
profileLegend,
profileSvg,
type BalanceProfile,
} from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const;
const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 + 종단(종단 반영 끄면 계획고 = 지반고)",
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단",
earthwork: "절토 · 성토가 가장 적은 노선 + 종단",
gentle: "종단기울기가 가장 낮은 노선 + 종단",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선 + 종단",
};
/** [적용] 확인 글(476 · 요구 277) — 같은 노선이어도 수작업(종단 · 횡단 손 입력)을 모두 버린다. */
const WEIGHT_NAME: Record<WeightKey, string> = {
expected: "예상",
earthwork: "토공",
grade: "기울기",
};
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
const CRITERIA_FIELD: Record<
CriteriaKey,
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
> = {
max_grade_pct: {
label: "종단기울기 상한",
unit: "%",
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
of: (c) => c.maxGradePct,
},
min_radius_m: {
label: "최소 반지름",
unit: "m",
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
of: (c) => c.minRadiusM,
},
stream_offset_m: {
label: "계류 이격",
unit: "m",
title: "계류 이격(m) — 비우면 기본값 100m",
of: (c) => c.streamOffsetM,
},
grade_smooth_m: {
label: "지반 고름 폭",
unit: "m",
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
of: (c) => c.gradeSmoothM,
},
};
const TERRAIN_NAME: Record<AnalysisTerrain, string> = { las: "라이다", sheet: "수치지형도" };
/** 서버 `NOT_PREPARED`(409 · 505-1) 글 머리 — 이 글이면 [재계산]을 안내 줄 옆에. */
const NOT_PREPARED_HEAD = "추천 저장본이 없습니다";
export interface RecommendParams {
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
criteria: RouteInitialCriteria | null;
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */
defaults: Partial<Record<CriteriaKey, number>>;
weights: RouteInitialWeights;
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
/** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */
logic: RouteInitialLogic;
/** 「종단 반영」 처음 값 — 적용한 추천의 값 · 없으면 켬(421). */
withProfile: boolean;
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
current: Vertex[];
/** 서버에 갈래 하나를 묻고 편집값 한 벌로 펴 돌려준다(던지면 토스트). */
request: (
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
terrain: AnalysisTerrain,
) => Promise<InitialChoice>;
/** 고를 수 있는 지형(509-5) — 둘이면 창에 고르기 · 처음 값 = 지금 설계 지형. */
terrains: AnalysisTerrain[];
terrain: AnalysisTerrain;
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
onApply: (choice: InitialChoice) => void;
/** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */
confirmText: () => Promise<string>;
/** 서버 미리 셈 진행(364-4) — 셈 기다리는 동안 상태줄에 붙인다. */
progress?: () => Promise<PrecomputeProgress | null>;
}
export function openRecommendModal(params: RecommendParams): void {
// 알약에 없는 옛 갈래(안전 우선 등)로 적용했던 노선이면 예상노선 추적에서 시작한다.
let logic: RouteInitialLogic = (PILLS as readonly string[]).includes(params.logic)
? params.logic
: "follow";
const { locks } = params.weightMemory;
let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) };
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
const invalid = new Set<WeightKey>();
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;
/** 마지막 셈이 저장본 없음(409 · 505-1) — [재계산]을 안내 줄 옆에 보임(508-2). */
let missing = false;
let closed = false;
let seq = 0;
/** 이 모달에서 셈한 후보의 균형 종단(계획서 63-4) — 모달이 닫히면 버린다(캐시 · 저장 없음). */
const seen = new Map<string, BalanceProfile>();
let ticker: number | undefined;
let bodyEl: HTMLElement | null = null;
let map: ReturnType<typeof createRecommendMap> | null = null;
openModal({
title: "노선 추천",
closeLabel: "닫기", // 제목줄 단추는 mount 에서 [적용] · [닫기] 묶음으로 갈아 끼운다(PLAN 77-3)
dialogClass: "b05-routerec",
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
backdropClass: "b05-routerec-backdrop",
// [닫기] · Esc · 바깥 누르기 모두 미리 보기를 버린다.
beforeClose: () => {
closed = true;
window.clearInterval(ticker);
map?.dispose();
return true;
},
mount: (body, close) => {
bodyEl = body;
body.classList.add("b05-routerec__body");
body.innerHTML = `
<div class="b05-routerec__actions">
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
</div>
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래" data-slot="pills"></div>
<div class="b05-routerec__main">
<div class="b05-routerec__mapbox">
<div class="b05-routerec__mapwrap" role="img" aria-label="추천 노선 미리 보기">
${RECOMMEND_MAP_HTML}
<div class="b05-routerec__side" role="group" aria-label="추천 옵션">
<div class="b05-routerec__weights" hidden>
<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-size="32" 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="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
<span data-slot="target-input"></span>%</label>
<div class="b05-routerec__row"><button type="button" class="ui-btn ui-btn--glass" data-act="rerun">재계산</button></div>
</div>
</div>
<div class="ui-legend b05-routerec__legend">
<span class="ui-legend__item"><i class="ui-legend__mark ui-legend__mark--dash is-expected"
style="--legend-color: var(--color-text-secondary)"></i>예상노선(원본)</span>
<span class="ui-legend__item"><i class="ui-legend__mark ui-legend__mark--line is-current"
style="--legend-color: var(--map-route)"></i>창을 연 노선</span>
<span class="ui-legend__item"><i class="ui-legend__mark ui-legend__mark--line is-preview"
style="--legend-color: var(--color-primary)"></i>미리 보기</span>
</div>
<div class="b05-routerec__maphint">휠 = 확대 · 축소 &nbsp; 끌기 = 이동 &nbsp; ↺ ↻ = 회전</div>
</div>
</div>
<div class="b05-routerec__lines">
<div data-line="metrics"></div>
<div data-line="metrics-work"></div>
<div data-line="balance" hidden></div>
<div data-line="initial" hidden></div>
<div class="b05-routerec__balance">
<div class="b05-routerec__profile" data-line="profile" hidden></div>
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
</div>
<div data-line="violations" hidden></div>
<div data-line="ground" hidden></div>
<div class="is-warn" data-line="ends" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div class="is-warn" data-line="stations" hidden></div>
<div data-line="similar" hidden></div>
<div class="is-dim" data-line="similar-hint" hidden></div>
<div data-line="criteria"></div>
</div>
<div class="b05-routerec__foot">
<span class="b05-routerec__state" data-state></span>
</div>
${recommendHelpHtml()}`;
// 알약 · 비중 막대 · 제약 칸 · 목표 기울기 칸은 공용 부품(createButton · createInputField ·
// PF toggleButton)으로 짓는다(PLAN 117) — 글 틀은 위에 자리만 남기고 여기서 채운다.
const pillsBox = body.querySelector<HTMLElement>('[data-slot="pills"]')!;
for (const key of PILLS) {
const pill = createButton({ label: LOGIC_LABEL[key], variant: "pill" });
pill.classList.add("b05-routerec__pill");
pill.setAttribute("role", "radio");
pill.dataset.logic = key;
pill.title = PILL_TITLE[key];
pillsBox.append(pill);
}
const barsBox = body.querySelector<HTMLElement>('[data-slot="bars"]')!;
for (const key of WEIGHT_KEYS) {
const bar = document.createElement("div");
bar.className = "b05-routerec__bar";
const numField = createInputField({ type: "number", min: 0, max: 100, size: 32 }); // 부품 입력 단 32(553-2)
numField.input.step = "1";
numField.input.inputMode = "numeric";
numField.input.classList.add("b05-routerec__num");
numField.input.dataset.weightNum = key;
numField.input.setAttribute("aria-label", `${WEIGHT_NAME[key]} 비중 값`);
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0";
slider.max = "100";
slider.step = "1";
slider.dataset.weight = key;
slider.setAttribute("aria-label", `${WEIGHT_NAME[key]} 비중`);
const lock = toggleButton("고정", false, (active) => {
locks[key] = active;
invalid.clear();
render();
});
lock.classList.add("b05-routerec__lock");
lock.dataset.weightLock = key;
lock.setAttribute("aria-label", `${WEIGHT_NAME[key]} 비중 고정`);
const name = document.createElement("span");
name.textContent = WEIGHT_NAME[key];
bar.append(numField.input, slider, lock, name);
barsBox.append(bar);
}
const criteriaBox = body.querySelector<HTMLElement>('[data-slot="criteria"]')!;
for (const key of CRITERIA_KEYS) {
const field = CRITERIA_FIELD[key];
const row = document.createElement("label");
row.title = field.title;
const name = document.createElement("span");
name.textContent = field.label;
const input = createInputField({ type: "number", min: 0, size: 32 }); // 부품 입력 단 32(553-2)
input.input.inputMode = "decimal";
input.input.step = "0.1";
input.input.dataset.criteria = key;
input.input.placeholder = "기본값";
const unit = document.createElement("span");
unit.textContent = field.unit;
row.append(name, input.input, unit);
criteriaBox.append(row);
}
const targetField = createInputField({ type: "number", min: 0, max: 100, size: 32 }); // 부품 입력 단 32(553-2)
targetField.input.inputMode = "decimal";
targetField.input.step = "0.1";
targetField.input.dataset.criteria = TARGET_GRADE_KEY;
targetField.input.placeholder = "상한";
body.querySelector('[data-slot="target-input"]')!.replaceWith(targetField.input);
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
// [적용] · [닫기]를 제목줄 오른쪽 위로 — 공용 겉틀의 닫기 단추와 갈아 끼운다(PLAN 77-3).
const actions = q<HTMLElement>(".b05-routerec__actions");
const applyButton = q<HTMLButtonElement>('[data-act="apply"]');
const closeButton = q<HTMLButtonElement>('[data-act="close"]');
body.parentElement?.querySelector(".ui-modal__top > button")?.replaceWith(actions);
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
const profileToggle = toggleButton("종단 반영", profileOn, (active) => {
profileOn = active;
void run(); // 누르면 바로 — 저장본 있으면 그것
});
profileToggle.dataset.act = "profile";
profileToggle.title = "켬 = 후보마다 측점 횡단으로 균형 종단 · 끔 = 계획고 = 지반고";
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);
criteriaInputs.forEach((input) => {
const value =
params.criteria?.[input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY];
input.value = value === undefined ? "" : String(value);
});
/** 칸에 지금 든 값만(빈 칸은 안 담김) — 서버에 실어 보낼 겹침. */
function currentCriteria(): RouteInitialCriteria {
const out: RouteInitialCriteria = {};
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
// 목표 기울기는 영선 노선을 물을 때만 보낸다.
if (key === TARGET_GRADE_KEY && logic !== "gradeline") return;
const raw = input.value.trim();
if (raw === "") return;
const value = Number(raw);
if (Number.isFinite(value)) out[key] = value;
});
return out;
}
function drawMap(): void {
const layers: MapLayer[] = [
{ kind: "expected", points: params.expected },
{ kind: "current", points: params.current },
];
// 서버가 짠 원호 그대로(351-2) — 옛 답이면 꺾임점
if (preview)
layers.push({ kind: "preview", points: preview.line ?? preview.snapshot.planned });
map?.setLayers(layers);
}
function render(): void {
pills.forEach((pill) => {
const on = pill.dataset.logic === logic;
pill.setAttribute("aria-checked", String(on));
pill.classList.toggle("is-active", on);
pill.disabled = busy;
});
weightsBox.hidden = logic !== "weighted";
const derived = derivedWeightKey(locks);
for (const key of WEIGHT_KEYS) {
const slider = q<HTMLInputElement>(`[data-weight="${key}"]`);
const num = q<HTMLInputElement>(`[data-weight-num="${key}"]`);
const lock = q<HTMLButtonElement>(`[data-weight-lock="${key}"]`);
// 고정했거나 나머지 둘이 고정돼 저절로 정해진 비중은 잠김 — 움직이지 않는다.
const shut = locks[key] || derived === key;
slider.value = String(weights[key]);
slider.disabled = busy || shut;
// 넣다 만 틀린 글은 지우지 않는다 — 빨간 테두리로 남겨 고치게 한다.
if (!invalid.has(key)) num.value = String(weights[key]);
num.disabled = busy || shut;
num.classList.toggle("is-invalid", invalid.has(key));
num.setAttribute("aria-invalid", String(invalid.has(key)));
lock.setAttribute("aria-pressed", String(shut));
lock.classList.toggle("is-active", shut);
lock.classList.toggle("is-auto", derived === key);
// 셋 다 고정은 막는다 — 마지막 하나는 고정 단추가 꺼지고 잠김으로만 보인다.
lock.disabled = busy || !canLockWeight(locks, key) || derived === key;
lock.title =
derived === key
? "나머지 둘이 고정돼 저절로 정해집니다"
: locks[key]
? "고정 풀기"
: "이 값 고정 — 다른 비중을 움직여도 안 바뀝니다";
}
q<HTMLElement>("[data-weight-sum]").textContent =
`합 ${weights.expected + weights.earthwork + weights.grade}`;
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY;
// 목표 기울기를 비우면 종단기울기 상한을 쓴다 — 그 값이 자리표시.
const known =
key === TARGET_GRADE_KEY ? params.defaults.max_grade_pct : params.defaults[key];
input.placeholder =
known !== undefined ? String(known) : key === TARGET_GRADE_KEY ? "상한" : "기본값";
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
// 저장본 없음 = 안내 줄 옆 · 칸을 바꿈 = 종단 반영 오른쪽 · 그 밖엔 숨김(508-2)
rerun.hidden = !(missing || dirty);
if (missing) stateEl.after(rerun);
else profileToggle.after(rerun);
rerun.disabled = busy || invalid.size > 0;
terrainSelect.disabled = busy;
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(profileOn));
profileToggle.classList.toggle("is-active", profileOn);
const view = preview;
applyButton.disabled = busy || dirty || !view || invalid.size > 0;
const shown = metricsLines(view?.metrics ?? null);
line("metrics").textContent = view
? view.metrics
? shown.route
: "비교값 — 서버 답에 없습니다."
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
line("metrics-work").textContent = view ? shown.work : "";
line("metrics-work").hidden = !line("metrics-work").textContent;
const balance = view?.balance ?? null;
line("balance").textContent = balanceLine(balance);
line("balance").hidden = !balance;
// 적용될 초기 종단(476) — 끔이면 지반선 + 초기 종단 · 실패면 이유 한 줄만
const initial = view?.initialProfile ?? null;
const initialShape = initial && !("error" in initial) ? initial : null;
line("initial").textContent = initialLine(initial);
line("initial").title = initialShape?.warnings.join("\n") ?? "";
line("initial").classList.toggle("is-warn", !!initial && "error" in initial);
line("initial").hidden = !initial;
const svg = profileSvg(balance, initialShape);
line("profile").innerHTML =
svg && svg + profileLegend(!!balance?.planM.length, !!initialShape);
line("profile").hidden = !balance && !initialShape;
line("candidates").innerHTML = candidatesTable([...seen]);
line("candidates").hidden = seen.size === 0;
const violations = violationsLine(view?.metrics ?? null);
line("violations").textContent = violations;
line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
line("violations").hidden = !violations;
// 원지반 기울기 = 참고 줄 따로 · 시종점 높이 차 알림(421-2 · 엔진 419)
const ground = groundGradeLine(view?.metrics ?? null);
line("ground").textContent = ground;
line("ground").title = groundGradeTitle(view?.metrics ?? null);
line("ground").hidden = !ground;
const ends = endsLine(view);
line("ends").textContent = ends;
line("ends").hidden = !ends;
const relaxed = relaxedLine(view);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
// 곡선 MC 를 일반측점에 못 놓은 곡선(351-2 · 그 자리에 둠)
const offStation = view?.offStationCurves ?? 0;
line("stations").textContent = offStation > 0 ? `측점 밖 곡선 ${offStation}개` : "";
line("stations").hidden = offStation <= 0;
const similar = similarLine(view);
line("similar").textContent = similar;
line("similar").hidden = !similar;
line("similar-hint").textContent = similar ? SIMILAR_HINT : "";
line("similar-hint").hidden = !similar;
line("criteria").textContent = view
? criteriaLine(view.criteria, params.planMinRadiusM, view.logic === "gradeline")
: "";
line("criteria").hidden = !view;
syncRecommendHelp(body, logic);
drawMap();
}
/** `recompute` = [재계산] — 이 입력의 서버 저장본을 지우고 새로 셈. */
async function run(recompute = false): Promise<void> {
if (logic === "weighted" && invalid.size > 0) {
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
return;
}
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
const withProfile = profileOn;
busy = true;
dirty = false;
missing = false;
preview = null;
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
const started = Date.now();
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}${
choosable ? ` · ${TERRAIN_NAME[terrain]}` : ""
}`;
/** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */
let behind = "";
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
if (seconds % 2 === 1 && params.progress) {
void params.progress().then((p) => {
if (mine !== seq) return;
behind =
p?.running && p.total > 0
? ` · 서버가 다섯 갈래를 미리 셈하는 중 ${p.done}/${p.total}${
p.mode ? `(지금 ${choiceLabel(p.mode, null)})` : ""
} — 끝나면 다음부터 바로 보입니다`
: "";
});
}
stateEl.textContent = `${label} 셈하는 중… ${seconds}초${
withProfile ? " (후보마다 측점 횡단으로 종단을 셈합니다)" : " (종단 없음 — 원지반만)"
}${behind}`;
};
window.clearInterval(ticker);
tick();
ticker = window.setInterval(tick, 1000);
render();
try {
const choice = await params.request(
logic,
sentWeights,
Object.keys(criteria).length ? criteria : null,
withProfile,
recompute,
terrain,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
for (const key of CRITERIA_KEYS) {
if (criteria[key] !== undefined) continue;
const resolved = choice.criteria ? CRITERIA_FIELD[key].of(choice.criteria) : null;
if (resolved !== null) params.defaults[key] = resolved;
}
preview = choice;
if (choice.balance) seen.set(label, choice.balance);
else seen.delete(label); // 종단 없는 갈래(예상노선 추적)
stateEl.textContent = `미리 보기: ${label}${
choice.stored ? " (저장된 결과)" : ""
} — [적용]하면 바로 저장 · 새 초기값이 됩니다.`;
} catch (error) {
if (closed || mine !== seq) return;
// 서버 사유를 판에 남김 — 저장본 없음이면 「전처리를 다시 하면 생깁니다」(505-1)
const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.";
stateEl.textContent = message;
missing = message.includes(NOT_PREPARED_HEAD);
showToast(message, "error");
} finally {
if (mine === seq) window.clearInterval(ticker);
if (!closed && mine === seq) {
busy = false;
render();
}
}
}
pills.forEach((pill) => {
pill.addEventListener("click", () => {
if (busy) return;
logic = pill.dataset.logic as RouteInitialLogic;
void run();
});
});
const remember = (): void => {
params.weightMemory.weights = { ...weights };
};
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
input.addEventListener("input", () => {
weights = moveWeight(
weights,
locks,
input.dataset.weight as WeightKey,
Number(input.value),
);
invalid.clear();
remember();
dirty = preview !== null;
render();
});
});
// 값 칸 — 0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않고 빨간 테두리.
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((input) => {
input.addEventListener("input", () => {
const key = input.dataset.weightNum as WeightKey;
const raw = input.value.trim();
const result = inputWeight(weights, locks, key, raw === "" ? Number.NaN : Number(raw));
if (result.ok) {
weights = result.weights;
invalid.clear();
remember();
dirty = preview !== null;
// 다른 칸의 글도 새 값으로 — 이 칸은 넣은 그대로 둔다.
body.querySelectorAll<HTMLInputElement>("[data-weight-num]").forEach((other) => {
if (other !== input)
other.value = String(weights[other.dataset.weightNum as WeightKey]);
});
} else {
invalid.add(key);
}
render();
});
// 칸을 떠날 때 맞는 값이면 글을 값으로 다듬는다(예: 070 → 70).
input.addEventListener("blur", () => {
if (!invalid.has(input.dataset.weightNum as WeightKey)) render();
});
});
// 고정 단추 — 눌림은 만들 때(`toggleButton`) 이미 배선했다. 셋 다 고정은 `render()` 가
// disabled 로 막아 클릭 자체가 안 먹는다.
criteriaInputs.forEach((input) => {
input.addEventListener("input", () => {
dirty = preview !== null;
render();
});
});
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;
void (async () => showConfirmDialog(await params.confirmText(), "적용"))().then((ok) => {
if (!ok || closed || preview !== picked) return;
params.onApply(picked);
closed = true;
close();
});
});
closeButton.addEventListener("click", () => {
closed = true;
close();
});
render();
// 처음 고른 갈래(적용한 추천 · 없으면 예상노선 추적) — 열자마자 그 미리 보기를 받는다.
void run();
},
});
// 본문 평면 · 종단도 단축키(Ctrl+Z 등 · 창에서 들음)가 뒤에서 되돌리지 않게 막는다 — 칸 안 글자
// 되돌리기는 그대로(345-4 · 이제 모달은 이 창 하나).
(bodyEl as HTMLElement | null)
?.closest(".ui-modal__backdrop")
?.addEventListener("keydown", (event) => {
const key = event as KeyboardEvent;
if (key.ctrlKey || key.metaKey) key.stopPropagation();
});
}
@@ -1,216 +0,0 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Help.ts
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2 · 64-2 · 77) — 갈래 다섯 · 종단 반영 · 공통 제약 넷 · 결과 줄 읽는 법.
*
* 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을
* 바꾸면 여기 글도 같이 고친다. 갈래 설명은 **한 번에 한 갈래**(쪽) — 위 ◀ ▶ 단추와 「n / 전체」로
* 넘기고, 고른 갈래(알약)를 바꾸면 그 갈래 쪽으로 간다.
* ========================================================================== */
import type { RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
interface HelpEntry {
/** 한 줄 요약. */
summary: string;
/** 노선을 만드는 법. */
how: string[];
/** 이럴 때 고름. */
when: string;
/** 주의할 점. */
caution: string;
/** 종단 반영이 이 갈래에 하는 일. */
profile?: string;
}
type ShownLogic = "follow" | "gradeline" | "earthwork" | "gentle" | "weighted";
const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
follow: {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙 · 곡선 가운데(MC)는 일반측점(20m) 위로 맞춤",
"계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음 · 원지반 기울기는 참고 줄",
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution:
"계류 이격 · 기울기를 보장하지 않음 — 못 지킨 구간 · 원지반 기울기(참고) · 시종점 알림을 꼭 확인",
profile: "켬 = 균형 종단 · 끔 = 전처리 계획선처럼 계획고 = 지반고",
},
gradeline: {
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
how: [
"구간마다 기울기가 「목표 기울기」에 가장 가깝게 붙도록 길을 찾음",
"목표 칸을 비우면 종단기울기 상한이 목표 · 상한보다 크게 넣으면 상한으로 잘림",
"후보 중 목표와의 차(측점 20m 기울기 평균)가 가장 작은 것을 고름",
],
when: "일정한 기울기로 오르내리는 노선이 필요할 때(지형도 위 영선 잡기와 같은 일)",
caution:
"오르막 · 내리막을 가리지 않음 · 목표를 비우면 상한에 붙은 가파른 노선이 되기 쉬움 — 목표를 상한보다 조금 낮게 넣기를 권함",
},
earthwork: {
summary: "깎고 쌓는 흙(절토 + 성토)이 가장 적은 노선",
how: [
"옆경사가 급한 곳 · 지반이 상한보다 가파른 곳을 비싸게 보고 길을 찾음",
"후보 중 절토 + 성토 어림 양이 가장 적은 것을 고름",
"흙 양 = 계획고를 상한 안으로 맞춘 선과 옆경사 단면(노폭 4m)으로 어림",
],
when: "토공량 · 누가토량(±)을 줄여야 할 때",
caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것",
profile: "후보마다 균형 종단을 세워 절토 + 성토 + 누가토량(± 크기) ㎥ 로 비교",
},
gentle: {
summary: "종단기울기가 가장 순한 노선",
how: [
"기울기가 상한에 가까울수록 크게 벌점을 매겨 길을 찾음",
"후보 중 가장 가파른 구간의 기울기가 가장 낮은 것을 고름(같으면 평균 기울기)",
],
when: "차량 주행 · 노면 침식이 걱정되는 노선",
caution: "거리 비용이 작아 돌아가는 긴 노선이 나올 수 있음 — 연장을 확인",
},
weighted: {
summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선",
how: [
"예상 = 예상노선에서 떨어지지 않기 · 토공 = 흙 양 줄이기 · 기울기 = 순한 기울기",
"비중대로 섞은 비용 하나로 한 번 길을 찾음 — 한쪽이 100 이면 그 갈래와 같은 노선",
"자물쇠로 비중 하나를 고정하면 나머지 둘만 움직임 · 기본 50 / 25 / 25",
],
when: "예상노선을 어느 정도 살리면서 토공 · 기울기도 챙기고 싶을 때",
caution: "제약은 벌점일 뿐이라 넘는 구간이 남을 수 있음 — 못 지킨 구간을 확인",
},
};
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단 반영",
text: "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두) — 누르면 바로 다시 셈(저장본 있으면 그것). 끄면 계획고 = 지반고로 흙 양만 셈. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨",
},
{
name: "종단기울기 상한(%)",
text: "지반이 이보다 가파른 쪽으로 곧장 오르는 길은 찾지 않음. 측점(20m) 사이 원지반 기울기는 참고만 — 계획선 높이(종단)로 푸는 값이라 「못 지킨 구간」에 안 듦. 시종점 높이 차가 상한을 넘으면 종단으로도 못 맞춰 알림. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%)",
},
{
name: "최소 반지름(m)",
text: "꺾는 곳마다 이 반지름의 곡선이 들어갈 만큼 곧게 간 뒤에만 다시 꺾음. 비우면 설계속도 · 지형 법정 값(20km/h 15m · 12m)",
},
{
name: "계류 이격(m)",
text: "계류에서 이 거리 안으로는 따라 걷지 않고 건너거나 빠져나가는 방향으로만 움직임. 비우면 100m(타당성평가 「산지계류 100m 이내」 기준)",
},
{
name: "지반 고름 폭(m)",
text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m",
},
{
name: "측점 맞춤(늘)",
text: "모든 갈래에서 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 셈",
},
{
name: "재계산",
text: "지금 칸 값으로 서버 저장본을 지우고 새로 셈(덮어쓰기 아님) — 셈하는 동안 진행 줄이 보임. [적용]한 노선 · [초기화] 자리는 안 바뀜",
},
];
const LINES_HELP: Array<{ name: string; text: string }> = [
{
name: "비교값",
text: "연장 · 기울기 최대 · 평균 · 옆경사 · 절토 · 성토 · 계류 횡단 · 공사비 어림 — 갈래를 바꿔 가며 견줌",
},
{
name: "못 지킨 구간",
text: "최소 반지름 · 계류 이격 · 측점 밖 곡선을 넘은 곳만(「기준 초과」 수와 같음 · 5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
},
{
name: "원지반 기울기(참고)",
text: "측점(20m) 사이 원지반 기울기 최대 · 상한 넘는 곳 수 — 종단으로 푸는 값이라 판정에 안 듦(넘는 곳은 마우스를 올리면 보임)",
},
{
name: "시종점 높이 차",
text: "시점 · 종점 높이 차 ÷ 노선 연장이 상한을 넘으면 알림 — 종단으로 못 맞춤 · 노선을 늘려야 함",
},
{
name: "탐색 링크 한도를 풂",
text: "노선 찾기(4 m 마디 원지반)에서 상한 안 링크로는 시점 · 종점을 못 이어 꼭 필요한 만큼만 링크 한도를 올린 경우 — 원지반 기울기(참고 · 측점 20 m)와 잣대가 달라 값이 다를 수 있음",
},
{
name: "거의 같은 노선",
text: "제약 안에서 갈 길이 좁은 띠 하나뿐이라 갈래가 달라도 노선이 겹치는 경우 — 제약 값을 바꾸면 벌어질 수 있음",
},
];
const LOGIC_ORDER: ShownLogic[] = ["follow", "gradeline", "earthwork", "gentle", "weighted"];
/** 갈래 설명 쪽 하나 — 고른 갈래(`is-on`)는 테두리로 알린다. */
function logicPage(key: ShownLogic): string {
const entry = LOGIC_HELP[key];
const profile = entry.profile ? `<p><b>종단 반영</b> ${entry.profile}</p>` : "";
return `<article class="b05-routerec__help-item" data-help-logic="${key}" hidden>
<h4>${LOGIC_LABEL[key]} <span class="b05-routerec__help-picked">고른 갈래</span></h4>
<p class="b05-routerec__help-summary">${entry.summary}</p>
<ul>${entry.how.map((line) => `<li>${line}</li>`).join("")}</ul>
<p><b>이럴 때</b> ${entry.when}</p>
${profile}
<p class="b05-routerec__help-caution"><b>주의</b> ${entry.caution}</p>
</article>`;
}
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
export function recommendHelpHtml(): string {
const list = (items: Array<{ name: string; text: string }>): string =>
`<dl>${items.map((item) => `<dt>${item.name}</dt><dd>${item.text}</dd>`).join("")}</dl>`;
return `<aside class="b05-routerec__help" aria-label="노선 추천 설명">
<h3>추천 갈래</h3>
<p class="b05-routerec__help-lead">제약을 지키는 후보 노선 여러 벌을 찾은 뒤, 갈래마다 자기 기준이 가장 좋은 것을 고름.</p>
<div class="b05-routerec__help-pager" role="group" aria-label="갈래 설명 쪽 넘기기">
<button type="button" class="ui-btn ui-btn--ghost" data-help-nav="-1"
aria-label="앞 갈래 설명">◀</button>
<span data-help-page aria-live="polite"></span>
<button type="button" class="ui-btn ui-btn--ghost" data-help-nav="1"
aria-label="다음 갈래 설명">▶</button>
</div>
${LOGIC_ORDER.map(logicPage).join("")}
<h3>공통 제약</h3>
<p class="b05-routerec__help-lead">예상노선 추적 · 비중 계산을 뺀 갈래는 넘지 않음 · 칸을 비우면 기본값.</p>
${list(CRITERIA_HELP)}
<h3>결과 줄 읽는 법</h3>
${list(LINES_HELP)}
</aside>`;
}
/** 설명 쪽을 `index` 번째 갈래로 — 쪽 수는 갈래 수. */
function showHelpPage(aside: HTMLElement, index: number): void {
const page = Math.min(Math.max(index, 0), LOGIC_ORDER.length - 1);
aside.dataset.page = String(page);
aside.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
item.hidden = item.dataset.helpLogic !== LOGIC_ORDER[page];
});
aside.querySelector<HTMLElement>("[data-help-page]")!.textContent =
`${page + 1} / ${LOGIC_ORDER.length}`;
aside.querySelector<HTMLButtonElement>('[data-help-nav="-1"]')!.disabled = page === 0;
aside.querySelector<HTMLButtonElement>('[data-help-nav="1"]')!.disabled =
page === LOGIC_ORDER.length - 1;
aside.scrollTop = 0;
}
/** ◀ ▶ 단추 — 모달 본문에 한 번만 건다. */
export function bindRecommendHelp(body: HTMLElement): void {
body.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLElement>("[data-help-nav]");
const aside = body.querySelector<HTMLElement>(".b05-routerec__help");
if (!button || !aside) return;
showHelpPage(aside, Number(aside.dataset.page ?? 0) + Number(button.dataset.helpNav));
});
}
/** 고른 갈래를 표시하고, 고른 갈래가 바뀐 때만 그 갈래 쪽으로 간다(넘겨 보던 쪽은 그대로 둠). */
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
const aside = body.querySelector<HTMLElement>(".b05-routerec__help");
if (!aside) return;
aside.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
item.classList.toggle("is-on", item.dataset.helpLogic === logic);
});
if (aside.dataset.pickedLogic === logic) return;
aside.dataset.pickedLogic = logic;
showHelpPage(aside, Math.max(0, LOGIC_ORDER.indexOf(logic as ShownLogic)));
}
@@ -1,183 +0,0 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Map.ts
* [노선 추천] 모달의 미리 보기 지도 — 캔버스 하나에 선 세 겹(예상노선 · 창을 연 노선 · 미리 보기).
*
* 화면 셈 · 조작은 편집 창 것을 그대로 가져다 쓴다(베끼지 않음) — 좌표 옮기기 `metricToScreen` ·
* 처음 맞춤 `computeRouteView` · 캔버스 크기 `fitCanvas`(`_View`) · 휠 확대 · 끌기
* `bindRouteEditNavigation`(`_Input`) · 회전 `createMapRotation`(`_Rotate`). 배경 지도(VWorld)는
* 없어서, 선들의 범위를 감싼 가짜 도엽(`VWorldMeta`)을 만들어 같은 셈에 넣는다.
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import {
computeMapRect,
computeRouteView,
metricToScreen,
ROUTE_VIEW_MARGIN_M,
routeLineColor,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { HALF_TURN_ICON } from "./B05_Profile_UI_RouteEdit_Chrome";
import { bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
import type { MapLayer } from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { fitCanvas } from "./B05_Profile_UI_RouteEdit_View";
/** 처음 맞출 때 선 범위 둘레로 두는 여유(m) — 편집 창(200 m)보다 좁게, 선이 지도를 채우게. */
const FIT_MARGIN_M = 30;
/** 지도 칸 마크업 — 캔버스 + 회전 단추 둘(편집 창과 같은 모양 · 같은 `data-act`). */
export const RECOMMEND_MAP_HTML = `
<canvas class="b05-routerec__map"></canvas>
<div class="b05-routeedit__spin">
<button type="button" data-size="32" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
<button type="button" data-size="32" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
</div>`;
export interface RecommendMap {
/** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */
setLayers: (layers: MapLayer[]) => void;
dispose: () => void;
}
/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */
export function createRecommendMap(box: HTMLElement): RecommendMap {
const canvas = box.querySelector<HTMLCanvasElement>("canvas")!;
const context = canvas.getContext("2d")!;
let layers: MapLayer[] = [];
let meta: VWorldMeta | null = null;
let fitted = false;
let view: ViewState = {
width: 0,
height: 0,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: computeMapRect(null, 0, 0),
};
const rotation = createMapRotation({ overlay: box, size: () => view, onChange: () => draw() });
function bounds(): { x_min: number; x_max: number; y_min: number; y_max: number } | null {
const all = layers.flatMap((layer) => layer.points);
if (!all.length) return null;
const xs = all.map(([x]) => x);
const ys = all.map(([, y]) => y);
return {
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
};
}
/** 선 범위 + 여유를 도엽으로 본다 — 이후 확대 상한 · 맞춤이 편집 창과 같은 공식을 탄다. */
function makeMeta(range: NonNullable<ReturnType<typeof bounds>>): VWorldMeta {
const x_min = range.x_min - ROUTE_VIEW_MARGIN_M;
const y_min = range.y_min - ROUTE_VIEW_MARGIN_M;
const x_max = range.x_max + ROUTE_VIEW_MARGIN_M;
const y_max = range.y_max + ROUTE_VIEW_MARGIN_M;
return {
x_min,
x_max,
y_min,
y_max,
width_meters: x_max - x_min,
height_meters: y_max - y_min,
center_x: (x_min + x_max) / 2,
center_y: (y_min + y_max) / 2,
lon_min: 0,
lon_max: 1,
lat_min: 0,
lat_max: 1,
};
}
function refit(): void {
const b = bounds();
if (!b || !view.width) return;
meta = makeMeta(b);
view = {
...view,
mapRect: computeMapRect(meta, view.width, view.height),
...computeRouteView(meta, b, view.width, view.height, FIT_MARGIN_M),
};
fitted = true;
}
function draw(): void {
context.clearRect(0, 0, view.width, view.height);
if (!meta) return;
context.save();
context.translate(view.width / 2, view.height / 2);
context.rotate(rotation.radians());
context.translate(-view.width / 2, -view.height / 2);
context.lineCap = "round";
context.lineJoin = "round";
const styles: Record<MapLayer["kind"], () => void> = {
expected: () => {
context.strokeStyle = themeColor("--draw-text-secondary", "#9ca3af");
context.lineWidth = 1.5;
context.setLineDash([5, 4]);
},
current: () => {
context.strokeStyle = routeLineColor();
context.globalAlpha = 0.55;
context.lineWidth = 2;
context.setLineDash([]);
},
preview: () => {
context.strokeStyle = themeColor("--draw-primary", "#6d28d9");
context.lineWidth = 3;
context.setLineDash([]);
},
};
for (const layer of layers) {
if (layer.points.length < 2) continue;
context.save();
styles[layer.kind]();
context.beginPath();
layer.points.forEach(([x, y], index) => {
const [sx, sy] = metricToScreen(meta!, view, x, y);
if (index) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
context.restore();
}
context.restore();
}
function resize(): void {
const { width, height } = fitCanvas(canvas, context);
if (!width || !height) return;
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
// 처음 한 번만 맞춘다 — 그 뒤 창 크기가 바뀌어도 사용자가 만진 배율 · 자리를 지킨다.
if (!fitted) refit();
draw();
}
bindRouteEditNavigation({
canvas,
getView: () => view,
setView: (next) => (view = next),
getMeta: () => meta,
unrotateDelta: rotation.unrotateDelta,
leftPan: true,
draw,
});
// 모달이 붙기 전에는 칸 크기가 0 이다 — 붙은 뒤 크기가 잡히면 맞추고 그린다.
const observer = new ResizeObserver(() => resize());
observer.observe(canvas.parentElement!);
return {
setLayers(next) {
layers = next;
if (!fitted) refit();
draw();
},
dispose: () => observer.disconnect(),
};
}
@@ -87,6 +87,8 @@ export interface RouteEditScene {
hasMeta: boolean;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */
contours: RouteEditContours | null;
/** 등고선 겹을 끔(624-3 · GL 겹 줄) — 글 · 고른 가닥 덧그림도 안 냄(선은 GL 이 숨김). */
contourHidden?: boolean;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
otherSheets: ReadonlyArray<PreparedLayer>;
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
@@ -123,6 +125,8 @@ export interface RouteEditScene {
minorContours: PreparedLayer | null;
/** 측점 눈금 바로 위에 얹을 것(측점 사이 기울기 라벨, 계획서 18 ①). 회전 안에서 부른다. */
overStations?: (context: CanvasRenderingContext2D) => void;
/** 예상노선 위 · 계획노선 아래에 얹을 것(추천 노선 보기 626-1). 회전 안에서 부른다. */
underRoute?: (context: CanvasRenderingContext2D) => void;
}
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
@@ -231,7 +235,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
if (!glBase) drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM, pad);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
if (scene.pickedContour >= 0) {
if (scene.pickedContour >= 0 && !scene.contourHidden) {
const { layer } = scene.contours;
const level = layer.features[scene.pickedContour]?.labelValue ?? null;
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
@@ -256,6 +260,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
style.getPropertyValue("--draw-text-secondary") || "#9ca3af",
1.6,
);
scene.underRoute?.(context); // 추천 노선 보기(626-1)
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
// 예상노선 뒤 · 노선 앞(414-2) — 노선이 네모 위를 지나감 · 고른 곡선은 `drawPickedCurve` 가 위에.
@@ -357,7 +362,7 @@ function drawContourLabels(
band: { x: number; y: number; width: number; height: number } | null,
style: CSSStyleDeclaration,
): void {
if (!scene.contours) return;
if (!scene.contours || scene.contourHidden) return;
context.save();
if (band) {
context.beginPath();
@@ -0,0 +1,91 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Terrain.ts
* 평면 설계 지형 칩(PLAN 626-1 · 요구 530 · 결정 A④ · D2) — 공용 지형 칩(`map_terrain_chip`)으로 설계 지형 보기 · 바꾸기.
* 고를 수 있는 지형 = `/route/terrain-sources`(없는 쪽 막힘) · 바꾸면 확인창 → `POST /route/design-terrain`
* (설계 지형 바꿈 · 뒤 단계 모두 다시 셈 · 노선 값 · 초기값 · 추천 저장본 그대로 · 잠금 막) → 초안 비움 → 페이지 다시 세움.
* 취소 · 실패 = 칩을 지금 지형으로 되돌림. 옛 길(추천 [적용] 안 지형 고르기 · 509-5) 대신.
* 부르는 곳 = `_Chrome`(도구줄 `terrain` 칸) · `_RouteEdit`(지형 읽은 뒤 `load`).
* ========================================================================== */
import { clearDrafts } from "../A00_Common/b_page_state";
import {
createTerrainChip,
TERRAIN_NAMES,
type TerrainSource,
} from "../A00_Common/map/map_terrain_chip";
import { askConfirm } from "@ui/ui_template_modal";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { requestJson, type AnalysisTerrain } from "./B05_Profile_Api_Replan";
import { forgetDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import { rebuildAfterRouteSave } from "./B05_Profile_UI_RouteEdit_Apply";
/** 서버 설계 지형 이름(`las`) ↔ 공용 칩 값(`lidar`). */
const toChip = (terrain: AnalysisTerrain): TerrainSource => (terrain === "las" ? "lidar" : "sheet");
const toServer = (source: TerrainSource): AnalysisTerrain => (source === "lidar" ? "las" : "sheet");
export interface RouteTerrain {
root: HTMLElement;
/** 지형을 읽음(못 읽음 = null · 칩 숨김). */
load: (sources: { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null) => void;
/** 지금 설계 지형. */
design: () => AnalysisTerrain;
}
export function createRouteTerrain(projectId: string): RouteTerrain {
let design: AnalysisTerrain = "las";
let busy = false;
const chip = createTerrainChip({
page: "b05",
value: toChip(design),
have: [],
onChange: (value) => void change(toServer(value)),
});
chip.root.hidden = true;
async function change(next: AnalysisTerrain): Promise<void> {
if (busy || next === design) return chip.set(toChip(design));
const ok = await askConfirm({
title: "설계 지형 바꿈",
message:
`설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 바꿉니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. ` +
"평면 노선 · 초기값([초기화])은 그대로 · 저장 안 한 고침은 버립니다.",
okLabel: "바꿈",
});
if (!ok) return chip.set(toChip(design));
busy = true;
showLoadingOverlay();
try {
await requestJson(
`/projects/${projectId}/route/design-terrain`,
{ method: "POST", body: JSON.stringify({ terrain: next }) },
600000,
);
design = next;
clearDrafts(projectId);
forgetDesignTerrainGrid(projectId); // 새 지형 격자로(509-2)
showToast(`설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 바꿨습니다.`, "success");
await rebuildAfterRouteSave(projectId);
} catch (error) {
chip.set(toChip(design));
showToast(
`설계 지형을 바꾸지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
"error",
);
} finally {
busy = false;
hideLoadingOverlay();
}
}
return {
root: chip.root,
load(sources) {
chip.root.hidden = !sources;
if (!sources) return;
design = sources.design;
chip.set(toChip(design));
chip.setHave(sources.sources.map(toChip));
},
design: () => design,
};
}
-33
View File
@@ -1,33 +0,0 @@
/* =============================================================================
* B05_Profile_UI_RouteReset.ts
* 노선 초기화 = 추천 [적용](요구 289 · 493-2) — 확인 창 글 몫.
*
* 지워질 수 = 서버 저장분(구조물 · 관 · 추가 측점) — 서버가 [적용]에서 지우는 것과 같음(응답
* `route_reset.dropped`) · 세션 초안은 안 셈 · 관 = 저장 파일이 없으면 0(응답 관은 자동 제안).
* 구조물 · 관 = 페이지 자료 줄(저장 · 적용 뒤 다시 받음) · 추가 측점 = 지금 받음.
* 손 고침 [저장]은 묻지 않음(보이는 화면 그대로 저장).
* 글 = `logic/B05_Profile_Route_Reset`.
* ========================================================================== */
import { pagePipes, pageStructures } from "../A00_Common/design/design_page_data";
import { fetchSavedPlusStations } from "./B05_Profile_Api_PlusStations";
import { routeResetConfirmText } from "./logic/B05_Profile_Route_Reset";
/** 추천 [적용] 확인 창 글 — 서버 저장분에서 지워질 수(못 받은 칸 = 0). */
export async function applyResetText(projectId: string): Promise<string> {
const [structures, pipes, plusStations] = await Promise.all([
pageStructures(projectId).then(
(list) => list.structures?.length ?? 0,
() => 0,
),
pagePipes(projectId).then(
(list) => (list.saved ? (list.pipe_points?.length ?? 0) : 0),
() => 0,
),
fetchSavedPlusStations(projectId).then(
(list) => list.length,
() => 0,
),
]);
return routeResetConfirmText({ structures, pipes, plusStations });
}
+24 -3
View File
@@ -14,10 +14,16 @@
min-height: 0;
}
/* 도구줄 — 지도 왼쪽 위(공용 `ui-map-tools` · 넘치면 가로 밀기 · 빈 자리는 지도 조작 통과) · [?] 안내 판은 그 아래. */
/* 도구줄 — 지도 왼쪽 위(공용 `ui-map-tools` · 넘치면 가로 밀기 · 빈 자리는 지도 조작 통과) · [?] 안내 판은 그 아래 ·
공용 겹 줄(624-3)은 그 아래 줄. */
.b05-routeedit__tools {
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-8);
pointer-events: none;
top: var(--spacing-16);
right: var(--spacing-12);
left: var(--spacing-16);
@@ -334,8 +340,8 @@ html.is-bm-route .b05-routeedit__tools {
display: none;
}
/* 분석 지형이 숨으면 앞 구분선도(390-1) · 스냅 끔이면 간격 고르기 흐림(390-2). */
.ui-toolbar__divider:has(+ .b05-routeedit__terrain[hidden]) {
/* 설계 지형 칩이 숨으면 앞 구분선도(390-1 · 626-1) · 스냅 끔이면 간격 고르기 흐림(390-2). */
.ui-toolbar__divider:has(+ [data-act="terrain"][hidden]) {
display: none;
}
@@ -466,3 +472,18 @@ html.is-bm-route .b05-routeedit__tools {
color: var(--color-text-secondary);
line-height: 1.35;
}
/* 공용 지도 겹 범례(624-3) — 상태 글판 맨 위 한 줄 통째 */
.b05-routeedit__maplegend {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
pointer-events: auto;
}
.b05-routeedit__maplegend[hidden],
.b05-routeedit__info.is-collapsed > .b05-routeedit__maplegend {
display: none;
}
@@ -1,504 +0,0 @@
/* 노선 추천 모달(계획서 23-2) — 알약 넷 · 미리 보기 지도 · 비중 · 제약 칸 · 비교값.
색은 전부 테마 토큰을 쓴다(밝음 · 어두움). 종단 보기 화면 위에 뜬다(모달 하나 · 345-4). */
/* 좌측 「노선 추천」 칸(345-4) — 「현재 추천: ○○」 + [노선 추천] [초기화]. */
.b05-routeedit__rec-now {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-route__rec-actions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
/* 곡선 라벨 패널 · 페이지 막들 위로 — 모달과 뒤 막이 함께 올라간다. */
.ui-modal__backdrop.b05-routerec-backdrop {
z-index: calc(var(--z-modal, 1000) + 2);
}
/* 오른쪽 설명 칸(계획서 48-2) · 옵션 칸을 지도 안으로 옮긴 만큼 지도를 넓힘(PLAN 77-4). */
.ui-modal.b05-routerec {
width: min(1560px, 97vw);
--modal-max: 92vh;
font-size: var(--text-body-sm);
}
.b05-routerec__body {
gap: var(--spacing-12);
font-size: var(--text-body-sm);
}
/* 왼쪽 = 기존 칸 넷(알약 · 지도 · 결과 줄 · 바닥) · 오른쪽 = 세로 구분선 + 설명 칸. */
.ui-modal.b05-routerec .b05-routerec__body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
align-content: start;
column-gap: var(--spacing-16, 16px);
}
.b05-routerec__body > :not(.b05-routerec__help) {
grid-column: 1;
}
/* 설명 칸 — 왼쪽 높이를 따라가고(제 글 길이로 창을 키우지 않음) 넘치면 제 안에서 굴림. */
.b05-routerec__help {
grid-column: 2;
grid-row: 1 / span 4;
contain: size;
overflow-y: auto;
padding-left: var(--spacing-16, 16px);
border-left: 1px solid var(--color-border);
color: var(--color-text-body);
font-size: var(--text-body-sm);
line-height: 1.55;
}
.b05-routerec__help h3 {
margin: var(--spacing-12) 0 var(--spacing-4, 4px);
color: var(--color-text-heading, var(--color-text-body));
font-size: var(--text-body);
font-weight: 700;
}
.b05-routerec__help h3:first-child {
margin-top: 0;
}
.b05-routerec__help-lead {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
}
.b05-routerec__help-item {
margin-bottom: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-inputs);
}
/* 고른 갈래 — 알약과 같은 강조색 테두리. */
.b05-routerec__help-item.is-on {
border-color: var(--color-primary);
background: var(--color-surface);
}
.b05-routerec__help-item h4 {
margin: 0 0 2px;
font-size: var(--text-body);
font-weight: 700;
}
.b05-routerec__help-item.is-on h4 {
color: var(--color-primary);
}
/* 「고른 갈래」 표시 — 지금 알약으로 고른 갈래의 쪽에만 보임. */
.b05-routerec__help-picked {
display: none;
margin-left: var(--spacing-4, 4px);
font-size: var(--text-caption, 11px);
font-weight: 600;
}
.b05-routerec__help-item.is-on .b05-routerec__help-picked {
display: inline;
}
/* 쪽 넘김 — ◀ n / 전체 ▶ */
.b05-routerec__help-pager {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-12);
margin-bottom: var(--spacing-8);
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.b05-routerec__help-item[hidden] {
display: none;
}
.b05-routerec__help-item p,
.b05-routerec__help dd {
margin: 2px 0;
}
.b05-routerec__help-item ul {
margin: 2px 0;
padding-left: 1.1em;
}
.b05-routerec__help-summary {
font-weight: 600;
}
.b05-routerec__help-caution {
color: var(--color-text-secondary);
}
.b05-routerec__help dl {
margin: 0;
}
.b05-routerec__help dt {
margin-top: var(--spacing-8);
font-weight: 600;
}
.b05-routerec__help dd {
color: var(--color-text-secondary);
}
/* 좁은 폭 — 설명 칸은 아래로(가로 구분선 · 제 높이 한도 안에서 굴림). */
@media (max-width: 1000px) {
.ui-modal.b05-routerec .b05-routerec__body {
grid-template-columns: minmax(0, 1fr);
}
.b05-routerec__help {
grid-column: 1;
grid-row: auto;
contain: none;
max-height: 280px;
padding-top: var(--spacing-12);
padding-left: 0;
border-top: 1px solid var(--color-border);
border-left: none;
}
}
/* 알약 — 하나만 고른 상태(라디오 묶음). 다섯 알약 한 줄(좁은 폭은 두 칸 · PLAN 77-1). */
.b05-routerec__pills {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--spacing-8);
}
/* 좁은 폭 — 두 줄을 지키려 글자를 줄이고, 글자가 잘릴 만큼 좁으면(560px 이하) 두 칸 네 줄. */
@media (max-width: 720px) {
.b05-routerec__pill {
padding-inline: 4px;
font-size: var(--text-body-sm);
}
}
@media (max-width: 560px) {
.b05-routerec__pills {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.b05-routerec__pill {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b05-routerec__pill:disabled {
cursor: progress;
}
/* 제목줄 오른쪽 위 [적용] · [닫기](공용 겉틀 닫기 단추 자리 · PLAN 77-3). */
.b05-routerec__actions {
display: flex;
gap: var(--spacing-8);
}
.b05-routerec__main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-12);
align-items: start;
}
.b05-routerec__mapbox {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
/* 미리 보기 지도 — 캔버스 칸(높이 고정). 회전 단추는 편집 창 것(`.b05-routeedit__spin`)과 같은 모양. */
.b05-routerec__mapwrap {
position: relative;
height: 460px;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
}
.b05-routerec__map {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.b05-routerec__maphint {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
/* 범례 = 부품 「범례」(553-2) · 창을 연 노선은 지도처럼 흐림 */
.b05-routerec__legend .is-current {
opacity: 0.55;
}
/* 옵션 겹창 — 지도 왼쪽 위 작은 칸(회전 단추는 오른쪽 위 · PLAN 77-4). 지도 끌기를 막지 않게 제 칸만. */
.b05-routerec__side {
position: absolute;
top: var(--spacing-8);
left: var(--spacing-8);
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 232px;
max-height: calc(100% - 2 * var(--spacing-8));
overflow: auto;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
box-shadow: var(--shadow-sm);
color: var(--color-text-body);
font-size: var(--text-caption);
}
/* 비중 — 세로 막대 셋이 나란히(값 위 · 이름 아래). 슬라이더가 세로로 선다. */
.b05-routerec__weights {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
.b05-routerec__weights[hidden] {
display: none;
}
.b05-routerec__bars {
display: flex;
gap: var(--spacing-8);
}
.b05-routerec__bar {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: center;
gap: var(--spacing-4, 4px);
}
.b05-routerec__bar input[type="range"] {
width: 28px;
height: 90px;
margin: 0;
writing-mode: vertical-lr;
direction: rtl; /* 위로 올릴수록 큰 값 */
accent-color: var(--color-primary);
}
/* 값 칸 = 부품 입력 단 32(553-2) — 슬라이더 옆에서 숫자를 직접 넣는다. 합 100 을 못 맞추면 빨간 테두리. */
.b05-routerec__num.ui-input {
width: 100%;
min-width: 3.2em;
padding-inline: 4px;
font-weight: 600;
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
}
.b05-routerec__num::-webkit-outer-spin-button,
.b05-routerec__num::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.b05-routerec__num:disabled {
opacity: 0.6;
}
.b05-routerec__num.is-invalid {
border-color: var(--color-danger);
box-shadow: 0 0 0 1px var(--color-danger);
}
/* 저절로 정해진 비중은 고정 단추가 눌린 채 꺼져(잠김 표시) — 색은 공용 꼴(`ui-btn--glass`)을 쓴다. */
.b05-routerec__lock.is-auto {
opacity: 0.8;
border-style: dashed;
}
.b05-routerec__bar > span {
color: var(--color-text-secondary);
}
.b05-routerec__sum {
text-align: center;
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;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-8);
}
.b05-routerec__criteria label,
.b05-routerec__target {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5em 1.5em;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-body);
}
/* 제약 칸 = 부품 입력 단 32(553-2) — 자리 · 오른쪽 맞춤만 */
.b05-routerec__criteria input.ui-input,
.b05-routerec__target input.ui-input {
width: 100%;
text-align: right;
}
.b05-routerec__criteria input::placeholder,
.b05-routerec__target input::placeholder {
color: var(--color-text-secondary);
}
.b05-routerec__side .ui-btn {
align-self: flex-start;
}
/* 종단 반영 · 재계산 한 줄(421) */
.b05-routerec__row {
display: flex;
gap: var(--spacing-8);
}
.b05-routerec__lines {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
line-height: 1.5;
}
/* 같은 노선 알림 아래 흐린 글. */
.b05-routerec__lines .is-dim {
color: var(--color-text-secondary);
font-size: var(--text-caption);
opacity: 0.8;
}
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
.b05-routerec__lines .is-warn {
color: var(--color-warning);
font-weight: 600;
}
.b05-routerec__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-8);
}
.b05-routerec__state {
flex: 1 1 12em;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
/* 좁은 폭 — 지도 아래로 조작 칸. */
@media (max-width: 720px) {
.b05-routerec__main {
grid-template-columns: minmax(0, 1fr);
}
.b05-routerec__mapwrap {
height: 360px;
}
}
.b05-routerec__target[hidden] {
display: none;
}
/* 균형 종단(계획서 63-4) — 종단 그림 · 후보 표를 나란히(창 높이를 덜 먹게). */
.b05-routerec__balance {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--spacing-8);
align-items: start;
}
.b05-routerec__balance > [hidden] {
display: none;
}
/* 지반선 · 계획선. */
.b05-routerec__profile svg {
display: block;
width: 100%;
height: 96px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
}
.b05-routerec__profile .is-ground {
fill: none;
stroke: var(--draw-text-secondary);
stroke-width: 1.5;
}
.b05-routerec__profile .is-plan {
fill: none;
stroke: var(--draw-primary);
stroke-width: 2;
}
/* 적용될 초기 종단(476) — 종단곡선 포함 · [적용]하면 이 선이 작업본 · 초기값. */
.b05-routerec__profile .is-initial {
fill: none;
stroke: var(--map-route, #f97316);
stroke-width: 2;
stroke-dasharray: 6 3;
}
/* 그림 범례 = 부품 「범례」(553-2) */
.b05-routerec__plegend {
margin-top: var(--spacing-4);
}
/* 이 모달에서 셈한 후보 표 — 절 · 성 · 반입 · 최대 종단경사. */
/* 후보 표 = 부품 표 데이터표(553-2) — 수 칸 자리 맞춤 · 한 줄 */
.b05-routerec__cands .ui-table {
font-variant-numeric: tabular-nums;
}
.b05-routerec__cands .ui-table td {
white-space: nowrap;
}
+1 -30
View File
@@ -1,42 +1,13 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Route_Reset.ts
* 노선 초기화 = 추천 [적용](요구 289 · 493-2) — 확인 창 글 · 적용 뒤 알림 글.
* 노선 초기화 응답 꼴(요구 289 · 493-2) — 서버 `route_reset`(추천 [적용] = `as_initial` 길 · 화면 부름은 걷음 626-1).
*
* 손 고침 [저장]은 보이는 화면 그대로 저장(초기화 없음) — 초기화 기준 = 추천 [적용]만.
* 지워질 수 = 서버 저장분(서버가 지우는 것 · 응답 `route_reset.dropped` 와 같은 셈).
* ========================================================================== */
/** 서버 저장분에서 지워질 수 — 사용자 구조물 · 관 · 추가 측점(응답 `route_reset.dropped` 와 같은 칸). */
export interface RouteResetCounts {
structures: number;
pipes: number;
plusStations: number;
}
/** 서버 응답 `route_reset` — 초기화했나 · 지운 수. */
export interface RouteReset {
reset: boolean;
dropped?: { structures?: number; pipes?: number; plus_stations?: number };
}
/** 추천 [적용] 확인 창 글. */
export function routeResetConfirmText(counts: RouteResetCounts): string {
return (
`추천 노선을 [적용]하면 초기화합니다 — 구조물 ${counts.structures} · 관 ${counts.pipes} · ` +
`추가 측점 ${counts.plusStations} · 종단 · 횡단 손 입력이 지워지고 ` +
`새 노선으로 다시 셉니다(세류선 교차 관 · 측점 · 초기 종단 · 횡단). 적용할까요?`
);
}
/** 적용 뒤 알림 한 줄 — 초기화가 아니면 null. */
export function routeResetNotice(
reset: RouteReset | null | undefined,
crossing = 0,
): string | null {
if (!reset?.reset) return null;
const dropped = reset.dropped ?? {};
return (
`노선이 바뀌어 초기화했습니다 — 지움: 구조물 ${dropped.structures ?? 0} · 관 ${dropped.pipes ?? 0} · ` +
`추가 측점 ${dropped.plus_stations ?? 0} · 새 교차 관 ${crossing}`
);
}
@@ -1,91 +0,0 @@
/* 283 · 493-2 추천 [적용] = 초기화 — 진짜 TS(`logic/B05_Profile_Route_Reset` · `B05_Profile_UI_RouteReset`
* applyResetText)를 Node 로 돌림 · 서버 저장분 받기는 가짜(입력 JSON `server`).
* 입력 argv[2] JSON · 표준출력 JSON 한 벌.
*/
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file, stubs = {}) {
return makeLoader({
root: ROOT,
cache: false,
requireFor: (full) => (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request} (${file})`);
},
}).loadTs(path.join(ROOT, file));
}
const reset = loadTs("B05_Profile/logic/B05_Profile_Route_Reset.ts");
/** 서버 저장분 가짜 — 값 = 돌려줄 것 · "fail" = 받기 실패. */
const answer = (value) =>
value === "fail" ? Promise.reject(new Error("받기 실패")) : Promise.resolve(value);
async function applyText(server) {
const ui = loadTs("B05_Profile/B05_Profile_UI_RouteReset.ts", {
"../A00_Common/design/design_page_data": {
pageStructures: () => answer(server.structures),
pagePipes: () => answer(server.pipes),
},
"./B05_Profile_Api_PlusStations": { fetchSavedPlusStations: () => answer(server.plus) },
"./logic/B05_Profile_Route_Reset": reset,
});
return ui.applyResetText("p");
}
/** 493-5 — 추가 측점 저장분 기억: 저장 [127 · 163] → 서버가 [적용]으로 지움 → 다시 세운 페이지가 읽는 값. */
async function plusAfterApply(reload) {
let server = [127, 163];
const drafts = new Map();
global.window = { setTimeout: () => 0, clearTimeout: () => undefined };
global.fetch = async () => ({
ok: true,
json: async () => ({ chainages: server, stations: server.map((c) => ({ chainage_m: c })) }),
});
const plus = loadTs("B05_Profile/B05_Profile_Api_PlusStations.ts", {
"../config/config_frontend": { API_BASE_URL: "", API_TIMEOUT_MS: 1000 },
"../A00_Common/design/design_store": {
draftProjectId: () => "p",
useDesignStore: () => ({
get: (k) => drafts.get(k) ?? null,
set: (k, v) => drafts.set(k, v),
}),
},
"./B05_Profile_UI_Profile_History": { onPageHistory: () => undefined },
});
await plus.loadSavedPlusStations("p");
plus.followPlusDraft("p", { plusStations: [127, 163] }); // 노선 끌기 따라감 기억
const before = plus.plusStationsOf("p");
server = []; // 추천 [적용] — 서버가 지움 · 세션 초안 비움(clearDrafts)
drafts.clear();
if (reload) await plus.reloadSavedPlusStations("p");
await plus.loadSavedPlusStations("p"); // 다시 세운 페이지
return { before, after: plus.plusStationsOf("p"), body: plus.plusStationsPart("p").body };
}
async function main() {
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const plusApply = { fixed: await plusAfterApply(true), stale: await plusAfterApply(false) };
const texts = {};
for (const [name, server] of Object.entries(input.servers)) texts[name] = await applyText(server);
process.stdout.write(
JSON.stringify({
texts,
plusApply,
notice: reset.routeResetNotice(input.reset, 2),
noticeNone: [
reset.routeResetNotice(undefined),
reset.routeResetNotice({ reset: false, dropped: { pipes: 3 } }),
],
}),
);
}
main().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
@@ -149,38 +149,5 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
out.kindresetAfter = readState("kindreset", "p1");
out.carryCalls = [...calls];
// 추천 [적용] — as_initial · 잠금 막 · 초안 비움 · 다시 세움
useDesignStore("p1", null).set("routeDraft", draft);
writeState("pipes", [{ chainage_m: 1 }], "p1");
calls.length = 0;
const snapshot = {
planned: [
[1, 1],
[9, 9],
],
curveOn: [true, true],
curveRadius: [null, null],
};
out.recommendOk = await apply.saveRecommendedRoute(
"p1",
snapshot,
{ mode: "earthwork" },
true,
"sheet", // 509-5 — 창에서 고른 지형
);
out.recommendBody = bodies[bodies.length - 1];
out.recommendCalls = calls.filter((c) => !c.startsWith("toast:"));
out.recommendDraftsLeft = {
route: useDesignStore("p1", null).get("routeDraft"),
pipes: readState("pipes", "p1"),
};
out.rebuilt = rebuilt;
// 추천 저장 실패 — false · 오류 한 줄 · 다시 안 세움
failReplan = true;
calls.length = 0;
out.recommendFail = await apply.saveRecommendedRoute("p1", snapshot, null, false);
out.recommendFailCalls = [...calls];
out.rebuiltAfterFail = rebuilt;
console.log(JSON.stringify(out));
})();
@@ -164,9 +164,9 @@ def test_화면_고르기_배선():
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
api = (page / "B05_Profile_Api_Replan.ts").read_text(encoding="utf-8")
initial_api = (page / "B05_Profile_Api_RouteInitial.ts").read_text(encoding="utf-8")
# 509-5 — 평면은 설계 지형 이름만(둘 다 있을 때) · 고르기 · 지형 실음 = 추천 창(test_509_5)
assert 'data-slot="terrain-name" hidden' in chrome and 'data-act="terrain"' not in chrome
assert 'sources?.sources.includes("las") && sources.sources.includes("sheet")' in main
# 626-1 — 평면 도구줄 지형 칩(공용 · 고르기 = 확인창 → 설계 지형 바꿈) · 옛 이름 칩 · 추천 창 고르기는 걷음
assert 'data-slot="terrain-name"' not in chrome and "terrain: terrain.root" in chrome
assert "chrome.terrain.load(sources)" in main
assert "analysisTerrain" not in api and "analysisTerrain" not in initial_api
assert "body: JSON.stringify(request)," in initial_api
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
@@ -291,8 +291,10 @@ def test_화면_확인이_고른_지형을_실음():
page = ROOT / "B05_Profile"
apply = (page / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
# 509-5 — 지형 바꿈 = 추천 [적용]만(창 지형) · 손 [저장] 초안 = 설계 지형(null)
# 626-1 — 지형 바꿈 = 도구줄 지형 칩(`/route/design-terrain`) · 손 [저장] 초안 = 설계 지형(null)
assert "terrain: settings.terrain," in apply
assert "const settings = () => ({ ...params.snap(), terrain: null });" in apply
assert "stationSnapStep: null,\n terrain,\n" in apply # 창 지형(509-5)
assert "saveRecommendedRoute" not in apply # 옛 추천 [적용] 화면 부름 걷음
chip = (page / "B05_Profile_UI_RouteEdit_Terrain.ts").read_text(encoding="utf-8")
assert "/route/design-terrain" in chip
assert "pickedTerrain" not in main and "terrainSelect" not in main
@@ -2,57 +2,15 @@
"""283 · 493-2 · 493-5 B05 [저장] = 보이는 화면 그대로 · 초기화 기준 = 노선 추천 [적용](요구 289 · 290).
- 손 고침 [저장] — 띠 · 확인 창 · 저장 뒤 초기화 알림 · 세션 초안 통째 비움 없음(492-2 걷음 · 소스 확인).
- 추천 [적용] 확인 창 — 지워질 수 = 서버 저장분(구조물 · 관 · 추가 측점) = 적용 뒤 알림 수
(서버 `route_reset.dropped` = 저장 파일 길이) · 세션 초안은 안 셈 · 관 저장 파일 없음 = 0.
- [적용] 뒤 추가 측점 저장분 다시 받음 — 지운 측점이 목록 · [저장]에 남지 않음(493-5).
- 추천 [적용] 확인 창 · 적용 뒤 알림 · 추가 측점 다시 받음 시험은 지움 — 화면 [적용] 걷음(626-1 · 요구 530 · D8).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
SERVERS = {
"saved": {
"structures": {"structures": [{"id": "s1"}]},
"pipes": {"saved": True, "pipe_points": [{}, {}, {}]},
"plus": [120],
},
"unsaved_pipes": { # 관 저장 파일 없음 — 응답 관은 자동 제안 · 서버가 지울 것 0
"structures": {"structures": []},
"pipes": {"saved": False, "pipe_points": [{}, {}]},
"plus": [],
},
"fail": {"structures": "fail", "pipes": "fail", "plus": "fail"},
}
RESET = {"reset": True, "dropped": {"structures": 1, "pipes": 3, "plus_stations": 1}}
@pytest.fixture(scope="module")
def ts(tmp_path_factory):
source = tmp_path_factory.mktemp("s283") / "in.json"
source.write_text(json.dumps({"servers": SERVERS, "reset": RESET}), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_283_route_reset_confirm.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
ROOT = Path(__file__).resolve().parents[2]
def _read(*parts: str) -> str:
@@ -79,63 +37,3 @@ def test_띠_걷음():
logic = _read("B05_Profile", "logic", "B05_Profile_Route_Reset.ts")
for gone in ("ROUTE_RESET_BAND", "draftShapeChanged", "askRouteReset"):
assert gone not in logic
# ── 추천 [적용] 확인 창 = 서버가 지울 수 ─────────────────────────────────────
def test_적용_확인창_서버_저장분_수(ts):
text = ts["texts"]["saved"]
assert text.startswith("추천 노선을 [적용]하면 초기화")
for part in ("구조물 1", "관 3", "추가 측점 1", "종단 · 횡단 손 입력"):
assert part in text
assert text.endswith("적용할까요?")
def test_적용_확인창_수_적용뒤_알림_수_같음(ts):
dropped = RESET["dropped"]
text, notice = ts["texts"]["saved"], ts["notice"]
for name, key in (("구조물", "structures"), ("관", "pipes"), ("추가 측점", "plus_stations")):
assert f"{name} {dropped[key]} " in text and f"{name} {dropped[key]}" in notice
def test_관_저장파일_없음_0_받기실패_0(ts):
assert "구조물 0 · 관 0 · 추가 측점 0" in ts["texts"]["unsaved_pipes"]
assert "구조물 0 · 관 0 · 추가 측점 0" in ts["texts"]["fail"]
def test_적용_확인창_배선():
initial = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Initial.ts")
assert "confirmText: () => applyResetText(projectId)" in initial
ui = _read("B05_Profile", "B05_Profile_UI_RouteReset.ts")
assert "useDesignStore" not in ui # 세션 초안 안 셈
# ── 적용 뒤 알림 ─────────────────────────────────────────────────────────────
def test_적용뒤_알림_dropped(ts):
assert ts["notice"] == (
"노선이 바뀌어 초기화했습니다 — 지움: 구조물 1 · 관 3 · 추가 측점 1 · 새 교차 관 2"
)
assert ts["noticeNone"] == [None, None]
# ── 493-5 [적용] 뒤 화면 초안 = 새 초기값 ────────────────────────────────────
def test_적용뒤_추가측점_저장분_다시받음(ts):
fixed, stale = ts["plusApply"]["fixed"], ts["plusApply"]["stale"]
assert fixed["before"] == [127, 163]
assert fixed["after"] == [] and fixed["body"] is None # [저장]이 지운 측점을 다시 안 보냄
assert stale["after"] == [127, 163] # 대조 — 다시 안 받으면 옛 저장분이 남음(고치기 전)
def test_적용_배선_추가측점_다시받기():
src = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Apply.ts")
body = src[src.index("export async function saveRecommendedRoute(") :]
assert (
body.index("clearDrafts(projectId)")
< body.index("await reloadSavedPlusStations(projectId)")
< body.index("await rebuildAfterRouteSave(projectId)")
)
@@ -1,4 +1,4 @@
"""346-3 화면 — [노선 반영] 없앰 · [저장]이 노선부터 · 추천 [적용] = as_initial 저장 · 저장 뒤 초안 넘김.
"""346-3 화면 — [노선 반영] 없앰 · [저장]이 노선부터 · 저장 뒤 초안 넘김(추천 [적용] 화면 부름 = 626-1 걷음).
노선 이음(`B05_Profile_UI_RouteEdit_Apply`)은 Node 헬퍼 `helper_346_3_route_save.cjs` 가 서버 · 화면
창구만 가짜로 두고 돌림 · 서버 길 시험은 `test_346_3_route_save.py` · 저장 차례(노선 먼저 · 안 바뀌면
@@ -86,31 +86,6 @@ def test_초안_넘김_차례(out):
assert any("상단측 손 고침 1곳" in c for c in out["carryCalls"]) # 요구 274 — 새 지형이 정함
def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
assert out["recommendOk"] is True
body = out["recommendBody"]
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
assert body["draft"]["terrain"] == "sheet" # 창 지형 = 설계 지형(509-5)
# 잠금 막 하나(472-6 6-B — 저장 직전 저장 관 떠 두기는 서버 한 본문 몫 · 추천은 초안을 비우니 안 받음)
assert out["recommendCalls"] == [
"overlay:on",
"replan",
"commit",
"grid:forget", # 새 설계 지형 격자로(509-2)
"overlay:off",
] # 초안 전 시설 버림
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
def test_추천_저장_실패는_한_줄_다시_안_세움(out):
assert out["recommendFail"] is False
errors = [c for c in out["recommendFailCalls"] if c.startswith("toast:error:")]
assert len(errors) == 1 and "체인 실패" in errors[0]
assert out["rebuiltAfterFail"] == 1
def test_노선_반영_단추_없앰_배선():
chrome = _read("B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts")
assert 'data-act="apply"' not in chrome and "노선 반영</button>" not in chrome
@@ -123,9 +98,8 @@ def test_노선_반영_단추_없앰_배선():
assert "carryAfterRouteSave" not in save and "saveRouteDraft" not in save
server = _read("B06_Section/B06_Section_Router_DesignSave.py")
assert server.index('step("route"') < server.index('step("settings"')
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
at = initial.index("onApply: (picked) => {")
assert "saveRecommendedRoute(" in initial[at : at + 900]
# 626-1 — 추천 [적용] 화면 부름 걷음(서버 `as_initial` 길 · 시험은 남김)
assert "saveRecommendedRoute" not in apply
replan = _read("B05_Profile/B05_Profile_Api_Replan.ts")
assert "body: JSON.stringify(body)" in replan and "as_initial?: boolean;" in replan
@@ -143,8 +117,6 @@ def test_넘김_알림은_결과_한_줄에_합침():
def test_노선_반영_글자_없음():
recommend = _read("B05_Profile/B05_Profile_UI_Panel_Recommend.ts")
assert "노선 반영" not in recommend # 칸 [초기화](「다시 계산은 [저장]에서」 글) 지움(418)
for rel in (
"B05_Profile/B05_Profile_UI_RouteEdit.ts",
"B05_Profile/B05_Profile_UI_RouteEdit_History.ts",
-9
View File
@@ -24,15 +24,6 @@ def test_초기화_확인_글():
assert "recomputation takes" not in text and "without recomputation" in text
def test_노선_추천_창_글():
recommend = _read("B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts")
assert "편집 창에 놓입니다" not in recommend
assert "[적용]하면 바로 저장 · 새 초기값이 됩니다." in recommend
help_ = _read("B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts")
assert "[확인]" not in help_ and "아래 띠" not in help_ and "편집 창" not in help_
assert "[적용]하면 이 종단이 그대로 첫 계획선" in help_
def test_노선_편집_다른_옛_글():
edits = _read("B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts")
assert "[확인] 때 반영" not in edits and "[저장] 때 반영" in edits
@@ -74,10 +74,6 @@ def test_b05_열기_예약_모달_진행_배선() -> None:
assert "await run_precompute(project_id)" in source
api = (ROOT / "B05_Profile/B05_Profile_Api_RouteInitial.ts").read_text(encoding="utf-8")
assert "/route/initial/progress" in api
initial = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts").read_text(encoding="utf-8")
assert "progress: () => fetchPrecomputeProgress(projectId)" in initial
modal = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert "미리 셈하는 중 ${p.done}/${p.total}" in modal
def test_모달이_여는_갈래_먼저(monkeypatch) -> None:
@@ -10,7 +10,6 @@
import json
import math
import re
from pathlib import Path
from shapely.geometry import LineString, Point
@@ -107,12 +106,3 @@ def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
assert _ensure_planned_initial(root, RADIUS) is None
assert {p: p.read_bytes() for p in before} == before
assert load_station_snap(root) is False
def test_apply_saves_station_snap_on() -> None:
text = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert re.search(
r"routeDraftOf\(nodes,\s*\{\s*\.\.\.settings,\s*stationSnap: true,"
r"\s*stationSnapStep: null,\s*terrain,\s*\}\)",
text,
)
@@ -9,7 +9,6 @@ TS 셈은 `helper_390_5_snap_step_ops.cjs`(Node) · 파이썬 짝 거울 · 저
import json
import random
import re
import subprocess
import sys
from pathlib import Path
@@ -225,5 +224,3 @@ def test_서버_측점_셈이_저장_간격을_씀() -> None:
assert "draft ? draft.stationSnapStep : saved?.station_snap_step" in stage
assert "stationSnapStep: step === 10 ? 10 : null" in stage # 옛 초안 0.1 · 1 m = null(505-2)
assert "saved: () => plan" in page and "...snapOptions(inputs.route, plan)" in page
apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert re.search(r"stationSnap: true,\s*stationSnapStep: null", apply) # 추천 = 측점 No.
+2 -3
View File
@@ -28,9 +28,8 @@ def _read(rel: str) -> str:
def test_노선_추천_칸에_초기화_단추_없음():
part = _read("B05_Profile/B05_Profile_UI_Panel_Recommend.ts")
assert 'data-act="reset"' not in part and "resetButton" not in part
assert part.count('data-act="recommend" disabled') == 1
# 626-1 — 「노선 추천」 칸 통째 걷음
assert not (ROOT / "B05_Profile/B05_Profile_UI_Panel_Recommend.ts").exists()
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
assert "resetButton" not in initial and "initialRouteSnapshot" not in initial
assert "initial_route" not in _read("B05_Profile/B05_Profile_Api_Replan.ts")
@@ -46,16 +46,3 @@ def test_옛_답은_서버_수_그대로_줄_없음() -> None:
assert (
old["over"] == 3 and old["violations"] == "" and old["ground"] == "" and old["ends"] == ""
)
def test_창에_줄_두_개와_도움말() -> None:
rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert '<div data-line="ground" hidden></div>' in rec
assert '<div class="is-warn" data-line="ends" hidden></div>' in rec
assert 'line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])' in rec
assert "const ends = endsLine(view);" in rec
help_ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend_Help.ts").read_text(encoding="utf-8")
for name in ('name: "원지반 기울기(참고)",', 'name: "시종점 높이 차",'):
assert name in help_ts
assert "이보다 가파른 구간을 만들지 않음" not in help_ts
assert "「기준 초과」 수와 같음" in help_ts
+1 -27
View File
@@ -32,36 +32,10 @@ def test_종단_반영_값_정하기() -> None:
assert out["readBack"] is False # 캐시에 남아 다시 열 때 되살림
def test_창_단추와_재계산() -> None:
rec = _read("B05_Profile_UI_RouteEdit_Recommend.ts")
assert "측점 맞춤" not in rec and "snapOn" not in rec and "unsnapped" not in rec
assert 'toggleButton("종단 반영", profileOn,' in rec
assert "let profileOn = params.withProfile;" in rec
toggle = rec[rec.index('toggleButton("종단 반영"') :][:200]
assert "profileOn = active;" in toggle and "void run();" in toggle # 누르면 바로
assert "rerun.before(profileToggle);" in rec
assert "rerun.disabled = busy || invalid.size > 0;" in rec # 늘 켜짐
assert 'rerun.addEventListener("click", () => void run(true));' in rec
assert "const withProfile = profileOn;" in rec
assert "params.onApply(picked);" in rec # 476 — 확인 창 뒤 미리 본 그 후보
def test_요청과_적용에_종단_반영() -> None:
init = _read("B05_Profile_UI_RouteEdit_Initial.ts")
assert "with_profile: withProfile," in init
assert "...(recompute ? { recompute: true } : {})," in init
assert "withProfile: choice?.withProfile ?? base.withProfile ?? true," in init
on_apply = init[init.index("onApply: (picked) => {") :][:900]
assert "with_profile: choiceWithProfile(picked)," in on_apply
assert on_apply.count("choiceWithProfile(picked)") == 2 # 저장 칸 · 균형 종단
# 626-1 — 추천 창 요청 · [적용] 걷음 · [저장]은 지금 추천의 종단 반영 그대로
assert "withProfileOf(" not in init
assert "with_profile: choiceWithProfile(now)" in init # [저장]에도 실음
api = _read("B05_Profile_Api_RouteInitial.ts")
assert "recompute?: boolean;" in api
def test_도움말_글() -> None:
help_ts = _read("B05_Profile_UI_RouteEdit_Recommend_Help.ts")
assert "「측점 맞춤」을 끄면" not in help_ts and "끔 = 옮기기 전 선" not in help_ts
assert 'name: "재계산",' in help_ts and 'name: "측점 맞춤(늘)",' in help_ts
assert "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두)" in help_ts
@@ -49,21 +49,3 @@ def test_그림_선() -> None:
assert 'class="is-plan" d="M6.0 90.0L300.0 62.0L594.0 62.0"' in out["svgOn"]
assert "is-initial" not in out["svgOld"] # 옛 답 = 지반 · 균형만
assert "is-plan" not in out["legendOff"] and "적용될 초기 종단" in out["legendOff"]
def test_모달_배선과_확인_글() -> None:
rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert '<div data-line="initial" hidden></div>' in rec
assert "profileSvg(balance, initialShape)" in rec
assert 'line("profile").hidden = !balance && !initialShape;' in rec
apply = rec[rec.index('applyButton.addEventListener("click"') :][:400]
assert (
'showConfirmDialog(await params.confirmText(), "적용")' in apply
) # 492-2 — [저장]과 같은 글 · 수
assert apply.index("showConfirmDialog") < apply.index("params.onApply(picked);")
reset = (B05 / "logic" / "B05_Profile_Route_Reset.ts").read_text(encoding="utf-8")
assert (
"횡단 손 입력이 지워지고 " in reset and "새 노선으로 다시 셉니다" in reset
) # 요구 277 · 284
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
assert ".b05-routerec__profile .is-initial" in css and "var(--map-route" in css
@@ -1,32 +0,0 @@
# -*- coding: utf-8 -*-
"""508-2 ② B05 추천 노선 창 [재계산] — 저장본 없음(409) 안내 줄 옆 · 칸(제약 · 비중) 바꾼 뒤만 보임.
505-1 · 509-5 규칙 그대로 — 접근 = 저장본만 · 사용자가 누른 셈만 그 자리.
./venv/Scripts/python.exe -m pytest resources/tester/test_508_2_recommend_rerun.py -q
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
REC = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Recommend.ts"
def test_보임_규칙() -> None:
ts = REC.read_text(encoding="utf-8")
assert "rerun.hidden = !(missing || dirty);" in ts
assert "if (missing) stateEl.after(rerun);" in ts # 안내 줄(상태 줄) 옆
assert "else profileToggle.after(rerun);" in ts # 칸 바꿈 = 종단 반영 오른쪽(지금처럼)
run = ts.split("async function run(recompute = false)")[1].split("pills.forEach")[0]
assert "missing = false;" in run.split("try {")[0] # 새 셈마다 지움
assert "missing = message.includes(NOT_PREPARED_HEAD);" in run
assert 'rerun.addEventListener("click", () => void run(true));' in ts
def test_안내_글_머리_서버와_같음() -> None:
ts = REC.read_text(encoding="utf-8")
head = re.search(r'const NOT_PREPARED_HEAD = "([^"]+)";', ts).group(1)
py = (ROOT / "B05_Profile" / "B05_Profile_Router_RouteInitial.py").read_text(encoding="utf-8")
message = re.search(r'^NOT_PREPARED = "([^"]+)"', py, re.M).group(1)
assert message.startswith(head)
+3 -3
View File
@@ -49,8 +49,8 @@ def test_적용으로_지형이_바뀌면_격자_버림() -> None:
grid = _ts("B05_Profile/B05_Profile_Api_TerrainGrid.ts")
forget = grid.split("export function forgetDesignTerrainGrid", 1)[1][:200]
assert "loaded.delete(projectId)" in forget and "ready.delete(projectId)" in forget
apply = _ts("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
recommend = apply.split("export async function saveRecommendedRoute", 1)[1]
assert recommend.index("forgetDesignTerrainGrid(projectId)") < recommend.index(
# 626-1 — 지형 바꿈 = 도구줄 지형 칩(옛 추천 [적용] 대신)
chip = _ts("B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts")
assert chip.index("forgetDesignTerrainGrid(projectId)") < chip.index(
"await rebuildAfterRouteSave(projectId)"
)
@@ -126,26 +126,12 @@ def _ts(name: str) -> str:
return (ROOT / "B05_Profile" / name).read_text(encoding="utf-8")
def test_창_지형_고르기_배선() -> None:
modal = _ts("B05_Profile_UI_RouteEdit_Recommend.ts")
assert 'data-act="terrain"' in modal and "data-terrain-row hidden" in modal
assert 'params.terrains.includes("las") && params.terrains.includes("sheet")' in modal
change = modal.split('terrainSelect.addEventListener("change"', 1)[1][:300]
assert "void run();" in change # 바꾸면 그 지형 저장본(없으면 안내 · [재계산])
assert "recompute,\n terrain,\n" in modal # 서버에 창 지형
initial = _ts("B05_Profile_UI_RouteEdit_Initial.ts")
assert "terrain: params.terrains()?.design" in initial # 처음 = 설계 지형
assert "return { ...normalizeRouteInitial(" in initial and "terrain };" in initial
assert "picked.terrain ?? null," in initial # [적용] = 창 지형
api = _ts("B05_Profile_Api_RouteInitial.ts")
assert "terrain?: AnalysisTerrain;" in api and "body: JSON.stringify(request)," in api
def test_평면_지형_고르기_없음_이름만() -> None:
chrome = _ts("B05_Profile_UI_RouteEdit_Chrome.ts")
main = _ts("B05_Profile_UI_RouteEdit.ts")
assert 'data-act="terrain"' not in chrome and 'data-slot="terrain-name"' in chrome
assert "terrainName.textContent = `지형 ${" in main
# 626-1 — 평면 도구줄 지형 칩(공용) · 바꾸기 = 확인창 → 설계 지형(`_Terrain`)
assert "terrain: terrain.root" in chrome and 'data-slot="terrain-name"' not in chrome
assert "chrome.terrain.load(sources);" in main
assert "terrainSelect" not in main and "setAnalysisTerrain" not in main
assert "routeedit-terrain" not in (ROOT / "A00_Common/b_page_state.ts").read_text(
encoding="utf-8"
+2 -4
View File
@@ -15,7 +15,6 @@ THEME = ROOT / "ui_template" / "ui_template_theme.css"
DRAW_CSS = [
"B05_Profile/B05_Profile_UI_Style.css",
"B05_Profile/B05_Profile_UI_Style_RouteEdit.css",
"B05_Profile/B05_Profile_UI_Style_RouteRecommend.css",
"B05_Profile/B05_Profile_UI_Style_Table.css",
"B06_Section/B06_Section_UI_Style.css",
"B06_Section/B06_Section_UI_Style_Cross_Areas.css",
@@ -30,8 +29,7 @@ DRAW_READS = [
("B05_Profile/B05_Profile_UI_RouteEdit_Render.ts", '"--draw-surface"'),
("B05_Profile/B05_Profile_UI_RouteEdit_Render.ts", '"--draw-text-secondary"'),
("B05_Profile/B05_Profile_UI_RouteEdit_Render.ts", '"--draw-danger"'),
("B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts", '"--draw-primary"'),
("B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts", '"--draw-text-secondary"'),
("B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts", '"--draw-map-sketch"'), # 626-1 추천 보기
("B05_Profile/B05_Profile_UI_Viewer.ts", '"--draw-surface-raised"'),
("B04_PreProcess/B04_PreProcess_UI_Camera.ts", '"--draw-surface-raised"'),
("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts", '"--draw-map-sketch"'),
@@ -45,7 +43,7 @@ DRAW_READS = [
]
NO_COLOR_READS = [
"B05_Profile/B05_Profile_UI_RouteEdit_Render.ts",
"B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts",
"B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts",
"B05_Profile/B05_Profile_UI_Viewer.ts",
"B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts",
]
@@ -332,7 +332,9 @@ def test_screen_defs() -> None:
) # 586-2 캔버스 그리기 따로
page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint
legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8")
legend += (ROOT / "A00_Common" / "map" / "map_legend.ts").read_text("utf-8") # 624-3 범례 줄 공용
legend += (ROOT / "A00_Common" / "map" / "map_legend.ts").read_text(
"utf-8"
) # 624-3 범례 줄 공용
for key, color in (
("forestRoadFile", "forest-road-file"),
("landslideZones", "landslide-zone"),
+1 -4
View File
@@ -18,10 +18,7 @@ def test_하한_기본은_0_5():
def test_B05_는_하한_인자를_안_줌():
for rel in (
"B05_Profile/B05_Profile_UI_RouteEdit.ts",
"B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts",
):
for rel in ("B05_Profile/B05_Profile_UI_RouteEdit.ts",):
assert "minScale" not in read(rel), rel
+9 -2
View File
@@ -38,9 +38,16 @@ def test_toolbar_table_d03_same_as_before_plus_terrain() -> None:
["terrain"],
], d03
b05 = {k for g in _toolbar_rows() for k, p in g if "b05" in p}
assert {"contour-minor", "footprint", "station-snap", "snap-step", "terrain"} <= b05
assert {
"contour-minor",
"footprint",
"station-snap",
"snap-step",
"recommend",
"terrain",
} <= b05
tools = _read(ROOT / "ui_template" / "ui_template_map_tools.ts")
for key in {k for g in _toolbar_rows() for k, _ in g} - {"snap-step", "terrain"}:
for key in {k for g in _toolbar_rows() for k, _ in g} - {"snap-step", "recommend", "terrain"}:
assert f'"{key}": {{' in tools or f"{key}: {{" in tools, key # 아이콘 · 이름 = MAP_TOOLS
+55
View File
@@ -0,0 +1,55 @@
"""PLAN 624-3 — B05 평면도에 공용 지도 부품(도구줄 표 · 겹 줄 · 범례 · 지형 칩)(요구 530 · 결정 A② · A③ · A⑦ · A⑨).
- 도구줄 = 공용 표 「b05」 · 아래 줄 = 공용 겹 줄 · 범례 = 공용 `legendRows`.
- 노선 띠 300 m 자르기 = B05 자기 칸(등고선 · 하천)만 · 그 밖 겹 = 안 자르는 둘째 GL 칸.
- B05 화면 파일은 D03 파일을 안 부름 · 큰 파일은 안 늘어남.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
read = lambda path: path.read_text(encoding="utf-8") # noqa: E731
def test_도구줄_표_겹_줄_범례() -> None:
chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts")
assert 'mapToolGroups("b05", {' in chrome
assert "toolRows.append(mapTools.root, layers.bar)" in chrome
assert "info.prepend(layers.legend)" in chrome
layers = read(B05 / "B05_Profile_UI_RouteEdit_Layers.ts")
assert 'page: "b05"' in layers and "legendRows(" in layers and "mapLayerSpecs(" in layers
def test_띠_자르기는_등고선_하천만() -> None:
gl = read(B05 / "B05_Profile_UI_RouteEdit_Gl.ts")
assert "container.style.clipPath" in gl # B05 자기 칸(등고선 · 하천 · 1 m)
assert 'gl.setVisible("contour", own.contour)' in gl
assert 'gl.setVisible("sheets", own.stream)' in gl
assert "layers.follow(meta, normalizer, toScreen" in gl
layers = read(B05 / "B05_Profile_UI_RouteEdit_Layers.ts")
assert "clipPath" not in layers # 공용 겹 칸은 안 자름
assert 'const OWN: readonly MapLayerKey[] = ["contour", "stream"]' in layers
def test_처음_겹_숨긴_겹() -> None:
src = read(ROOT / "A00_Common" / "map" / "map_layer_list.ts")
b05 = re.search(r"b05: \{(.*?)\},", src, re.S).group(1)
assert 'hide: ["bands", "forestRoadFile"]' in b05 and 'on: ["contour", "stream"]' in b05
def test_D03_안_부름_파일_한도() -> None:
for name in (
"B05_Profile_UI_RouteEdit_Layers.ts",
"B05_Profile_UI_RouteEdit_Gl.ts",
"B05_Profile_UI_RouteEdit_Chrome.ts",
"B05_Profile_UI_RouteEdit_RecPreview.ts",
"B05_Profile_UI_RouteEdit_Terrain.ts",
):
src = read(B05 / name)
assert "D03_PlanAnalysis" not in src, name
assert len(src.splitlines()) <= 700, name
assert len(read(B05 / "B05_Profile_UI_RouteEdit.ts").splitlines()) <= 694
d03 = read(ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_Api.ts")
assert 'export * from "../A00_Common/map/map_layer_api";' in d03
@@ -0,0 +1,122 @@
"""PLAN 626-1 — 노선 추천 미리보기 · [적용] 걷음 · 지형 바꿈 = 지형 칩(요구 530 · 결정 D1 · D2 · D7 · D8).
- 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 화면 부름 없음(서버 길 · 시험은 남김).
- 미리보기 = 저장본 넷(영선 · 토공 · 기울기 · 비중) 하나만 · 끔 · 다시 셈(`recompute`) 없음.
- 지형 칩 → `POST /route/design-terrain` — 노선 그대로(옛 = 새 선) · 초기값 폴더 안 씀 · 같은 지형 = 그대로.
"""
import asyncio
import re
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile import B05_Profile_Router_TerrainSwitch as switch
from B06_Section import B06_Section_Router_DesignSave as design_save
from common_util import common_util_save_guard as guard
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
read = lambda path: path.read_text(encoding="utf-8") # noqa: E731
def test_옛_추천_모달_칸_적용_걷음() -> None:
for name in (
"B05_Profile_UI_RouteEdit_Recommend.ts",
"B05_Profile_UI_RouteEdit_Recommend_Help.ts",
"B05_Profile_UI_RouteEdit_Recommend_Map.ts",
"B05_Profile_UI_Panel_Recommend.ts",
"B05_Profile_UI_Style_RouteRecommend.css",
):
assert not (B05 / name).exists(), name
assert "route-recommend" not in read(B05 / "B05_Profile_UI_Panel_Sections.ts")
assert "recommend" not in read(ROOT / "A00_Common" / "design" / "design_view_profile.ts")
# 화면 어디서도 as_initial 을 안 부름(서버 길은 남김)
for path in B05.glob("B05_Profile_UI_*.ts"):
assert "as_initial" not in read(path).replace("`as_initial`", ""), path.name
assert "as_initial" in read(B05 / "B05_Profile_Router_Replan.py")
def test_미리보기_넷_하나만_저장본만() -> None:
src = read(B05 / "B05_Profile_UI_RouteEdit_RecPreview.ts")
modes = re.search(r"const MODES = \[(.*?)\]", src, re.S).group(1)
assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"]
assert "recompute" not in src and "createPickMenu({" in src and 'offLabel: "끔"' in src
assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7)
chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts")
assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome
menu = read(ROOT / "ui_template" / "ui_template_context_menu.ts")
assert "export function createPickMenu(" in menu and "aria-checked" in menu
def test_지형_칩_확인창_새_길() -> None:
src = read(B05 / "B05_Profile_UI_RouteEdit_Terrain.ts")
assert "askConfirm({" in src and "/route/design-terrain" in src
assert "chip.set(toChip(design))" in src # 취소 · 실패 = 되돌림
assert "rebuildAfterRouteSave(projectId)" in src
def _run(monkeypatch, now: str, want: str):
calls: dict = {}
line = [[0.0, 0.0], [10.0, 0.0]]
async def paths(_pid):
return Path("root"), "stored"
async def recompute(*args):
calls["args"] = args
return {"route_id": 7}
async def rows(*_a, **_k):
return {}
async def restore(*_a):
calls["restored"] = True
class Pool:
def acquire(self):
class Ctx:
async def __aenter__(self):
return object()
async def __aexit__(self, *_):
return False
return Ctx()
async def params(*_a):
return {}
def storage(root, scope):
calls["scope"] = scope
return SimpleNamespace(discard=lambda: None)
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_planned_line", lambda _root: line)
monkeypatch.setattr(replan, "_recompute", recompute)
monkeypatch.setattr(design_save, "_restore", restore)
monkeypatch.setattr(guard, "snapshot_rows", rows)
monkeypatch.setattr(guard, "snapshot_storage", storage)
monkeypatch.setattr(switch, "get_db_pool", lambda: Pool())
monkeypatch.setattr(switch, "get_surface_confirmation_params", params)
monkeypatch.setattr(switch, "design_terrain", lambda _p: now)
body = switch.DesignTerrainRequest(terrain=want)
result = asyncio.run(switch.switch_route_design_terrain(uuid4(), body))
return result, calls, line
def test_지형_바꿈_노선_그대로_초기값_안씀(monkeypatch) -> None:
result, calls, line = _run(monkeypatch, "las", "sheet")
assert result["changed"] is True and result["design"] == "sheet"
_pid, _root, _stored, balanced, terrain, lines, initial = calls["args"]
assert balanced is None and terrain == "sheet" and initial is False
assert lines == (line, line) # 계획고 옮김 없음 · 노선 안 씀
assert guard.INITIAL_SCOPE[0] not in calls["scope"]
assert "restored" not in calls
def test_같은_지형이면_그대로(monkeypatch) -> None:
result, calls, _ = _run(monkeypatch, "sheet", "sheet")
assert result == {"status": "success", "design": "sheet", "changed": False}
assert "args" not in calls and "scope" not in calls
+22 -61
View File
@@ -12,6 +12,7 @@ Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌
import json
import os
import re
import subprocess
import pytest
@@ -169,26 +170,19 @@ def test_미리_보기_지도_선(results):
def test_도구_막대는_노선_추천_하나만():
"""[노선 추천] 하나 · 추천 묶음(로직 단추 · 슬라이더 · 제약 칸) 없음 · 「연 상태로」 없음. 그 묶음은
모달(`_Recommend`)로 옮겼다. 345-4 — [노선 추천] · [초기화] · 현재 추천은 편집 머리에서 좌측
「노선 추천」 칸(`_Panel_Recommend`)으로 옮김."""
"""626-1 — 추천 = 평면 도구줄 아이콘 하나(미리보기 · `_RecPreview`) · 옛 모달 · 좌측 칸 걷음 ·
「연 상태로」 · [초기화] 없음."""
page = os.path.join(HERE, "..", "..", "B05_Profile")
chrome = open(os.path.join(page, "B05_Profile_UI_RouteEdit_Chrome.ts"), encoding="utf-8").read()
for gone in ('data-act="recommend"', 'data-act="reset"', "data-rec-now"):
for gone in ('data-act="reset"', "data-rec-now"):
assert gone not in chrome
assert "연 상태로" not in chrome and "history-reset" not in chrome
part = open(os.path.join(page, "B05_Profile_UI_Panel_Recommend.ts"), encoding="utf-8").read()
assert part.count('data-act="recommend" disabled') == 1 # 단추 하나
assert 'data-act="reset"' not in part # 칸 [초기화] 지움(418)
assert chrome.count("recommend: preview.root") == 1 # 도구줄 아이콘 하나
assert not os.path.exists(os.path.join(page, "B05_Profile_UI_Panel_Recommend.ts"))
initial = open(
os.path.join(page, "B05_Profile_UI_RouteEdit_Initial.ts"), encoding="utf-8"
).read()
assert "data-criteria" not in initial and "data-weight" not in initial
recommend = open(
os.path.join(page, "B05_Profile_UI_RouteEdit_Recommend.ts"), encoding="utf-8"
).read()
for name in ("follow", "earthwork", "gentle", "weighted"):
assert f'"{name}"' in recommend
def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results):
@@ -202,55 +196,44 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results)
def test_노선_추천_다듬기_23_3():
"""구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위."""
"""도구줄 차례 = 공용 표 「b05」(624-3) · 스냅 간격 = [측점 스냅] 오른쪽 · 지형 칩 맨 오른쪽."""
page = os.path.join(HERE, "..", "..", "B05_Profile")
def read(name):
return open(os.path.join(page, name), encoding="utf-8").read()
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
# 366-2 · 568-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 측점 · 기울기 · 절/성토선 · 거리 재기 | 측점 스냅 · 간격 |
# 설계 지형 이름(맨 오른쪽) — 공용 지도 도구줄 묶음 차례([?] 는 hint 가 맨 앞에 붙임)
bar = chrome[
chrome.index("createMapTools({") : chrome.index('classList.add("b05-routeedit__tools")')
]
bar = chrome[chrome.index("createMapTools({") : chrome.index("mapTools.hint!")]
order = [
"hint: { lines: HINT_LINES",
"groups: [",
'groups: mapToolGroups("b05", {',
'mapToolButton("undo"',
'mapToolButton("redo"',
"],",
"tools, // 부른 쪽 단추([3D])",
'"rotate-ccw",',
'"rotate-cw",',
"view3d: tools[0]", # 부른 쪽 단추([3D])
'mapToolButton("contour-minor"',
'mapToolButton("stations"', # 381-2 [측점] [기울기] = 절/성토선 왼쪽
'mapToolButton("stations"',
'mapToolButton("slopes"',
'mapToolButton("footprint"',
'mapToolButton("measure"',
"],",
'mapToolButton("station-snap"',
"snapStep,", # 390-2 스냅 간격 = [측점 스냅] 오른쪽
"],", # 390-1 [측점 스냅] | 설계 지형 이름(509-5)
"[terrainName],",
'"snap-step": snapStep,',
"recommend: preview.root,",
"terrain: terrain.root,",
]
at = 0
for mark in order:
at = bar.index(mark, at) + 1
assert 'class="b05-routeedit__terrain" data-slot="terrain-name" hidden' in chrome
table = open(
os.path.join(HERE, "..", "..", "A00_Common", "map", "map_toolbar.ts"), encoding="utf-8"
).read()
keys = re.findall(r'key: "([\w-]+)"', table[table.index("MAP_TOOLBAR") :])
assert keys.index("station-snap") + 1 == keys.index("snap-step")
assert keys[-2:] == ["recommend", "terrain"]
assert "b05-route__view-separator" not in chrome # 나눔선 = 공용 도구줄
# 분석 지형이 숨으면 앞 구분선도 숨김 · 스냅 끔이면 간격 고르기 흐림
style = read("B05_Profile_UI_Style_RouteEdit.css")
assert ".ui-toolbar__divider:has(+ .b05-routeedit__terrain[hidden])" in style
assert '.ui-toolbar__divider:has(+ [data-act="terrain"][hidden])' in style
assert '[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step' in style
assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄(추천 모달 CSS 는 남음)
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
assert f"{name}(" in shared
css = read("B05_Profile_UI_Style_RouteRecommend.css")
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
assert "writing-mode: vertical-lr" in css
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄
def test_비중_고정_합_100_규칙(results):
@@ -300,28 +283,6 @@ def test_추천_갈래_이름_알약_다섯_순서(results):
}
# 짧은 노선 · 흙 균형은 뺐다
assert six["removedLogics"] == [False, False]
page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts")
with open(page, encoding="utf-8") as handle:
text = handle.read()
start = text.index("const PILLS")
pills = text[start : text.index("] as const", start)]
order = [
pills.index(f'"{name}"')
for name in ("follow", "gradeline", "earthwork", "gentle", "weighted")
]
assert order == sorted(order) # 알약 순서 · 다섯(PLAN 77-1)
for gone in ("safe", "crossings", "cost"):
assert f'"{gone}"' not in pills
assert '"short"' not in pills and '"balance"' not in pills
assert (
"grid-template-columns: repeat(5"
in open(
os.path.join(
HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Style_RouteRecommend.css"
),
encoding="utf-8",
).read()
) # 네 칸 두 줄
def test_옛_저장값_short_balance는_추적으로_읽음(results):
+2 -3
View File
@@ -59,8 +59,8 @@ def results():
def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results):
assert results["sameNodes"] == {"cross": True, "back": True}
# 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만.
# 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 지움 · 345-4 노선 추천)
assert results["childCount"] == 1 + 3 + 2
# 아래 고정 영역 + 종단 칸 둘 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 지움 · 626-1 노선 추천 걷음)
assert results["childCount"] == 1 + 2 + 2
def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results):
@@ -80,7 +80,6 @@ def test_보기마다_제_칸만_보인다(results):
assert views["profile"]["shownTitles"] == [
"구조물 배치",
"페이지 설정",
"노선 추천",
]
assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
assert views["back"]["shownTitles"] == views["profile"]["shownTitles"]
@@ -33,9 +33,6 @@ def test_공용_모달_위_고정():
# 고르기 창 = 공용 큰 창 곁값(147-2)
assert "--modal-max: 94vh;" in _block(css, ".ui-modal.ui-modal--wide")
assert "wide: true," in _text("ui_template/ui_template_item_picker.ts")
assert "--modal-max: 92vh;" in _block(
_text("B05_Profile/B05_Profile_UI_Style_RouteRecommend.css"), ".ui-modal.b05-routerec"
)
assert "margin-top: 0;" in _block(
_text("M02_MasterTemplete/M02_MasterTemplete_Drawing.css"), ".m02-pop > .ui-modal"
)
+11
View File
@@ -117,6 +117,17 @@
color: var(--color-danger);
}
/* 아이콘 고르기 메뉴(626-1) — 고른 항목 앞 ✓ · 안 고른 항목은 같은 폭 비움 */
.ui-pick-menu__pop .ui-row-menu__item::before {
content: "✓";
display: inline-block;
width: 1.25em;
visibility: hidden;
}
.ui-pick-menu__pop .ui-row-menu__item[aria-checked="true"]::before {
visibility: visible;
}
/* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */
:is(.ui-row-menu, .ui-row-menu__btn, .ui-row-menu__item)[hidden] {
display: none !important;
+85
View File
@@ -12,6 +12,7 @@
* ========================================================================== */
import { createIconButton } from "./ui_template_button";
import type { IconName } from "./ui_template_icons";
import "./ui_template_context_menu.css";
import { showConfirmDialog } from "./ui_template_modal";
@@ -296,3 +297,87 @@ export function createRowMenu(items: RowMenuItem[]): HTMLElement {
wrap.append(trigger, pop);
return wrap;
}
/* =============================================================================
* 부품 「아이콘 고르기 메뉴」(PLAN 626-1 · 요구 530 · 지도 도구줄 단추 하나 + 아래 판)
* 꼴 = 아이콘 단추(32 · 고른 것 있으면 눌림) → 아래 판 = 항목 하나만 고름(✓) + 맨 끝 「끔」
* 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래
* 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 disabled)
* 쓰는 법 = `createPickMenu({ icon: "compass", title, items: [{ value, label }], offLabel: "끔", onPick })`
* → { root, set(value | null), value() } · `onPick` 은 누를 때만(set 은 안 부름)
* 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu)
* ========================================================================== */
export interface PickMenu {
root: HTMLButtonElement;
set: (value: string | null) => void;
value: () => string | null;
}
export function createPickMenu(opts: {
icon: IconName;
title: string;
items: Array<{ value: string; label: string; title?: string }>;
offLabel: string;
onPick: (value: string | null) => void;
}): PickMenu {
let current: string | null = null;
const root = createIconButton({ icon: opts.icon, title: opts.title, size: 32, pressed: false });
root.setAttribute("aria-haspopup", "menu");
root.setAttribute("aria-expanded", "false");
const pop = document.createElement("div");
pop.className = "ui-row-menu__pop ui-pick-menu__pop";
pop.setAttribute("role", "menu");
pop.hidden = true;
const rows = [...opts.items, { value: "", label: opts.offLabel, title: undefined }].map(
(item) => {
const b = document.createElement("button");
b.type = "button";
b.className = "ui-row-menu__item";
b.textContent = item.label;
if (item.title) b.title = item.title;
b.dataset.value = item.value;
b.setAttribute("role", "menuitemradio");
b.addEventListener("click", () => {
setOpen(false);
set(item.value || null);
opts.onPick(current);
});
pop.append(b);
return b;
},
);
const entry: MenuEntry = {
close: () => setOpen(false),
contains: (target) => target instanceof Node && (root.contains(target) || pop.contains(target)),
};
function setOpen(open: boolean): void {
if (open) {
document.body.append(pop);
const at = root.getBoundingClientRect();
Object.assign(pop.style, {
position: "fixed",
top: `${at.bottom + 4}px`,
left: `${at.left}px`,
right: "auto",
});
}
pop.hidden = !open;
root.setAttribute("aria-expanded", String(open));
if (open) return enterMenu(entry);
leaveMenu(entry);
pop.remove();
}
function set(value: string | null): void {
current = value;
root.setAttribute("aria-pressed", String(value !== null));
for (const row of rows)
row.setAttribute("aria-checked", String((row.dataset.value || null) === value));
}
set(null);
root.addEventListener("click", (e) => {
e.stopPropagation();
setOpen(pop.hidden === true);
});
return { root, set, value: () => current };
}