feat(B05): 계획노선 편집 창 종단 띠 — 노드를 놓을 때마다 지금 노선 종단 · [확인] = 띠에 보인 종단(75-1)

- 지도 아래 한 줄 띠: 원지반선 · 계획선 · 측점 눈금 · 켬/끔 단추(추천 창 「종단 반영」과 한 벌)
- 켬 = 서버 새 통로 /route/profile-preview 가 [확인]과 같은 폴리라인(같은 R · CSV 자릿수)으로 균형 종단 셈 · 끔 = /route/elevations 지반선만
- 갱신은 놓을 때 · 되돌리기 · 추천 적용 · 켬/끔 때만 · 최신 응답만 채택 · 기다리는 동안 옛 그림 흐림
- [확인]은 띠 응답을 기다린 뒤 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용 — 추천을 손으로 고쳐도 켬이면 균형 종단

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-07 19:45:23 +09:00
co-authored by Claude Opus 5.5
parent 31fa43b372
commit 3c97d7c32d
12 changed files with 386 additions and 20 deletions
+16
View File
@@ -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<RouteReplanResp
REPLAN_TIMEOUT_MS,
);
}
/** 지금 노드의 **균형 종단**(PLAN 75-1) — [확인]과 같은 폴리라인 · 같은 셈. 파일은 안 쓴다.
* 답의 `profile` 은 추천 창과 같은 꼴(`parseBalance` 로 편다). */
export async function fetchProfilePreview(
projectId: string,
vertices: RouteReplanVertex[],
criteria: unknown,
): Promise<unknown> {
const payload = await requestJson<{ profile: unknown }>(
`/projects/${projectId}/route/profile-preview`,
{ method: "POST", body: JSON.stringify({ vertices, criteria }) },
120000,
);
return payload.profile;
}
+53 -1
View File
@@ -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}
+22
View File
@@ -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<HTMLElement>("[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 : "노선을 읽지 못했습니다.";
}
@@ -29,8 +29,9 @@ export interface RouteApplyParams {
initial: {
current: () => unknown;
keep: () => void;
profiled: (nodes: ReturnType<RouteApplyParams["nodes"]>) => boolean;
};
/** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */
profileShown: () => Promise<boolean>;
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
@@ -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(),
},
),
);
@@ -90,6 +90,14 @@ export function createRouteEditChrome(): RouteEditChrome {
</span>
</div>
</div>
<div class="b05-routeedit__strip" data-strip>
<div class="b05-routeedit__strip-head">
<button type="button" class="ui-btn ui-btn--ghost" data-strip-toggle aria-pressed="true"
title="켬 = 계획선은 지금 노선의 균형 종단(확인도 이것으로) · 끔 = 지반선만(계획고 = 지반고)">종단 반영 켬</button>
<span data-strip-note>종단을 읽는 중…</span>
</div>
<div class="b05-routeedit__strip-graph" data-strip-graph></div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__side"></div>`;
@@ -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<RouteEditSnapshot, "planned" | "curveOn" | "curveRadius">) => 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; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
@@ -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<Vertex>; criteria: unknown };
}
export interface ProfileStrip {
/** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용 · 켬/끔) — 다시 묻는다. */
refresh: () => void;
/** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지
* 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 켬/끔 값. */
settle: () => Promise<boolean>;
}
export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
const { root, projectId, profileMemory } = params;
const toggle = root.querySelector<HTMLButtonElement>("[data-strip-toggle]")!;
const note = root.querySelector<HTMLElement>("[data-strip-note]")!;
const graph = root.querySelector<HTMLElement>("[data-strip-graph]")!;
let seq = 0;
/** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 켬/끔이면 다시 묻지 않는다. */
let lastKey = "";
/** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */
let pending: Promise<void> = Promise.resolve();
let shownOn: boolean | null = null;
async function groundOnly(line: ReadonlyArray<Vertex>) {
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<Vertex>,
criteria: unknown,
): Promise<void> {
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;
},
};
}
@@ -147,6 +147,8 @@ export interface RecommendParams {
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 편집 창에 놓는다. */
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", () => {
@@ -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<BalanceProfile, "stationsM" | "groundM" | "planM"> | 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 `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
${ticks ? `<path class="is-tick" d="${tickPath}" vector-effect="non-scaling-stroke" />` : ""}
<path class="is-ground" d="${path(groundM)}" vector-effect="non-scaling-stroke" />
<path class="is-plan" d="${path(planM)}" vector-effect="non-scaling-stroke" /></svg>`;
<path class="is-plan" d="${planM.length ? path(planM) : ""}" vector-effect="non-scaling-stroke" /></svg>`;
}
/** 이 모달에서 셈한 후보들 — 갈래 이름 · 절 · 성 · 반입 · 최대 종단경사 표. 비면 빈 글. */
@@ -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<Vertex>, chainages: number[]): Vertex[] {
export function pointsAt(line: ReadonlyArray<Vertex>, 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<Vertex>, chainages: number[]): Vertex[] {
});
}
function lengthOf(line: ReadonlyArray<Vertex>): number {
export function lengthOf(line: ReadonlyArray<Vertex>): 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]);
@@ -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;
}
+75
View File
@@ -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])