feat(B05): 계획노선 편집 창 — 초기노선 갈래 화면 틀(로직 단추 셋 · 비중 슬라이더 · 초기화 · 현재 초기값 · 비교값)

- 「예상노선으로」를 「초기화」로 — 마지막에 적용한 초기값(처음엔 예상노선 추종)으로 편집값만 되돌림 · 즉시 재계산 없앰
- 옛 「초기화」(창을 연 상태로)는 「연 상태로」
- 로직 단추 토공 최소 · 기울기 순한 노선 · 비중 계산(예상 · 토공 · 기울기 슬라이더 · 합 100 자동 · 기본 50/25/25 · [계산])
- 누르면 서버 갈래(POST /route/initial)를 불러 받은 노드를 편집값에 싣고(되돌리기 한 걸음) 결과 캐시(routeedit-initial)에 새 초기값으로 적음
- 「현재 초기값: ○○」 · 결과 비교값(연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격) · 공통 제약 기준값은 서버 값만 보임(확정 표시 없으면 「후보 · 사용자 확정 대기」)
- 다시 계산은 노선이 바뀐 뒤 [확인]에서만 — 그대로면 계산 없이 닫음 · [확인] 몸통에 초기값(initial) 실음 · [취소]는 이 창에서 바꾼 초기값도 되돌림
- 서버 길이 아직 없으면 알림만(고친 적 없는 노선의 [초기화]는 창을 연 노선으로)
- 700줄 한도 — 노드 끼우기·빼기 배선은 _Input(bindNodeSplice), 셈은 _Initial_Model
- 시험 test_b05_routeedit_initial.py (비중 합 100 · 서버 답 두 꼴 펴기 · 글 · 캐시 칸)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
This commit is contained in:
2026-09-29 09:57:03 +09:00
co-authored by Claude Opus 5.5
parent f72252a5ca
commit 2082cd98a5
12 changed files with 884 additions and 55 deletions
+3
View File
@@ -174,6 +174,9 @@ export const STATE_REGISTRY = {
scope: "project",
legacy: (p) => `b06:rock-boundary-default:${p}`,
},
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
"routeedit-initial": { bucket: "result", scope: "project" },
} as const satisfies Record<string, StateEntry>;
export type StateName = keyof typeof STATE_REGISTRY;
+8 -2
View File
@@ -76,7 +76,11 @@ export interface RouteReplanResponse {
vertex_count: number;
}
async function requestJson<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
export async function requestJson<T>(
path: string,
init: RequestInit,
timeoutMs: number,
): Promise<T> {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
@@ -121,13 +125,15 @@ export interface RouteReplanVertex {
export async function replanRoute(
projectId: string,
vertices: Array<[number, number]> | RouteReplanVertex[],
/** 함께 실어 보낼 값 — 지금은 마지막에 적용한 초기노선 갈래(`initial`, 계획서 20). */
extra: Record<string, unknown> = {},
): Promise<RouteReplanResponse> {
const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) =>
Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex,
);
return requestJson<RouteReplanResponse>(
`/projects/${projectId}/route/replan`,
{ method: "POST", body: JSON.stringify({ vertices: payload }) },
{ method: "POST", body: JSON.stringify({ ...extra, vertices: payload }) },
REPLAN_TIMEOUT_MS,
);
}
@@ -0,0 +1,54 @@
/* =============================================================================
* B05_Profile_Api_RouteInitial.ts
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
*
* POST /projects/{id}/route/initial { logic, weights? } → 노드 · 비교값 · 공통 제약 기준값
*
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
* 받은 비교값·기준값을 보이기만 한다. 서버 길은 sub3 몫이라 모양이 오면 `normalizeRouteInitial`
* (`_RouteEdit_Initial_Model`) 한 곳만 맞춘다.
* ========================================================================== */
import { requestJson } from "./B05_Profile_Api_Replan";
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추종(처음 초기값). */
export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted";
/** 비중 계산의 세 비중(%) — 합 100. */
export interface RouteInitialWeights {
expected: number;
earthwork: number;
slope: number;
}
export interface RouteInitialRequest {
logic: RouteInitialLogic;
weights?: RouteInitialWeights;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
export const ROUTE_INITIAL_MISSING =
"초기노선 갈래 서버 길이 아직 없습니다(계획서 20 · sub3 대기).";
/** 로직 하나로 초기노선을 묻는다. 답의 모양은 `normalizeRouteInitial` 이 편다. */
export async function fetchRouteInitial(
projectId: string,
request: RouteInitialRequest,
): Promise<unknown> {
try {
return await requestJson<unknown>(
`/projects/${projectId}/route/initial`,
{ method: "POST", body: JSON.stringify(request) },
INITIAL_TIMEOUT_MS,
);
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (/HTTP 40[45]|Not Found|Method Not Allowed/.test(message)) {
throw new Error(ROUTE_INITIAL_MISSING);
}
throw error;
}
}
+43 -45
View File
@@ -7,7 +7,7 @@
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**.
*
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
* [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산).
* [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`).
*
* 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은
* 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다.
@@ -39,6 +39,7 @@ import {
} from "./B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
import {
bindNodeSplice,
bindRouteEditNavigation,
handleAtScreen,
nodeAtScreen,
@@ -59,6 +60,7 @@ import {
viewPxPerMeter,
} from "./B05_Profile_UI_RouteEdit_View";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
import {
applyArcLocks,
applyCurveLimits,
@@ -93,7 +95,7 @@ const CONTOUR_LEVEL_BUDGET = 1200;
type Vertex = [number, number];
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number;
@@ -227,8 +229,16 @@ export async function openRouteEditModal(
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
overlay.remove();
};
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close);
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
const initial = createInitialPanel({
overlay,
projectId,
planMinRadiusM: () => minRadiusM,
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
});
const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다.
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel);
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel);
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
function resize(): void {
@@ -412,19 +422,16 @@ export async function openRouteEditModal(
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
}
/** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */
function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void {
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
applyEdit(message, record);
}
const historyControls = bindHistoryControls({
overlay,
getHistory: () => history,
restore: (snapshot, message) => {
planned = snapshot.planned;
curveOn = snapshot.curveOn;
curveRadius = snapshot.curveRadius;
curveLock = snapshot.curveLock;
curveArc = snapshot.curveArc;
apexLock = snapshot.apexLock;
picked = snapshot.picked;
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
},
restore: (snapshot, message) => restoreSnapshot(snapshot, message, false),
});
// ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
@@ -592,28 +599,22 @@ export async function openRouteEditModal(
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("dblclick", (event) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const segment = segmentAt(px, py);
if (segment < 0) return;
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가).");
});
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top));
if (index < 0) return;
if (planned.length <= 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
spliceNode(snapshotNow(), index);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
bindNodeSplice({
canvas,
unrotate: rotation.unrotate,
segmentAt,
nodeAt,
insert: (segment, px, py) => {
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가).");
},
remove: (index) => {
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
spliceNode(snapshotNow(), index);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
},
});
// 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용.
@@ -634,6 +635,8 @@ export async function openRouteEditModal(
busy,
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
changed: () => history?.isDirty() ?? false,
initial,
close,
onApplied,
});
@@ -655,18 +658,13 @@ export async function openRouteEditModal(
limitArcM = plan.limit_curve_length_m ?? 0;
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
planned = flat.planned;
nodeInfo = flat.nodes;
curveInfo = flat.curves;
curveOn = flat.curveOn;
curveRadius = flat.curveRadius;
curveLock = flat.curveLock;
curveArc = flat.curveArc;
apexLock = flat.apexLock;
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat);
({ nodes: nodeInfo, curves: curveInfo } = flat);
picked = -1;
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
history = createRouteEditHistory(snapshotNow());
historyControls.sync();
initial.ready(snapshotNow(), plan.edited);
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
+17 -6
View File
@@ -1,6 +1,9 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts
* 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시.
* 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시.
*
* 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면
* 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`).
*
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
@@ -9,7 +12,7 @@
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import { replanRoute } from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
@@ -20,12 +23,16 @@ export interface RouteApplyParams {
projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
changed: () => boolean;
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
initial: { current: () => unknown; keep: () => void };
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
}
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params;
@@ -46,6 +53,7 @@ export function bindRouteApply(params: RouteApplyParams): void {
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다.
showToast("노선을 다시 계산했습니다.", "success");
params.close();
await params.onApplied();
@@ -63,6 +71,11 @@ export function bindRouteApply(params: RouteApplyParams): void {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
if (!params.changed()) {
showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info");
params.close();
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
@@ -72,10 +85,8 @@ export function bindRouteApply(params: RouteApplyParams): void {
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
{ initial: params.initial.current() },
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
});
}
@@ -52,8 +52,9 @@ export function createRouteEditChrome(): RouteEditChrome {
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset">예상노선으로</button>
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>연 상태로</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
title="마지막에 적용한 초기값으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
</span>
@@ -0,0 +1,202 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화]
* · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
*
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
fetchRouteInitial,
ROUTE_INITIAL_MISSING,
type RouteInitialLogic,
type RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
criteriaLine,
DEFAULT_WEIGHTS,
metricsLine,
normalizeRouteInitial,
readInitialChoice,
rebalanceWeights,
WEIGHT_KEYS,
writeInitialChoice,
type InitialChoice,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
const WEIGHT_NAME: Record<WeightKey, string> = {
expected: "예상",
earthwork: "토공",
slope: "기울기",
};
export interface InitialPanelParams {
overlay: HTMLElement;
projectId: string;
/** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */
planMinRadiusM: () => number;
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
apply: (snapshot: RouteEditSnapshot, message: string) => void;
}
export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */
ready: (opening: RouteEditSnapshot, edited: boolean) => void;
/** [확인]에 실어 보낼 지금 초기값. */
current: () => { logic: RouteInitialLogic; weights: RouteInitialWeights | null };
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
cancel: () => void;
/** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */
keep: () => void;
}
const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot;
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const { overlay, projectId } = params;
const box = document.createElement("div");
box.className = "b05-routeedit__initial";
box.innerHTML = `
<div class="b05-routeedit__initial-row">
<button type="button" class="ui-btn ui-btn--glass" data-logic="earthwork" disabled
title="절토 · 성토가 가장 적은 노선을 서버가 셈해 초기값으로 싣습니다">토공 최소</button>
<button type="button" class="ui-btn ui-btn--glass" data-logic="gentle" disabled
title="종단기울기가 가장 순한 노선을 서버가 셈해 초기값으로 싣습니다">기울기 순한 노선</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="weights-open" disabled
aria-expanded="false" title="예상 · 토공 · 기울기 비중(합 100)으로 셈합니다">비중 계산</button>
</div>
<div class="b05-routeedit__weights" hidden>
${WEIGHT_KEYS.map(
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
<output data-weight-out="${key}"></output></label>`,
).join("")}
<div class="b05-routeedit__weights-foot">
<span data-weight-sum></span>
<button type="button" class="ui-btn ui-btn--filled" data-logic="weighted">계산</button>
</div>
</div>
<div class="b05-routeedit__initial-now"></div>
<div class="b05-routeedit__initial-line" data-line="metrics"></div>
<div class="b05-routeedit__initial-line" data-line="criteria"></div>`;
overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box);
const nowLine = box.querySelector<HTMLElement>(".b05-routeedit__initial-now")!;
const metricsEl = box.querySelector<HTMLElement>('[data-line="metrics"]')!;
const criteriaEl = box.querySelector<HTMLElement>('[data-line="criteria"]')!;
const weightsBox = box.querySelector<HTMLElement>(".b05-routeedit__weights")!;
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
const openingChoice = readInitialChoice(projectId);
let choice: InitialChoice | null = openingChoice;
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
function render(): void {
const logic = choice?.logic ?? "follow";
nowLine.textContent = `현재 초기값: ${choice ? choiceLabel(logic, choice.weights) : choiceLabel("follow", null)}`;
metricsEl.textContent =
choice && !choice.metrics
? "비교값 — 서버 답에 없습니다."
: metricsLine(choice?.metrics ?? null);
criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM());
box.dataset.logic = logic;
for (const key of WEIGHT_KEYS) {
box.querySelector<HTMLInputElement>(`[data-weight="${key}"]`)!.value = String(weights[key]);
box.querySelector<HTMLOutputElement>(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`;
}
box.querySelector<HTMLElement>("[data-weight-sum]")!.textContent =
`합 ${weights.expected + weights.earthwork + weights.slope}`;
buttons.forEach((button) => {
button.disabled = busy || !opening;
});
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
}
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */
async function run(logic: RouteInitialLogic, allowFallback = false): Promise<void> {
if (busy || !opening) return;
busy = true;
const sent = logic === "weighted" ? { ...weights } : null;
render();
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
try {
const response = await fetchRouteInitial(projectId, {
logic,
...(sent ? { weights: sent } : {}),
});
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
choice = normalizeRouteInitial(response, logic, sent);
writeInitialChoice(projectId, choice);
params.apply(
copy(choice.snapshot),
`초기값을 「${choiceLabel(logic, sent)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`,
);
} catch (error) {
const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.";
// 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다.
if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited) {
params.apply(
copy(opening),
"초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
);
} else {
showToast(message, "error");
}
} finally {
busy = false;
if (box.isConnected) render();
}
}
box.querySelectorAll<HTMLButtonElement>("[data-logic]").forEach((button) => {
button.addEventListener("click", () => void run(button.dataset.logic as RouteInitialLogic));
});
openWeights.addEventListener("click", () => {
weightsBox.hidden = !weightsBox.hidden;
openWeights.setAttribute("aria-expanded", String(!weightsBox.hidden));
});
box.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
input.addEventListener("input", () => {
weights = rebalanceWeights(weights, input.dataset.weight as WeightKey, Number(input.value));
render();
});
});
// [초기화] — 마지막에 적용한 초기값으로. 아직 없으면 예상노선 추종을 서버에 묻는다.
resetButton.addEventListener("click", () => {
if (!choice) {
void run("follow", true);
return;
}
params.apply(
copy(choice.snapshot),
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
);
});
render();
return {
ready(snapshot, wasEdited) {
opening = copy(snapshot);
edited = wasEdited;
render();
},
current: () => ({ logic: choice?.logic ?? "follow", weights: choice?.weights ?? null }),
cancel: () => writeInitialChoice(projectId, openingChoice),
keep: () => writeInitialChoice(projectId, choice),
};
}
@@ -0,0 +1,191 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial_Model.ts
* 초기노선 갈래(계획서 20)의 **셈만** — 비중 맞추기 · 서버 답 펴기 · 글 만들기 · 캐시.
* 화면 배선은 `_RouteEdit_Initial` 몫이다(여기는 DOM 을 안 만진다 — 시험이 Node 로 돈다).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RouteInitialLogic, RouteInitialWeights } from "./B05_Profile_Api_RouteInitial";
import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
type Vertex = [number, number];
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
follow: "예상노선 추종",
earthwork: "토공 최소",
gentle: "기울기 순한 노선",
weighted: "비중 계산",
};
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, slope: 25 };
export const WEIGHT_KEYS = ["expected", "earthwork", "slope"] as const;
export type WeightKey = (typeof WEIGHT_KEYS)[number];
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */
export function rebalanceWeights(
weights: RouteInitialWeights,
key: WeightKey,
value: number,
): RouteInitialWeights {
const fixed = Math.min(100, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
const [first, second] = WEIGHT_KEYS.filter((name) => name !== key);
const rest = 100 - fixed;
const pool = weights[first] + weights[second];
const firstShare = pool > 0 ? Math.round((rest * weights[first]) / pool) : Math.round(rest / 2);
const out = { ...weights, [key]: fixed, [first]: firstShare, [second]: rest - firstShare };
return { expected: out.expected, earthwork: out.earthwork, slope: out.slope };
}
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
export interface InitialMetrics {
lengthM: number | null;
maxGradePct: number | null;
meanGradePct: number | null;
cutM3: number | null;
fillM3: number | null;
overLimitCount: number | null;
offsetM: number | null;
}
/** 공통 제약 기준값 — 서버가 준 값. `confirmed` 가 거짓이면 지식DB 후보(사용자 확정 대기). */
export interface InitialCriteria {
maxGradePct: number | null;
minRadiusM: number | null;
streamOffsetM: number | null;
confirmed: boolean;
}
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
export interface InitialChoice {
logic: RouteInitialLogic;
weights: RouteInitialWeights | null;
snapshot: RouteEditSnapshot;
metrics: InitialMetrics | null;
criteria: InitialCriteria | null;
}
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
/** 여러 이름 중 먼저 있는 숫자 — 서버 낱말이 정해지기 전이라 흔한 이름을 함께 본다. */
function pick(source: Record<string, unknown>, ...names: string[]): number | null {
for (const name of names) {
const value = num(source[name]);
if (value !== null) return value;
}
return null;
}
/** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/route/plan` 과 같은 규칙으로. */
function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
if (Array.isArray(curves) && curves.length) {
const flat = flattenServerPlan(nodes as never, curves as never);
return { ...flat, picked: -1 };
}
const rows = nodes as Array<Record<string, unknown> | Vertex>;
const planned = rows.map((row): Vertex =>
Array.isArray(row) ? [row[0], row[1]] : [Number(row.x), Number(row.y)],
);
return {
planned,
curveOn: rows.map((row) => (Array.isArray(row) ? true : row.curve !== false)),
curveRadius: rows.map((row) => (Array.isArray(row) ? null : num(row.radius_m))),
curveLock: planned.map(() => null),
curveArc: planned.map(() => null),
apexLock: planned.map(() => false),
picked: -1,
};
}
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */
export function normalizeRouteInitial(
response: unknown,
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
): InitialChoice {
const body = (response ?? {}) as Record<string, unknown>;
const nodes = Array.isArray(body.nodes)
? body.nodes
: Array.isArray(body.vertices)
? body.vertices
: [];
const snapshot = snapshotOf(nodes, body.curves);
if (snapshot.planned.length < 2 || snapshot.planned.some(([x, y]) => !Number.isFinite(x + y))) {
throw new Error("서버가 낸 초기노선에 노드가 모자랍니다.");
}
const m = (body.metrics ?? {}) as Record<string, unknown>;
const c = (body.criteria ?? {}) as Record<string, unknown>;
return {
logic,
weights,
snapshot,
metrics: body.metrics
? {
lengthM: pick(m, "length_m", "total_length_m"),
maxGradePct: pick(m, "max_grade_pct", "max_slope_pct"),
meanGradePct: pick(m, "mean_grade_pct", "mean_slope_pct", "avg_grade_pct"),
cutM3: pick(m, "cut_m3", "cut_volume_m3"),
fillM3: pick(m, "fill_m3", "fill_volume_m3"),
overLimitCount: pick(m, "over_limit_count", "violation_count", "exceed_count"),
offsetM: pick(m, "offset_mean_m", "expected_offset_m", "offset_m"),
}
: null,
criteria: body.criteria
? {
maxGradePct: pick(c, "max_grade_pct", "max_slope_pct"),
minRadiusM: pick(c, "min_radius_m"),
streamOffsetM: pick(c, "stream_offset_m", "stream_clearance_m"),
confirmed: c.confirmed === true,
}
: null,
};
}
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string {
if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic];
return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.slope}`;
}
const fixed = (value: number | null, digits: number, unit: string): string =>
value === null
? "—"
: `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`;
/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */
export function metricsLine(metrics: InitialMetrics | null): string {
if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다.";
return [
`연장 ${fixed(metrics.lengthM, 1, "m")}`,
`기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`,
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
`기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`,
`이격 ${fixed(metrics.offsetM, 1, "m")}`,
].join(" · ");
}
/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string {
const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null);
const parts = [
`종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`,
`최소 R ${fixed(radius, 0, "m")}`,
`계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`,
];
const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)";
return `공통 제약 ${parts.join(" · ")}${tail}`;
}
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */
export function readInitialChoice(projectId: string): InitialChoice | null {
const saved = readState<InitialChoice>("routeedit-initial", projectId);
if (!saved || !saved.snapshot || !Array.isArray(saved.snapshot.planned)) return null;
if (!(saved.logic in LOGIC_LABEL) || saved.snapshot.planned.length < 2) return null;
return saved;
}
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
writeState("routeedit-initial", choice, projectId);
}
@@ -104,6 +104,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
canvas.addEventListener("pointercancel", stop);
}
export interface NodeSpliceParams {
canvas: HTMLCanvasElement;
/** 돌린 지도 보정 — 화면 px 를 돌리기 전 그림 px 로. */
unrotate: (px: number, py: number) => [number, number];
segmentAt: (px: number, py: number) => number;
nodeAt: (px: number, py: number) => number;
/** 선(`segment` 번째 구간) 위 그림 px 자리에 노드를 끼운다. */
insert: (segment: number, px: number, py: number) => void;
/** `index` 번째 노드를 뺀다. */
remove: (index: number) => void;
}
/** 선 두 번 클릭 = 노드 추가 · 노드 오른쪽 클릭 = 삭제. 리스너는 캔버스와 수명이 같다. */
export function bindNodeSplice(params: NodeSpliceParams): void {
const { canvas } = params;
const at = (event: MouseEvent): [number, number] => {
const rect = canvas.getBoundingClientRect();
return params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
};
canvas.addEventListener("dblclick", (event) => {
const [px, py] = at(event);
const segment = params.segmentAt(px, py);
if (segment >= 0) params.insert(segment, px, py);
});
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
const index = params.nodeAt(...at(event));
if (index >= 0) params.remove(index);
});
}
/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */
export type ScreenOf = (point: [number, number]) => [number, number];
@@ -125,6 +125,78 @@
line-height: 1;
}
/* 초기노선 갈래(계획서 20) — 회전 단추 아래 오른쪽. 로직 단추 · 비중 · 현재 초기값 · 비교값. */
.b05-routeedit__initial {
position: absolute;
top: calc(var(--spacing-12) * 2 + 34px);
right: var(--spacing-12);
z-index: 1;
display: flex;
flex-direction: column;
gap: 4px;
max-width: min(420px, 44%);
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routeedit__initial-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.b05-routeedit__initial-row .ui-btn {
padding: 4px var(--spacing-8);
font-size: var(--text-caption);
}
.b05-routeedit__initial-row [aria-expanded="true"] {
border-color: var(--color-primary);
}
.b05-routeedit__weights {
display: grid;
gap: 2px;
}
.b05-routeedit__weights label {
display: grid;
grid-template-columns: 3.5em 1fr 2.5em;
align-items: center;
gap: var(--spacing-8);
}
.b05-routeedit__weights output {
text-align: right;
color: var(--color-text-body);
}
.b05-routeedit__weights-foot {
display: flex;
align-items: center;
justify-content: space-between;
}
.b05-routeedit__weights-foot .ui-btn {
padding: 4px var(--spacing-12);
font-size: var(--text-caption);
}
.b05-routeedit__initial-now {
color: var(--color-text-body);
font-weight: 600;
}
.b05-routeedit__initial-line {
line-height: 1.45;
}
.b05-routeedit__legend {
display: inline-flex;
align-items: center;
@@ -0,0 +1,162 @@
/* 계획노선 편집 창 **초기노선 갈래**(계획서 20) 셈 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_Initial_Model.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다.
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다.
* 브라우저 전용인 저장소는 가짜로 채운다 — 셈만 본다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
_map: map,
};
}
const session = memoryStorage();
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
globalThis.fetch = () => Promise.resolve({ ok: false });
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
const { DEFAULT_WEIGHTS } = model;
// ① 비중 맞추기 — 하나를 옮기면 나머지 둘이 비율대로 나눠 합 100.
const moves = [
model.rebalanceWeights(DEFAULT_WEIGHTS, "expected", 70),
model.rebalanceWeights(DEFAULT_WEIGHTS, "earthwork", 40),
model.rebalanceWeights({ expected: 100, earthwork: 0, slope: 0 }, "expected", 60),
model.rebalanceWeights({ expected: 20, earthwork: 30, slope: 50 }, "slope", 100),
model.rebalanceWeights({ expected: 33, earthwork: 33, slope: 34 }, "earthwork", 33.4),
model.rebalanceWeights(DEFAULT_WEIGHTS, "slope", -5),
];
// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다.
const plain = model.normalizeRouteInitial(
{
nodes: [
{ x: 0, y: 0 },
{ x: 50, y: 10, curve: true, radius_m: 30 },
{ x: 100, y: 0, curve: false },
],
metrics: {
length_m: 1234.56,
max_grade_pct: 12.34,
mean_grade_pct: 6.1,
cut_m3: 1500.4,
fill_m3: 900,
over_limit_count: 2,
offset_mean_m: 3.25,
},
criteria: { max_grade_pct: 14, min_radius_m: 12, stream_offset_m: 5 },
},
"earthwork",
null,
);
const planShape = model.normalizeRouteInitial(
{
nodes: [
{ x: 0, y: 0, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
{ x: 40, y: 30, radius_m: 25, inner_angle_deg: 140, tangent_m: 9, violations: [] },
{ x: 90, y: 30, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
],
curves: [
{
apex: [40, 30],
radius_m: 25,
tangent_m: 9,
inner_angle_deg: 140,
start: [33, 25],
end: [49, 30],
node_first: 1,
node_last: 1,
violations: [],
},
],
},
"weighted",
{ expected: 60, earthwork: 20, slope: 20 },
);
let tooShort = "";
try {
model.normalizeRouteInitial({ nodes: [{ x: 1, y: 1 }] }, "gentle", null);
} catch (error) {
tooShort = error.message;
}
// ③ 글 — 현재 초기값 · 비교값 · 기준값.
const labels = {
follow: model.choiceLabel("follow", null),
weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, slope: 20 }),
metrics: model.metricsLine(plain.metrics),
metricsNone: model.metricsLine(null),
criteria: model.criteriaLine(plain.criteria, 0),
criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0),
criteriaFallback: model.criteriaLine(null, 15),
};
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
const other = model.readInitialChoice("p-2");
const keys = [...session._map.keys()];
session._map.set(
keys[0],
JSON.stringify({
logic: "nope",
snapshot: {
planned: [
[0, 0],
[1, 1],
],
},
}),
);
const badLogic = model.readInitialChoice("p-1");
model.writeInitialChoice("p-1", null);
const cleared = model.readInitialChoice("p-1");
process.stdout.write(
JSON.stringify({
moves,
plain,
planShape,
tooShort,
labels,
readBack,
other,
keys,
badLogic,
cleared,
}),
);
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증.
비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 비교값 · 기준값은 서버 값을 글로만
보이고, 확정 표시가 없으면 「사용자 확정 대기」를 붙인다. 마지막 초기값은 결과 통 프로젝트 칸.
Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_비중은_늘_합_100_정수(results):
for weights in results["moves"]:
assert list(weights) == ["expected", "earthwork", "slope"]
assert sum(weights.values()) == 100
assert all(isinstance(v, int) and 0 <= v <= 100 for v in weights.values())
def test_비중은_나머지를_지금_비율대로_나눔(results):
moves = results["moves"]
assert moves[0] == {"expected": 70, "earthwork": 15, "slope": 15}
assert moves[1] == {"expected": 40, "earthwork": 40, "slope": 20}
# 나머지가 모두 0 이면 반씩.
assert moves[2] == {"expected": 60, "earthwork": 20, "slope": 20}
assert moves[3] == {"expected": 0, "earthwork": 0, "slope": 100}
assert moves[5]["slope"] == 0
def test_노드_목록_꼴을_편집값으로(results):
snap = results["plain"]["snapshot"]
assert snap["planned"] == [[0, 0], [50, 10], [100, 0]]
assert snap["curveOn"] == [True, True, False]
assert snap["curveRadius"] == [None, 30, None]
assert snap["curveLock"] == [None, None, None]
assert snap["apexLock"] == [False, False, False]
assert snap["picked"] == -1
def test_노선계획_꼴도_같은_규칙으로(results):
shape = results["planShape"]
snap = shape["snapshot"]
assert snap["planned"] == [[0, 0], [40, 30], [90, 30]]
assert snap["curveOn"] == [False, True, False]
assert snap["curveRadius"] == [None, 25, None]
assert shape["weights"] == {"expected": 60, "earthwork": 20, "slope": 20}
assert shape["metrics"] is None and shape["criteria"] is None
def test_노드가_모자라면_싣지_않음(results):
assert "모자랍니다" in results["tooShort"]
def test_비교값과_기준값_글(results):
labels = results["labels"]
assert labels["follow"] == "예상노선 추종"
assert labels["weighted"] == "비중 계산 60/20/20"
assert labels["metrics"] == (
"연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥"
" · 기준 초과 2구간 · 이격 3.3m"
)
assert "로직을 누르면" in labels["metricsNone"]
assert labels["criteria"] == (
"공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)"
)
assert labels["criteriaConfirmed"].endswith("5.0m")
assert labels["criteriaFallback"] == (
"공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)"
)
def test_캐시는_결과_통_프로젝트_칸(results):
assert results["keys"] == ["aislo:result:routeedit-initial:p-1"]
assert results["readBack"]["logic"] == "earthwork"
assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]]
assert results["other"] is None
assert results["badLogic"] is None
assert results["cleared"] is None