diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 5c04a3e3f..3c73412ba 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -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), }); diff --git a/A00_Common/map/map_toolbar.ts b/A00_Common/map/map_toolbar.ts index b01ddb7cc..dbcbb2887 100644 --- a/A00_Common/map/map_toolbar.ts +++ b/A00_Common/map/map_toolbar.ts @@ -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( diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 7315ea67c..1a5b951dd 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -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 diff --git a/B05_Profile/B05_Profile_Router_TerrainSwitch.py b/B05_Profile/B05_Profile_Router_TerrainSwitch.py new file mode 100644 index 000000000..b0f9dac7c --- /dev/null +++ b/B05_Profile/B05_Profile_Router_TerrainSwitch.py @@ -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} diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 4101a9255..043d2dabc 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -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, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */ diff --git a/B05_Profile/B05_Profile_UI_Panel_Recommend.ts b/B05_Profile/B05_Profile_UI_Panel_Recommend.ts deleted file mode 100644 index f06e6fa7d..000000000 --- a/B05_Profile/B05_Profile_UI_Panel_Recommend.ts +++ /dev/null @@ -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 = ` - `; - const recommendButton = actions.querySelector('[data-act="recommend"]')!; - return { recommendButton, nowLine, nodes: [nowLine, actions] }; -} diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index 4085c18fe..cd72211b4 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -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(ctx, "recommend").nodes); - return frame.root; - }, -}); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 4299518eb..80130828c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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 { 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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index effc05e50..9f620e188 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -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 { invalidateSectionDetail(projectId); remountRoute(currentRoute()); } - -/** - * 추천 [적용] — 받은 노드로 서버 노선을 바로 저장하고(`as_initial` · 초기값 = 추천 노선) 페이지를 다시 - * 세운다. 잠금 막 = 「노선 재탐색」 자리 하나. 새 초기값에서 다시 시작하므로 초안은 비운다([초기화]와 같음). - * 실패하면 false — 서버가 옛 노선으로 되살렸고 화면 노선은 그대로. - */ -export async function saveRecommendedRoute( - projectId: string, - nodes: RouteNodes, - initial: unknown, - balancedProfile: boolean, - terrain: string | null = null, -): Promise { - const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null }; - showLoadingOverlay(); - try { - const saved = await saveRouteDraft(projectId, { - // 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장 - // 창에서 고른 지형 = 설계 지형(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(); - } -} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 776c2754a..b60a03db5 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -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( `; parent.append(overlay); - // 스냅 간격 고르기 · 설계 지형 이름 칩 — 단추 아님(도구줄에 요소로 끼움). + // 스냅 간격 고르기 — 단추 아님(도구줄에 요소로 끼움). const extras = document.createElement("div"); extras.innerHTML = ` - `; - const [snapStep, terrainName] = extras.children as unknown as [HTMLElement, HTMLElement]; + `; + 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(".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(".b05-routeedit__info")!; + info.prepend(layers.legend); const infoToggle = info.querySelector('[data-act="info-toggle"]')!; const summary = info.querySelector(".b05-routeedit__summary")!; const setInfo = (collapsed: boolean): void => { @@ -249,7 +271,8 @@ export function createRouteEditChrome( const measureButton = mapTools.button("measure")!; const busy = overlay.querySelector(".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), }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts index ebd419bd2..19c94f3d4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts @@ -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(); 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({ 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); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index c6dcf7fd7..91e2f858d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -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> = {}; - let opening: RouteEditSnapshot | null = null; - let busy = false; - /** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */ - const weightMemory = { - weights: null as RouteInitialWeights | null, - locks: { ...NO_WEIGHT_LOCKS }, - }; - /** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕). 창이 사는 동안 안 바뀐다. */ - let planRead: Promise | null = null; - - function loadPlan(): Promise { - 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 { - 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), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts new file mode 100644 index 000000000..a372b9143 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts @@ -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> = { + 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; + loaded: MapLayerKey[]; + map: boolean; + gl: Record; + 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({ + 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 = {}; + const loaded = new Set(); + const pending = new Map>(); + 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 { + 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(`[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(`[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); + 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 { + 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 => Promise.resolve(); + + /** 프로젝트 범위 도엽 — 한 번 셈 · 조각은 이름마다 한 번. */ + let sheetList: Promise | null = null; + const sheetCache = new Map>(); + async function sheetCollection(names: string[], at: VWorldMeta): Promise { + 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 { + 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, + }), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts new file mode 100644 index 000000000..f8d7e0013 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts @@ -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(); + 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 { + 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 }), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts deleted file mode 100644 index b0f03e963..000000000 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ /dev/null @@ -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 = { - 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 = { las: "라이다", sheet: "수치지형도" }; -/** 서버 `NOT_PREPARED`(409 · 505-1) 글 머리 — 이 글이면 [재계산]을 안내 줄 옆에. */ -const NOT_PREPARED_HEAD = "추천 저장본이 없습니다"; - -export interface RecommendParams { - /** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */ - criteria: RouteInitialCriteria | null; - /** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */ - defaults: Partial>; - 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; - /** 고를 수 있는 지형(509-5) — 둘이면 창에 고르기 · 처음 값 = 지금 설계 지형. */ - terrains: AnalysisTerrain[]; - terrain: AnalysisTerrain; - /** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */ - onApply: (choice: InitialChoice) => void; - /** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */ - confirmText: () => Promise; - /** 서버 미리 셈 진행(364-4) — 셈 기다리는 동안 상태줄에 붙인다. */ - progress?: () => Promise; -} - -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(); - 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(); - let ticker: number | undefined; - - let bodyEl: HTMLElement | null = null; - let map: ReturnType | 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 = ` -
- - -
-
-
-
- -
- 예상노선(원본) - 창을 연 노선 - 미리 보기 -
-
휠 = 확대 · 축소   끌기 = 이동   ↺ ↻ = 회전
-
-
-
-
-
- - -
- - -
- - - - - - - -
-
-
- -
- ${recommendHelpHtml()}`; - - // 알약 · 비중 막대 · 제약 칸 · 목표 기울기 칸은 공용 부품(createButton · createInputField · - // PF toggleButton)으로 짓는다(PLAN 117) — 글 틀은 위에 자리만 남기고 여기서 채운다. - const pillsBox = body.querySelector('[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('[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('[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 = (sel: string): T => body.querySelector(sel)!; - // [적용] · [닫기]를 제목줄 오른쪽 위로 — 공용 겉틀의 닫기 단추와 갈아 끼운다(PLAN 77-3). - const actions = q(".b05-routerec__actions"); - const applyButton = q('[data-act="apply"]'); - const closeButton = q('[data-act="close"]'); - body.parentElement?.querySelector(".ui-modal__top > button")?.replaceWith(actions); - map = createRecommendMap(q(".b05-routerec__mapwrap")); - const pills = [...body.querySelectorAll("[data-logic]")]; - const weightsBox = q(".b05-routerec__weights"); - const rerun = q('[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("[data-state]"); - const terrainSelect = q('[data-act="terrain"]'); - q("[data-terrain-row]").hidden = !choosable; - terrainSelect.value = terrain; - const line = (name: string): HTMLElement => q(`[data-line="${name}"]`); - const criteriaInputs = body.querySelectorAll("[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(`[data-weight="${key}"]`); - const num = q(`[data-weight-num="${key}"]`); - const lock = q(`[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("[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("[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 { - 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("[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("[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("[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(); - }); -} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts deleted file mode 100644 index 605d85a07..000000000 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ /dev/null @@ -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 = { - 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 ? `

종단 반영 ${entry.profile}

` : ""; - return ``; -} - -/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */ -export function recommendHelpHtml(): string { - const list = (items: Array<{ name: string; text: string }>): string => - `
${items.map((item) => `
${item.name}
${item.text}
`).join("")}
`; - return ``; -} - -/** 설명 쪽을 `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("[data-help-logic]").forEach((item) => { - item.hidden = item.dataset.helpLogic !== LOGIC_ORDER[page]; - }); - aside.querySelector("[data-help-page]")!.textContent = - `${page + 1} / ${LOGIC_ORDER.length}`; - aside.querySelector('[data-help-nav="-1"]')!.disabled = page === 0; - aside.querySelector('[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("[data-help-nav]"); - const aside = body.querySelector(".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(".b05-routerec__help"); - if (!aside) return; - aside.querySelectorAll("[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))); -} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts deleted file mode 100644 index 49138e2f5..000000000 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts +++ /dev/null @@ -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 = ` - -
- - -
`; - -export interface RecommendMap { - /** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */ - setLayers: (layers: MapLayer[]) => void; - dispose: () => void; -} - -/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */ -export function createRecommendMap(box: HTMLElement): RecommendMap { - const canvas = box.querySelector("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>): 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 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(), - }; -} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 4e0d5a6b0..921413e50 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -87,6 +87,8 @@ export interface RouteEditScene { hasMeta: boolean; /** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */ contours: RouteEditContours | null; + /** 등고선 겹을 끔(624-3 · GL 겹 줄) — 글 · 고른 가닥 덧그림도 안 냄(선은 GL 이 숨김). */ + contourHidden?: boolean; /** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */ otherSheets: ReadonlyArray; /** 고른 등고선 가닥 — 없으면 -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(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts new file mode 100644 index 000000000..716bb48c4 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts @@ -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 { + 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, + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteReset.ts b/B05_Profile/B05_Profile_UI_RouteReset.ts deleted file mode 100644 index 39d3ff5ba..000000000 --- a/B05_Profile/B05_Profile_UI_RouteReset.ts +++ /dev/null @@ -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 { - 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 }); -} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index b7887a6b9..b4d0a90cf 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -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; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css deleted file mode 100644 index 2a3cd930a..000000000 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ /dev/null @@ -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; -} diff --git a/B05_Profile/logic/B05_Profile_Route_Reset.ts b/B05_Profile/logic/B05_Profile_Route_Reset.ts index c5c771461..4597bfba0 100644 --- a/B05_Profile/logic/B05_Profile_Route_Reset.ts +++ b/B05_Profile/logic/B05_Profile_Route_Reset.ts @@ -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}` - ); -} diff --git a/resources/tester/helper_283_route_reset_confirm.cjs b/resources/tester/helper_283_route_reset_confirm.cjs deleted file mode 100644 index a71e3ff41..000000000 --- a/resources/tester/helper_283_route_reset_confirm.cjs +++ /dev/null @@ -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); -}); diff --git a/resources/tester/helper_346_3_route_save.cjs b/resources/tester/helper_346_3_route_save.cjs index 4ad5e1b32..d8628e2ad 100644 --- a/resources/tester/helper_346_3_route_save.cjs +++ b/resources/tester/helper_346_3_route_save.cjs @@ -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)); })(); diff --git a/resources/tester/test_133_1_analysis_terrain.py b/resources/tester/test_133_1_analysis_terrain.py index 87df11b00..de3f2b96a 100644 --- a/resources/tester/test_133_1_analysis_terrain.py +++ b/resources/tester/test_133_1_analysis_terrain.py @@ -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] diff --git a/resources/tester/test_133_2_design_terrain.py b/resources/tester/test_133_2_design_terrain.py index e68f390ab..7f1cd9a09 100644 --- a/resources/tester/test_133_2_design_terrain.py +++ b/resources/tester/test_133_2_design_terrain.py @@ -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 diff --git a/resources/tester/test_283_route_reset_confirm.py b/resources/tester/test_283_route_reset_confirm.py index 128b437ee..8f9f5847e 100644 --- a/resources/tester/test_283_route_reset_confirm.py +++ b/resources/tester/test_283_route_reset_confirm.py @@ -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)") - ) diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index ed4339af8..70367b701 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -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 "노선 반영" 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", diff --git a/resources/tester/test_349_old_wording.py b/resources/tester/test_349_old_wording.py index 606f8b00b..eb49acf1d 100644 --- a/resources/tester/test_349_old_wording.py +++ b/resources/tester/test_349_old_wording.py @@ -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 diff --git a/resources/tester/test_364_4_precompute_on_open.py b/resources/tester/test_364_4_precompute_on_open.py index 20f21851a..90fc281a3 100644 --- a/resources/tester/test_364_4_precompute_on_open.py +++ b/resources/tester/test_364_4_precompute_on_open.py @@ -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: diff --git a/resources/tester/test_383_1_route_station_default.py b/resources/tester/test_383_1_route_station_default.py index 2976dfb9a..441329d57 100644 --- a/resources/tester/test_383_1_route_station_default.py +++ b/resources/tester/test_383_1_route_station_default.py @@ -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, - ) diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py index bf571c14d..c920fe83a 100644 --- a/resources/tester/test_390_5_snap_step_ops.py +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -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. diff --git a/resources/tester/test_418_reset_cleanup.py b/resources/tester/test_418_reset_cleanup.py index 0d4971071..6aca9bc11 100644 --- a/resources/tester/test_418_reset_cleanup.py +++ b/resources/tester/test_418_reset_cleanup.py @@ -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") diff --git a/resources/tester/test_421_2_violation_lines.py b/resources/tester/test_421_2_violation_lines.py index 61ecd6769..922b5e149 100644 --- a/resources/tester/test_421_2_violation_lines.py +++ b/resources/tester/test_421_2_violation_lines.py @@ -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 '' in rec - assert '' 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 diff --git a/resources/tester/test_421_recommend_profile.py b/resources/tester/test_421_recommend_profile.py index 31f964a0d..c2b4734eb 100644 --- a/resources/tester/test_421_recommend_profile.py +++ b/resources/tester/test_421_recommend_profile.py @@ -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 diff --git a/resources/tester/test_476_2_recommend_initial_line.py b/resources/tester/test_476_2_recommend_initial_line.py index 87ede29cf..f548ed1dc 100644 --- a/resources/tester/test_476_2_recommend_initial_line.py +++ b/resources/tester/test_476_2_recommend_initial_line.py @@ -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 '' 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 diff --git a/resources/tester/test_508_2_recommend_rerun.py b/resources/tester/test_508_2_recommend_rerun.py deleted file mode 100644 index dcc124d27..000000000 --- a/resources/tester/test_508_2_recommend_rerun.py +++ /dev/null @@ -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) diff --git a/resources/tester/test_509_2_sheet_grid.py b/resources/tester/test_509_2_sheet_grid.py index fed62ce8e..ef2f4ac0f 100644 --- a/resources/tester/test_509_2_sheet_grid.py +++ b/resources/tester/test_509_2_sheet_grid.py @@ -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)" ) diff --git a/resources/tester/test_509_5_recommend_terrain.py b/resources/tester/test_509_5_recommend_terrain.py index eac6640ae..b2ab71452 100644 --- a/resources/tester/test_509_5_recommend_terrain.py +++ b/resources/tester/test_509_5_recommend_terrain.py @@ -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" diff --git a/resources/tester/test_551_2_draw_tokens.py b/resources/tester/test_551_2_draw_tokens.py index f05b8e632..aac0ebb1e 100644 --- a/resources/tester/test_551_2_draw_tokens.py +++ b/resources/tester/test_551_2_draw_tokens.py @@ -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", ] diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 2aabae7fa..94cd55249 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -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"), diff --git a/resources/tester/test_600_3_zoom_limit.py b/resources/tester/test_600_3_zoom_limit.py index 13c5b4b61..4c405ebc1 100644 --- a/resources/tester/test_600_3_zoom_limit.py +++ b/resources/tester/test_600_3_zoom_limit.py @@ -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 diff --git a/resources/tester/test_624_2_map_parts.py b/resources/tester/test_624_2_map_parts.py index 537de1732..f7ce7444e 100644 --- a/resources/tester/test_624_2_map_parts.py +++ b/resources/tester/test_624_2_map_parts.py @@ -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 diff --git a/resources/tester/test_624_3_b05_map.py b/resources/tester/test_624_3_b05_map.py new file mode 100644 index 000000000..1822a261d --- /dev/null +++ b/resources/tester/test_624_3_b05_map.py @@ -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 diff --git a/resources/tester/test_626_1_recommend_preview.py b/resources/tester/test_626_1_recommend_preview.py new file mode 100644 index 000000000..c611aa1a2 --- /dev/null +++ b/resources/tester/test_626_1_recommend_preview.py @@ -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 diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index 6d63e1b10..4a13fd97a 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -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): diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index e13be7c44..9a23250eb 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -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"] diff --git a/resources/tester/test_modal_top_and_add_word.py b/resources/tester/test_modal_top_and_add_word.py index 26c09d446..349695503 100644 --- a/resources/tester/test_modal_top_and_add_word.py +++ b/resources/tester/test_modal_top_and_add_word.py @@ -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" ) diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 2b7321428..8565c0dc4 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -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; diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 79d58cc86..a6fc3719a 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -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 }; +}