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