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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user