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