diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 2cd7670b0..25c80fe49 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -5,6 +5,7 @@ * GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m) * POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 * POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 + * POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠) * * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) * 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다. @@ -208,3 +209,18 @@ export async function resetRoutePlan(projectId: string): Promise { + const payload = await requestJson<{ profile: unknown }>( + `/projects/${projectId}/route/profile-preview`, + { method: "POST", body: JSON.stringify({ vertices, criteria }) }, + 120000, + ); + return payload.profile; +} diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 6dd9af48c..1178f9284 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -13,6 +13,7 @@ from typing import Any from uuid import UUID from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice @@ -192,6 +193,17 @@ async def balanced_target( 화면 값을 받지 않고 노선 정점으로 다시 셈한다(CLAUDE.md 5). 못 세우면 None(지반고 종단). """ + profile = await balanced_profile_of(project_id, project_root, points, criteria) + return None if profile is None else (profile["stations_m"], profile["plan_m"]) + + +async def balanced_profile_of( + project_id: UUID, + project_root: Path, + points: list[dict[str, float]], + criteria: Any, +) -> dict[str, Any] | None: + """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀.""" from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile @@ -228,7 +240,7 @@ async def balanced_target( profile["fill_m3"], profile["seconds"], ) - return profile["stations_m"], profile["plan_m"] + return profile async def refresh_initial_snapshot( @@ -255,3 +267,43 @@ async def refresh_initial_snapshot( logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id) except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다 logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id) + + +class ProfilePreviewRequest(BaseModel): + """편집 창 종단 띠(PLAN 75-1) — 지금 노드 · 지금 추천 제약.""" + + vertices: list[replan.RouteVertexInput] = Field(..., min_length=2) + criteria: replan.RouteInitialCriteria | None = None + + +@replan.router.post("/{project_id}/route/profile-preview", response_model=None) +async def profile_preview( + project_id: UUID, request: ProfilePreviewRequest +) -> dict[str, Any] | JSONResponse: + """지금 노드의 균형 종단 — [확인]과 같은 폴리라인(같은 R · CSV 자릿수) · 같은 셈. + + 파일 · DB 를 쓰지 않는다. 「종단 반영」 켬일 때 편집 창 띠가 노드를 놓을 때마다 부른다. + """ + from common_util.common_util_route_polyline import build_planned_polyline + + paths = await replan._project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING) + project_root, _ = paths + radius_m, _, _ = await replan._plan_criteria(project_id) + built = build_planned_polyline( + [(v.x, v.y) for v in request.vertices], + min_radius_m=radius_m, + simplify=False, + curve_flags=[bool(v.curve) for v in request.vertices], + radii=[v.radius_m for v in request.vertices], + ) + # [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로. + points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices] + profile = await balanced_profile_of(project_id, project_root, points, request.criteria) + if profile is None: + return JSONResponse( + status_code=409, + content={"status": "error", "message": "이 노선으로 균형 종단을 세우지 못했습니다."}, + ) + return {"status": "success", "project_id": str(project_id), "profile": profile} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3ea47e00d..7ccf9177c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -60,6 +60,7 @@ import { writeRouteLocks, } from "./B05_Profile_UI_RouteEdit_Lock"; import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; +import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip"; import { applyArcLocks, applyCurveLimits, @@ -225,6 +226,23 @@ export async function openRouteEditModal( projectId, planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), + onProfileChange: () => strip.refresh(), + }); + /** 지도 아래 종단 띠 — 노드를 놓을 때마다 다시 묻는다(PLAN 75-1). 셈은 `_ProfileStrip` 몫. */ + const strip = createProfileStrip({ + root: overlay.querySelector("[data-strip]")!, + projectId, + profileMemory: initial.profileMemory, + now: () => ({ + vertices: planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + line: plannedLine.length ? plannedLine : planned, + criteria: initial.current().criteria, + }), }); const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다. overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel); @@ -379,6 +397,7 @@ export async function openRouteEditModal( draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); + strip.refresh(); } /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ @@ -458,6 +477,7 @@ export async function openRouteEditModal( commit: () => { history?.commit(snapshotNow()); historyControls.sync(); + strip.refresh(); }, }); @@ -501,6 +521,7 @@ export async function openRouteEditModal( nodes: () => ({ planned, curveOn, curveRadius }), changed: () => history?.isDirty() ?? false, initial, + profileShown: strip.settle, close, onApplied, }); @@ -556,6 +577,7 @@ export async function openRouteEditModal( `${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` + curveHint(); draw(); + strip.refresh(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 0be3ed8a5..ff05796a6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -29,8 +29,9 @@ export interface RouteApplyParams { initial: { current: () => unknown; keep: () => void; - profiled: (nodes: ReturnType) => boolean; }; + /** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */ + profileShown: () => Promise; /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ close: () => void; onApplied: () => void | Promise; @@ -80,7 +81,7 @@ export function bindRouteApply(params: RouteApplyParams): void { params.close(); return; } - void runHeavy("계획노선 반영", () => + void runHeavy("계획노선 반영", async () => replanRoute( projectId, planned.map(([x, y], index) => ({ @@ -91,7 +92,8 @@ export function bindRouteApply(params: RouteApplyParams): void { })), { initial: params.initial.current(), - balanced_profile: params.initial.profiled({ planned, curveOn, curveRadius }), + // 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다. + balanced_profile: await params.profileShown(), }, ), ); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 9e458f358..a3afcd055 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -90,6 +90,14 @@ export function createRouteEditChrome(): RouteEditChrome { +
+
+ + 종단을 읽는 중… +
+
+
`; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index b48dd4f4f..2dd5d0ddb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -47,6 +47,8 @@ export interface InitialPanelParams { planMinRadiusM: () => number; /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ apply: (snapshot: RouteEditSnapshot, message: string) => void; + /** 추천 창에서 「종단 반영」을 바꿨다 — 종단 띠가 다시 묻는다. */ + onProfileChange: () => void; } export interface InitialPanel { @@ -58,8 +60,9 @@ export interface InitialPanel { weights: RouteInitialWeights | null; criteria: RouteInitialCriteria | null; }; - /** 「종단 반영」 켬으로 [적용]한 추천 노선 그대로인가 — [확인]이 균형 종단을 1차 종단으로 쓸지(PLAN 74-4). */ - profiled: (nodes: Pick) => boolean; + /** 「종단 반영」 — 추천 창 · 종단 띠가 함께 쥔다. 켬이면 [확인]이 지금 노선의 균형 종단을 + * 1차 종단으로 쓴다(추천을 손으로 고쳤어도 · PLAN 75-1). 편집 창이 사는 동안만 남는다. */ + profileMemory: { withProfile: boolean }; /** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */ tag: () => InitialChoice | null; restoreTag: (tag: unknown) => void; @@ -148,6 +151,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: opening!.planned.map(([x, y]) => [x, y]), request, + onProfileChange: params.onProfileChange, onApply: (picked) => { choice = picked; writeInitialChoice(projectId, choice); @@ -216,14 +220,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { const criteria = choice ? choice.criteriaOverrides : base.criteria; return { mode: now.logic, weights: now.weights, criteria }; }, - profiled: (nodes) => { - if (!choice?.balance) return false; // 끔 · [초기화] · 손 편집만 한 노선 - const { planned, curveOn, curveRadius } = choice.snapshot; - return ( - JSON.stringify([planned, curveOn, curveRadius]) === - JSON.stringify([nodes.planned, nodes.curveOn, nodes.curveRadius]) - ); - }, + profileMemory, tag: () => choice, restoreTag(tag) { if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts new file mode 100644 index 000000000..023645a9d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_ProfileStrip.ts + * 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 · + * 측점 눈금 한 줄. + * + * 「종단 반영」(추천 창과 같은 `profileMemory`) 켬 = 계획선은 지금 노드로 서버가 다시 셈한 균형 + * 종단(`/route/profile-preview` — [확인] 체인과 같은 셈). 끔 = 지반선만(계획고 = 지반고 · + * `/route/elevations`). [확인]도 같은 규칙을 탄다(`_Apply` 의 `balanced_profile`). + * + * 갱신은 노드를 놓을 때마다(끌기 중 아님) · 최신 요청만 채택(seq) · 기다리는 동안 옛 그림은 흐리게. + * 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다. + * ========================================================================== */ + +import { + fetchProfilePreview, + fetchRouteElevations, + type RouteReplanVertex, +} from "./B05_Profile_Api_Replan"; +import { parseBalance, profileSvg } from "./B05_Profile_UI_RouteEdit_Recommend_Profile"; +import { lengthOf, pointsAt, stationChainages } from "./B05_Profile_UI_RouteEdit_Slope"; + +type Vertex = [number, number]; + +/** 지반선만 그릴 때 측점 간격(m) — 균형 종단(서버 `STATION_M`)과 같음. */ +const STATION_M = 20; + +export interface ProfileStripParams { + root: HTMLElement; + projectId: string; + /** 추천 창 「종단 반영」과 한 벌 — 이 띠의 켬/끔 단추도 같은 값을 바꾼다. */ + profileMemory: { withProfile: boolean }; + /** 지금 편집값 — 노드(서버로) · 그려진 노선(지반선 측점) · 지금 추천 제약. */ + now: () => { vertices: RouteReplanVertex[]; line: ReadonlyArray; criteria: unknown }; +} + +export interface ProfileStrip { + /** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용 · 켬/끔) — 다시 묻는다. */ + refresh: () => void; + /** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지 + * 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 켬/끔 값. */ + settle: () => Promise; +} + +export function createProfileStrip(params: ProfileStripParams): ProfileStrip { + const { root, projectId, profileMemory } = params; + const toggle = root.querySelector("[data-strip-toggle]")!; + const note = root.querySelector("[data-strip-note]")!; + const graph = root.querySelector("[data-strip-graph]")!; + let seq = 0; + /** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 켬/끔이면 다시 묻지 않는다. */ + let lastKey = ""; + /** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */ + let pending: Promise = Promise.resolve(); + let shownOn: boolean | null = null; + + async function groundOnly(line: ReadonlyArray) { + const stationsM = stationChainages(lengthOf(line), STATION_M); + const z = await fetchRouteElevations(projectId, pointsAt(line, stationsM)); + const known = stationsM.flatMap((s, i) => (z[i] === null ? [] : [[s, z[i]!] as const])); + if (known.length < 2) throw new Error("노선 위 지반고를 읽지 못했습니다."); + return { + stationsM: known.map(([s]) => s), + groundM: known.map(([, h]) => h), + planM: [] as number[], + }; + } + + function refresh(): void { + const on = profileMemory.withProfile; + toggle.setAttribute("aria-pressed", String(on)); + toggle.textContent = `종단 반영 ${on ? "켬" : "끔"}`; + const { vertices, line, criteria } = params.now(); + const key = JSON.stringify([on, vertices, criteria]); + if (vertices.length < 2 || line.length < 2 || key === lastKey) return; + lastKey = key; + pending = draw(on, vertices, line, criteria); + } + + async function draw( + on: boolean, + vertices: RouteReplanVertex[], + line: ReadonlyArray, + criteria: unknown, + ): Promise { + const ticket = ++seq; + root.classList.add("is-waiting"); + note.textContent = on ? "균형 종단을 셈하는 중…" : "지반선을 읽는 중…"; + const started = performance.now(); + try { + const profile = on + ? parseBalance(await fetchProfilePreview(projectId, vertices, criteria)) + : await groundOnly(line); + if (ticket !== seq || !root.isConnected) return; // 그 사이 노선이 또 바뀌었다. + if (!profile) throw new Error("균형 종단을 세우지 못했습니다."); + const seconds = ((performance.now() - started) / 1000).toFixed(1); + const total = profile.stationsM[profile.stationsM.length - 1]; + graph.innerHTML = profileSvg(profile, true); + shownOn = on; + note.textContent = + `${on ? "계획선 = 균형 종단" : "지반선만(계획고 = 지반고)"} · ` + + `연장 ${total.toFixed(1)}m · 측점 ${profile.stationsM.length}개 · ${seconds}초`; + } catch (error) { + if (ticket !== seq) return; + lastKey = ""; // 실패 — 다음 번에 다시 묻는다. + shownOn = null; + graph.innerHTML = ""; + note.textContent = error instanceof Error ? error.message : "종단을 읽지 못했습니다."; + } finally { + if (ticket === seq) root.classList.remove("is-waiting"); + } + } + + toggle.addEventListener("click", () => { + profileMemory.withProfile = !profileMemory.withProfile; + refresh(); + }); + + return { + refresh, + settle: async () => { + refresh(); + await pending; + return shownOn ?? profileMemory.withProfile; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index cb06d171b..9666692eb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -147,6 +147,8 @@ export interface RecommendParams { ) => Promise; /** [적용] — 미리 본 노선을 편집 창에 놓는다. */ onApply: (choice: InitialChoice) => void; + /** 「종단 반영」을 바꿨다 — 편집 창 종단 띠가 다시 묻는다(PLAN 75-1). */ + onProfileChange?: () => void; } export function openRecommendModal(params: RecommendParams): void { @@ -517,6 +519,7 @@ export function openRecommendModal(params: RecommendParams): void { params.profileMemory.withProfile = !params.profileMemory.withProfile; dirty = preview !== null; render(); + params.onProfileChange?.(); }); rerun.addEventListener("click", () => void run()); applyButton.addEventListener("click", () => { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts index 7b3da67bf..0020daf7e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts @@ -86,8 +86,13 @@ const PROFILE_W = 600; const PROFILE_H = 96; const PAD = 6; -/** 종단 그림(SVG) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림). */ -export function profileSvg(balance: BalanceProfile | null): string { +/** 종단 그림(SVG) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림). + * `ticks` 면 아래 가장자리에 측점 눈금(100 m 마다 길게) — 편집 창 종단 띠(PLAN 75-1). + * 계획선이 비면(종단 반영 끔) 지반선만. */ +export function profileSvg( + balance: Pick | null, + ticks = false, +): string { if (!balance) return ""; const { stationsM, groundM, planM } = balance; const total = stationsM[stationsM.length - 1] || 1; @@ -101,10 +106,20 @@ export function profileSvg(balance: BalanceProfile | null): string { return `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`; }) .join(""); + const tickPath = ticks + ? stationsM + .map((s) => { + const x = (PAD + ((PROFILE_W - 2 * PAD) * s) / total).toFixed(1); + const long = Math.abs(s / 100 - Math.round(s / 100)) < 1e-6; + return `M${x} ${PROFILE_H}V${PROFILE_H - (long ? 7 : 3)}`; + }) + .join("") + : ""; return ` + ${ticks ? `` : ""} - `; + `; } /** 이 모달에서 셈한 후보들 — 갈래 이름 · 절 · 성 · 반입 · 최대 종단경사 표. 비면 빈 글. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts index ec70e16de..3f79c5cba 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts @@ -52,7 +52,7 @@ export interface SlopeLabels { } /** 측점 누가거리 — 0, 간격, 2·간격, … 그리고 종점(끝 자투리 구간도 기울기가 있다). */ -function stationChainages(total: number, intervalM: number): number[] { +export function stationChainages(total: number, intervalM: number): number[] { const out: number[] = []; for (let chainage = 0; chainage < total - 1e-6; chainage += intervalM) out.push(chainage); out.push(total); @@ -60,7 +60,7 @@ function stationChainages(total: number, intervalM: number): number[] { } /** 누가거리 목록 자리의 노선 위 점. `drawStationTicks` 와 같은 보간이다. */ -function pointsAt(line: ReadonlyArray, chainages: number[]): Vertex[] { +export function pointsAt(line: ReadonlyArray, chainages: number[]): Vertex[] { const cumulative: number[] = [0]; for (let index = 1; index < line.length; index += 1) { cumulative.push( @@ -79,7 +79,7 @@ function pointsAt(line: ReadonlyArray, chainages: number[]): Vertex[] { }); } -function lengthOf(line: ReadonlyArray): number { +export function lengthOf(line: ReadonlyArray): number { let total = 0; for (let index = 1; index < line.length; index += 1) { total += Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index d7a07783a..f0ed06744 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -437,3 +437,53 @@ background: var(--color-primary, #7c3aed); color: #fff; } + +/* 지도 아래 종단 띠(PLAN 75-1) — 원지반선 · 계획선 · 측점 눈금 한 줄. 묻는 동안 옛 그림은 흐리게. */ +.b05-routeedit__strip { + display: flex; + flex: none; + flex-direction: column; + gap: var(--spacing-4, 4px); + padding: var(--spacing-8) var(--spacing-16); + border-top: 1px solid var(--color-border); +} + +.b05-routeedit__strip-head { + display: flex; + align-items: center; + gap: var(--spacing-12); + color: var(--color-text-secondary); + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; +} + +.b05-routeedit__strip-graph svg { + display: block; + width: 100%; + height: 88px; + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + background: var(--color-surface); + transition: opacity 0.15s; +} + +.b05-routeedit__strip.is-waiting .b05-routeedit__strip-graph svg { + opacity: 0.35; +} + +.b05-routeedit__strip-graph .is-ground { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1.5; +} + +.b05-routeedit__strip-graph .is-plan { + fill: none; + stroke: var(--color-primary); + stroke-width: 2; +} + +.b05-routeedit__strip-graph .is-tick { + stroke: var(--color-text-secondary); + stroke-width: 1; +} diff --git a/resources/tester/test_75_profile_strip.py b/resources/tester/test_75_profile_strip.py new file mode 100644 index 000000000..325034fad --- /dev/null +++ b/resources/tester/test_75_profile_strip.py @@ -0,0 +1,75 @@ +"""편집 창 종단 띠 — [확인]과 같은 노선 점으로 균형 종단을 셈한다(PLAN 75-1). + +./venv/Scripts/python.exe -m pytest resources/tester/test_75_profile_strip.py -q +""" + +import asyncio +from pathlib import Path +from uuid import uuid4 + +from B05_Profile import B05_Profile_Router_Replan as replan +from B05_Profile import B05_Profile_Router_RouteInitial as initial +from common_util.common_util_route_geometry import read_planned_route_csv + +NODES = [(0.0, 0.0), (120.0, 35.0), (180.0, 140.0), (310.0, 150.5)] +RADIUS_M = 25.0 + + +def _strip_points(monkeypatch, tmp_path: Path, criteria=None) -> tuple[list, object]: + seen: dict = {} + + async def paths(_pid): + return tmp_path, "x" + + async def plan_criteria(_pid): + return RADIUS_M, 0.0, 0.0 + + async def profile_of(_pid, _root, points, crit): + seen["points"], seen["criteria"] = points, crit + return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]} + + monkeypatch.setattr(replan, "_project_paths", paths) + monkeypatch.setattr(replan, "_plan_criteria", plan_criteria) + monkeypatch.setattr(initial, "balanced_profile_of", profile_of) + request = initial.ProfilePreviewRequest( + vertices=[ + replan.RouteVertexInput(x=x, y=y, curve=i != 2, radius_m=40.0 if i == 1 else None) + for i, (x, y) in enumerate(NODES) + ], + criteria=criteria, + ) + answer = asyncio.run(initial.profile_preview(uuid4(), request)) + assert answer["profile"]["plan_m"] == [1.0, 2.0] + return seen["points"], seen["criteria"] + + +def test_띠_노선점은_확인이_읽는_CSV와_같다(monkeypatch, tmp_path: Path) -> None: + points, _ = _strip_points(monkeypatch, tmp_path) + working = tmp_path / "planned_route.csv" + replan._write_planned_polyline( + working, + NODES, + RADIUS_M, + simplify=False, + curve_flags=[True, True, False, True], + radii=[None, 40.0, None, None], + ) + stored = read_planned_route_csv(working) + assert stored is not None + assert [(p["x"], p["y"]) for p in points] == [(v.x, v.y) for v in stored.vertices] + assert len(points) > len(NODES) # 원호가 펴져 있다 + + +def test_띠는_지금_추천_제약을_싣는다(monkeypatch, tmp_path: Path) -> None: + criteria = replan.RouteInitialCriteria(max_grade_pct=12.0) + _, got = _strip_points(monkeypatch, tmp_path, criteria) + assert got.max_grade_pct == 12.0 + + +def test_확인_목표는_같은_셈의_측점_계획고(monkeypatch, tmp_path: Path) -> None: + async def profile_of(*_args): + return {"stations_m": [0.0, 20.0, 31.5], "plan_m": [5.0, 6.0, 6.5], "cut_m3": 1.0} + + monkeypatch.setattr(initial, "balanced_profile_of", profile_of) + target = asyncio.run(initial.balanced_target(uuid4(), tmp_path, [], None)) + assert target == ([0.0, 20.0, 31.5], [5.0, 6.0, 6.5])