diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 319930f89..75eb2d58b 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -487,6 +487,7 @@ export async function mountProfileView( contourIntervalM: latestResponse.surface_params.contour_interval_m, smooth: latestResponse.surface_params.smooth, tools: [view3d.modal.button], + recommend: panel.recommend, }); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. diff --git a/B05_Profile/B05_Profile_Api_TerrainGrid.ts b/B05_Profile/B05_Profile_Api_TerrainGrid.ts index ce49bbc13..647f5dcad 100644 --- a/B05_Profile/B05_Profile_Api_TerrainGrid.ts +++ b/B05_Profile/B05_Profile_Api_TerrainGrid.ts @@ -4,6 +4,7 @@ * * GET /projects/{id}/route/terrain-sources → 설계 지형 · 격자 받을 자리(`grid`) * GET /projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(ETag · IndexedDB) + * POST /projects/{id}/route/elevations → 격자를 못 쓸 때 계산 묶음 ground 단계의 서버 길 * * 주소에 manifest 서명을 붙임 — 보관함은 옛 저장본을 먼저 내므로(`fetchCachedBytes`) 지형이 * 바뀐 뒤 옛 격자로 한 번 그리는 일을 막음. DTM 아닌 표현 · 못 받음 = null → 서버 표본 길. @@ -12,6 +13,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler"; +import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround"; export interface DesignTerrainGrid { /** 이 격자의 지형 — 편집 창 분석 지형이 이것과 같을 때만 씀. */ @@ -25,6 +27,8 @@ interface TerrainSourcesPayload { } const loaded = new Map>(); +/** 받기가 끝난 격자 — 끝나기 전엔 칸이 없음(그동안 서버 길). */ +const ready = new Map(); async function download(projectId: string): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/terrain-sources`, { @@ -45,11 +49,53 @@ async function download(projectId: string): Promise { export function loadDesignTerrainGrid(projectId: string): Promise { let pending = loaded.get(projectId); if (!pending) { - pending = download(projectId).catch(() => { - loaded.delete(projectId); - return null; - }); + pending = download(projectId).then( + (value) => { + ready.set(projectId, value); + return value; + }, + () => { + loaded.delete(projectId); + return null; + }, + ); loaded.set(projectId, pending); } return pending; } + +/** 지금 쓸 수 있는 격자 — 받는 중이면 null 을 돌려주고 받기를 띄움(기다리지 않음). */ +export function designTerrainGridNow(projectId: string): DesignTerrainGrid | null { + if (!ready.has(projectId)) void loadDesignTerrainGrid(projectId); + return ready.get(projectId) ?? null; +} + +/** 한 번에 물을 수 있는 점 수 — 서버 `MAX_POINTS` 와 같은 값. */ +const ELEVATION_CHUNK = 4000; + +/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길 값은 mm 자리(응답 그대로). */ +export function groundStageDeps(projectId: string): Pick { + return { + grid: () => designTerrainGridNow(projectId), + async remote(points, terrain, signal) { + const chunks: Array> = []; + for (let i = 0; i < points.length; i += ELEVATION_CHUNK) { + chunks.push(points.slice(i, i + ELEVATION_CHUNK)); + } + const answers = await Promise.all( + chunks.map(async (chunk) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/elevations`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ points: chunk, terrain }), + signal, + }); + if (!response.ok) throw new Error(`지반고 요청 실패: HTTP ${response.status}`); + return ((await response.json()) as { z: Array }).z; + }), + ); + return answers.flat(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index d4e7cb6fd..7ee996cbf 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -23,6 +23,7 @@ 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"; @@ -71,6 +72,7 @@ interface PanelCallbacks { export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) { const { viewControls, setCorridorPending } = createViewControls(callbacks); const settings = createSettingsPart(callbacks); + const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4) const { contourInterval, gradeClass, @@ -119,11 +121,13 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · // [확정]은 한 벌이 두 보기 몫을 모아 낸다. - Object.assign(shared.parts, { settings }); + Object.assign(shared.parts, { settings, recommend }); sharedPanel(shared); return { viewControls, + /** 「노선 추천」 칸 요소 — 노선 편집기(`_RouteEdit_Initial`)에 넘긴다. */ + recommend, /** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */ structures: structures as StructuresSection, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */ diff --git a/B05_Profile/B05_Profile_UI_Panel_Recommend.ts b/B05_Profile/B05_Profile_UI_Panel_Recommend.ts new file mode 100644 index 000000000..3fa0ed2ae --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Panel_Recommend.ts @@ -0,0 +1,29 @@ +/* ============================================================================= + * B05_Profile_UI_Panel_Recommend.ts + * 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」 · + * [초기화]. 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창 · 초기값 싣기)은 편집기의 + * `_RouteEdit_Initial` 이 이 요소를 받아 건다 — 여기는 생김새만. + * ========================================================================== */ + +export interface RecommendPart { + recommendButton: HTMLButtonElement; + resetButton: HTMLButtonElement; + nowLine: HTMLElement; + /** 칸 몸 — 화면 순서대로. */ + nodes: HTMLElement[]; +} + +export function createRecommendPart(): RecommendPart { + const nowLine = document.createElement("span"); + nowLine.className = "b05-routeedit__rec-now"; + const actions = document.createElement("div"); + actions.className = "b05-route__rec-actions"; + actions.innerHTML = ` + + `; + const recommendButton = actions.querySelector('[data-act="recommend"]')!; + const resetButton = actions.querySelector('[data-act="reset"]')!; + return { recommendButton, resetButton, nowLine, nodes: [nowLine, actions] }; +} diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index 67c28ff32..4085c18fe 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -1,16 +1,18 @@ /* ============================================================================= * B05_Profile_UI_Panel_Sections.ts - * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정. + * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 노선 추천. * 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * - * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역. + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 노선 추천 > 아래 고정 영역. * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) · - * 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문). + * 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문) · 그 자리에 + * 「노선 추천」(345-4 · 편집 머리에서 옮김 · 재료 `_Panel_Recommend`). * 「경로 계산 설정」은 `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({ @@ -24,3 +26,15 @@ registerPanelSection({ return frame.root; }, }); + +registerPanelSection({ + id: "route-recommend", + title: "노선 추천", + views: ["profile"], + order: 30, + build: (ctx, view) => { + const frame = createSectionFrame(view, "노선 추천"); + frame.body.append(...part(ctx, "recommend").nodes); + return frame.root; + }, +}); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index e6ea19bb8..b2a88106c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -67,7 +67,7 @@ import { routeLocksOf, writeRouteLocks, } from "./B05_Profile_UI_RouteEdit_Lock"; -import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; +import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial"; import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip"; import { applyArcLocks, @@ -104,6 +104,8 @@ export interface RouteEditOptions { smooth?: boolean; /** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */ tools?: HTMLElement[]; + /** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */ + recommend: InitialPanelParams["controls"]; } /** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */ @@ -115,7 +117,7 @@ export async function mountRouteEditor( host: RouteEditHost, projectId: string, onApplied: () => void | Promise, - options: RouteEditOptions = {}, + options: RouteEditOptions, ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(parent, options.tools); @@ -269,8 +271,9 @@ export async function mountRouteEditor( }; /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ const initial = createInitialPanel({ - overlay, + controls: options.recommend, projectId, + planned: () => planned, planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 0148d3de1..96dabba3e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -13,7 +13,8 @@ import { createButton } from "@ui/ui_template_elements"; * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · * 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸. - * 머리 끝 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. + * 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측 + * 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`). * ========================================================================== */ /** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 @@ -53,11 +54,8 @@ export function createRouteEditChrome(

계획노선

- -