Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-09-29 19:33:10 +09:00
15 changed files with 1182 additions and 596 deletions
+2
View File
@@ -68,6 +68,8 @@ export interface RoutePlanResponse {
edited: boolean;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
initial_route?: { nodes: RoutePlanNode[]; curves: RoutePlanCurve[] };
}
export interface RouteReplanResponse {
+1 -1
View File
@@ -14,7 +14,7 @@ import { requestJson } from "./B05_Profile_Api_Replan";
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추종(처음 초기값). */
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */
export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted";
/** 비중 계산의 세 비중(%) — 합 100. */
+19
View File
@@ -267,6 +267,24 @@ def _read_curves(path: Path) -> list[dict]:
return curves if isinstance(curves, list) else []
def _initial_shape(project_root: Path, fallback: list, radius_m: float) -> dict[str, list]:
"""전처리가 만든 **초기 노선**의 노드 · 곡선 — 화면 [초기화]가 재계산 없이 놓는 자리.
수정본이 있어도 초기 폴리라인 파일은 그대로라 항상 같은 노선이 나온다.
"""
path = planned_route_initial_path(project_root)
saved_nodes = _vertices_of(_nodes_path(path))
outline = build_planned_polyline(
[(x, y) for x, y in (saved_nodes or _vertices_of(path) or fallback)],
min_radius_m=radius_m,
simplify=not saved_nodes,
)
return {
"nodes": [node.as_dict() for node in outline.nodes],
"curves": _read_curves(path) or [curve.as_dict() for curve in outline.curves],
}
def _write_planned_polyline(
path: Path,
points: list[tuple[float, float]],
@@ -516,6 +534,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"violation_count": outline.violation_count,
"edited": bool(working),
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
}
+49 -217
View File
@@ -15,7 +15,6 @@
import {
computeMapRect,
hitPreparedLayer,
metricToScreen,
pickLevelStep,
type PreparedLayer,
@@ -33,19 +32,12 @@ import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import {
buildEditedPolyline,
dragHandleTo as curveDragTo,
type EditedCurve,
type EditedNode,
} from "./B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
import {
bindNodeSplice,
bindRouteEditNavigation,
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
@@ -64,11 +56,8 @@ import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
radiusFloorM,
curveSummary,
flattenServerPlan,
shortfallCrossed,
spliceNode,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
@@ -80,14 +69,6 @@ import {
} from "./B05_Profile_UI_RouteEdit_History";
import "./B05_Profile_UI_Style_RouteEdit.css";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
const NODE_HIT_PX = 9;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11;
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
* (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로
* 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */
@@ -284,7 +265,7 @@ export async function openRouteEditModal(
pxPerMeter: pxPerMeter(),
toScreen,
uprightRad: rotation.uprightRad(),
dragging: dragNode >= 0 || dragHandle !== null,
dragging: pointer.dragging(),
}),
measure: measure.marks(),
crossWidth: crossPreview.watched(),
@@ -302,38 +283,6 @@ export async function openRouteEditModal(
viewMemory.touch(); // 회전·이동·확대 모두 다시 그리기로 끝난다 — 여기 한 곳에서 적는다.
}
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
handleAtScreen(
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
curveInfo.filter(
(entry) =>
(curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true,
),
toScreen,
px,
py,
NODE_HIT_PX + 2,
);
const nodeAt = (px: number, py: number): number =>
nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX);
const segmentAt = (px: number, py: number): number =>
segmentAtScreen(planned, toScreen, px, py, SEGMENT_HIT_PX);
/** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */
function dragHandleTo(
node: number,
which: "start" | "end",
to: Vertex,
): { apex: Vertex; radius: number } | null {
const curve = curveInfo.find((entry) => entry.node_first === node);
if (!curve) return null;
const before = planned[node - 1];
const after = planned[node + 1];
if (!before || !after) return null;
return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to);
}
/** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②).
*
* 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부
@@ -434,176 +383,59 @@ export async function openRouteEditModal(
restore: (snapshot, message) => restoreSnapshot(snapshot, message, false),
});
// ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
// ── 조작 — 노드 끌기 · 집기는 `_Pointer` 몫 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
// 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다.
let dragNode = -1;
/** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */
let dragHandle: { node: number; end: "start" | "end" } | null = null;
/** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다
* (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */
let dragMoved = false;
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const rect = canvas.getBoundingClientRect();
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (event.shiftKey || measureControls.mode()) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py);
return;
}
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
const hitNode = nodeAt(px, py);
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1;
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false;
if (hitNode >= 0) {
picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
syncCurveBar();
draw();
} else if (dragHandle) {
picked = dragHandle.node;
measure.clear();
syncCurveBar();
draw();
} else if (
// 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나
// 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다.
((): boolean => {
if (picked >= 0) {
picked = -1;
syncCurveBar();
}
return false;
})()
) {
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
const chainage = stationAtScreen(
plannedLine.length ? plannedLine : planned,
toScreen,
stationIntervalM,
px,
py,
STATION_HIT_PX,
);
if (chainage === null) return false;
void crossPreview.open(chainage);
return true;
})()
) {
/* 횡단 창이 떴다 — 더 집지 않는다. */
} else if (contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM());
if (hit !== pickedContour) {
pickedContour = hit;
status.textContent = `${routeHead()} — ${contourHint()} ${curveHint()}`;
draw();
}
}
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const node = dragHandle.node;
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
planned[node] = moved.apex;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
curveRadius[node] = Math.max(
radiusFloorM(nodeInfo[node]?.inner_angle_deg, limitRadiusM, limitArcM),
Math.round(moved.radius * 100) / 100,
);
curveOn[node] = true;
picked = node;
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
dragMoved = true;
markEdited();
syncCurveBar();
status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`;
draw();
}
return;
}
if (dragNode >= 0) {
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
const previous = planned[dragNode];
dragMoved = true;
planned[dragNode] = toMetric(px, py);
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
planned[dragNode] = previous;
markEdited();
status.textContent =
`${routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`;
draw();
return;
}
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`;
draw();
return;
}
const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
overNode >= 0
? apexLock[overNode]
? "not-allowed"
: "grab"
: handleAt(px, py)
? "grab"
: "default";
});
const endDrag = (event: PointerEvent): void => {
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
// 눌러야 한다. 놓는 순간에만 쌓는다.
if (dragMoved) {
const pointer = bindRouteEditPointer({
canvas,
status,
stationIntervalM,
state: () => ({
planned,
plannedLine,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
apexLock,
limitRadiusM,
limitArcM,
picked,
pickedContour,
contours,
view,
}),
setPicked: (index) => {
picked = index;
},
setPickedContour: (index) => {
pickedContour = index;
},
toScreen,
toMetric,
unrotate: rotation.unrotate,
contourStepM,
measure,
measureMode: () => measureControls.mode(),
crossPreview,
slopeRefresh: () => slope.refresh(),
markEdited,
syncCurveBar: () => syncCurveBar(),
draw,
routeHead,
curveHint,
contourHint,
commit: () => {
history?.commit(snapshotNow());
historyControls.sync();
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void crossPreview.refresh();
}
const moved = dragMoved;
dragNode = -1;
dragHandle = null;
dragMoved = false;
// 끄는 동안 숨겨 둔 기울기 라벨 — 놓은 노선 높이를 한 번 묻게 다시 그린다(계획서 18 ①).
if (moved) {
slope.refresh();
draw();
}
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
},
});
bindNodeSplice({
canvas,
unrotate: rotation.unrotate,
segmentAt,
nodeAt,
segmentAt: pointer.segmentAt,
nodeAt: pointer.nodeAt,
insert: (segment, px, py) => {
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
picked = segment + 1;
@@ -40,7 +40,11 @@ export function createRouteEditChrome(): RouteEditChrome {
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong>계획노선 편집</strong>
<span class="b05-routeedit__rec-now" data-rec-now></span>
<span class="b05-routeedit__actions">
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 토공 최소 · 기울기 순한 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
aria-pressed="false" disabled
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
@@ -51,10 +55,8 @@ export function createRouteEditChrome(): RouteEditChrome {
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
<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" disabled
title="마지막에 적용한 초기값으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
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>
@@ -119,7 +119,7 @@ export interface HistoryControlsParams {
restore: (snapshot: RouteEditSnapshot, message: string) => void;
}
/** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다.
/** [되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다.
*
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을
* 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */
@@ -130,34 +130,24 @@ export function bindHistoryControls(params: HistoryControlsParams): {
const { overlay, getHistory, restore } = params;
const undoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="undo"]')!;
const redoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="redo"]')!;
const resetBtn = overlay.querySelector<HTMLButtonElement>('[data-act="history-reset"]')!;
const sync = (): void => {
const history = getHistory();
undoBtn.disabled = !history?.canUndo();
redoBtn.disabled = !history?.canRedo();
resetBtn.disabled = !history?.isDirty();
};
const step = (which: "undo" | "redo" | "reset"): void => {
const step = (which: "undo" | "redo"): void => {
const history = getHistory();
if (!history) return;
const snapshot = history[which]();
if (!snapshot) return;
restore(
snapshot,
which === "undo"
? "되돌렸습니다."
: which === "redo"
? "다시 했습니다."
: "창을 연 상태로 돌렸습니다.",
);
restore(snapshot, which === "undo" ? "되돌렸습니다." : "다시 했습니다.");
sync();
};
undoBtn.addEventListener("click", () => step("undo"));
redoBtn.addEventListener("click", () => step("redo"));
resetBtn.addEventListener("click", () => step("reset"));
const onKey = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) return;
+103 -234
View File
@@ -1,24 +1,24 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 공통 제약 겹침 네 칸 ·
* 비중 슬라이더 · [초기화] · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
* 계획노선 편집 창의 **노선 추천 이음**(계획서 20 · 23-2) — 도구 막대 [노선 추천] 단추 →
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [확인]에 실을 지금 추천.
*
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시
* 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의
* `initial_route` 한 곳에서 읽는다. 종단 · 배수유역 · 횡단 다시 계산은 여기서 안 돈다 —
* 노선이 바뀐 뒤 [확인]에서만(`_Apply`). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
*
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고(비운 칸은 서버가 알아서), 그
* 「채운 칸」만 캐시·`initial_choice.json`에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder는
* 그 칸을 처음 비운 채 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 ·
* `initial_choice.json` 에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder 는 그 칸을 처음 비운 채
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import {
fetchRouteInitial,
ROUTE_INITIAL_MISSING,
type RouteInitialCriteria,
type RouteInitialLogic,
type RouteInitialWeights,
@@ -26,63 +26,18 @@ import {
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
criteriaLine,
CRITERIA_KEYS,
DEFAULT_WEIGHTS,
metricsLine,
violationsLine,
violationText,
initialRouteSnapshot,
normalizeRouteInitial,
readInitialChoice,
rebalanceWeights,
relaxedLine,
sameBase,
serverBase,
WEIGHT_KEYS,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
type InitialChoice,
type InitialCriteria,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
const WEIGHT_NAME: Record<WeightKey, string> = {
expected: "예상",
earthwork: "토공",
grade: "기울기",
};
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
const CRITERIA_FIELD: Record<
CriteriaKey,
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
> = {
max_grade_pct: {
label: "기울기",
unit: "%",
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
of: (c) => c.maxGradePct,
},
min_radius_m: {
label: "최소R",
unit: "m",
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
of: (c) => c.minRadiusM,
},
stream_offset_m: {
label: "계류",
unit: "m",
title: "계류 이격(m) — 비우면 기본값 100m",
of: (c) => c.streamOffsetM,
},
grade_smooth_m: {
label: "지반",
unit: "m",
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
of: (c) => c.gradeSmoothM,
},
};
import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend";
export interface InitialPanelParams {
overlay: HTMLElement;
@@ -96,15 +51,15 @@ export interface InitialPanelParams {
export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
/** [확인]에 실어 보낼 지금 초기값. */
/** [확인]에 실어 보낼 지금 추천. */
current: () => {
mode: RouteInitialLogic;
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
};
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
/** [취소] — 이 창에서 바꾼 추천을 창을 열 때 것으로 되돌린다. */
cancel: () => void;
/** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */
/** [확인] 뒤 결과 캐시를 비웠다 — 추천은 새 노선의 출발점이라 다시 적는다. */
keep: () => void;
}
@@ -113,213 +68,127 @@ const copy = (snapshot: RouteEditSnapshot): 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__criteria-row">
${CRITERIA_KEYS.map(
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
<input type="number" inputmode="decimal" step="0.1" min="0" disabled
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
).join("")}
</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="violations" hidden></div>
<div class="b05-routeedit__initial-line is-warn" data-line="relaxed" hidden></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 violationsEl = box.querySelector<HTMLElement>('[data-line="violations"]')!;
const relaxedEl = box.querySelector<HTMLElement>('[data-line="relaxed"]')!;
const weightsBox = box.querySelector<HTMLElement>(".b05-routeedit__weights")!;
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
const recommendButton = overlay.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
const criteriaInputs = box.querySelectorAll<HTMLInputElement>("[data-criteria]");
const nowLine = overlay.querySelector<HTMLElement>("[data-rec-now]")!;
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
let openingChoice = readInitialChoice(projectId);
let choice: InitialChoice | null = openingChoice;
/** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */
/** 노드 없이 이름만 아는 현재 추천 — 서버가 적어 둔 것(없으면 예상노선 추적). */
let base: InitialBase = { logic: "follow", weights: null, criteria: null };
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
const defaults: Partial<Record<CriteriaKey, number>> = {};
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
let planRead: Promise<RoutePlanResponse> | null = null;
/** 화면 칸을 값으로 채운다(비운 칸은 빈 문자열 — placeholder 가 기본값을 보인다). */
function setCriteriaInputs(overrides: RouteInitialCriteria | null): void {
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
const value = overrides?.[key];
input.value = value === undefined ? "" : String(value);
function loadPlan(): Promise<RoutePlanResponse> {
planRead ??= fetchRoutePlan(projectId).catch((error) => {
planRead = null; // 다음에 다시 읽는다.
throw error;
});
}
/** 화면 칸에 지금 사용자가 넣은 값만(빈 칸은 안 담김) — 다음 요청에 실어 보낼 겹침. */
function currentCriteria(): RouteInitialCriteria {
const out: RouteInitialCriteria = {};
criteriaInputs.forEach((input) => {
const raw = input.value.trim();
if (raw === "") return;
const value = Number(raw);
if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value;
});
return out;
return planRead;
}
function render(): void {
const now = choice ?? base;
const logic = now.logic;
nowLine.textContent = `현재 초기값: ${choiceLabel(logic, now.weights)}`;
metricsEl.textContent =
choice && !choice.metrics
? "비교값 — 서버 답에 없습니다."
: metricsLine(choice?.metrics ?? null);
criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM());
// 제약 못 지킨 구간 · 상한 풂 — 서버가 준 목록 그대로(계획서 20). 없는 칸이면 숨긴다.
violationsEl.textContent = violationsLine(choice?.metrics ?? null);
violationsEl.title = (choice?.metrics?.violations ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
violationsEl.hidden = !violationsEl.textContent;
relaxedEl.textContent = relaxedLine(choice);
relaxedEl.hidden = !relaxedEl.textContent;
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.grade}`;
// 공통 제약 칸 — placeholder 는 그 칸을 처음 비운 채 물었을 때 서버가 준 진짜 기본값.
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
input.placeholder = defaults[key] !== undefined ? String(defaults[key]) : "기본값";
input.disabled = busy || !opening;
});
buttons.forEach((button) => {
button.disabled = busy || !opening;
});
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
nowLine.textContent = `현재 추천: ${choiceLabel(now.logic, now.weights)}`;
recommendButton.disabled = busy || !opening;
resetButton.disabled = busy || !opening;
}
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다.
* `useCriteria` 를 안 주면 화면 칸에 지금 든 값을 그대로 겹침으로 보낸다. */
async function run(
/** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */
async function request(
logic: RouteInitialLogic,
allowFallback = false,
useWeights: RouteInitialWeights | null = null,
useCriteria?: RouteInitialCriteria | null,
): Promise<void> {
if (busy || !opening) return;
busy = true;
const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null;
const criteria = useCriteria !== undefined ? (useCriteria ?? {}) : currentCriteria();
render();
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
try {
const sentCriteria = Object.keys(criteria).length ? criteria : null;
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(sent ? { weights: sent } : {}),
...(sentCriteria ? { criteria: sentCriteria } : {}),
});
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
choice = normalizeRouteInitial(response, logic, sent, sentCriteria);
// 비운 칸(안 겹친 칸)을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
for (const key of CRITERIA_KEYS) {
if (criteria[key] !== undefined) continue;
const resolved = choice.criteria ? CRITERIA_FIELD[key].of(choice.criteria) : null;
if (resolved !== null) defaults[key] = resolved;
}
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 && logic === "follow") {
params.apply(
copy(opening),
"초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
);
} else {
showToast(message, "error");
}
} finally {
busy = false;
if (box.isConnected) render();
}
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(weights ? { weights } : {}),
...(criteria ? { criteria } : {}),
});
return normalizeRouteInitial(response, logic, weights, criteria);
}
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));
recommendButton.addEventListener("click", () => {
void (async () => {
if (busy || !opening) return;
busy = true;
render();
});
const plan = await loadPlan().catch(() => null); // 지도 바탕만 못 깔 뿐 추천은 된다.
busy = false;
if (!recommendButton.isConnected) return; // 그 사이 창이 닫혔다.
render();
openRecommendModal({
criteria: choice?.criteriaOverrides ?? base.criteria,
defaults,
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: opening!.planned.map(([x, y]) => [x, y]),
request,
onApply: (picked) => {
choice = picked;
writeInitialChoice(projectId, choice);
render();
params.apply(
copy(picked.snapshot),
`추천을 「${choiceLabel(picked.logic, picked.weights)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`,
);
},
});
})();
});
// [초기화] — 마지막에 적용한 초기값으로. 노드가 없으면(서버가 이름만 앎) 그 갈래를 서버에 묻는다.
// [초기화] — 재계산 없이 전처리 때 만든 초기값 노선(예상노선 추적)을 다시 놓는다.
resetButton.addEventListener("click", () => {
if (!choice) {
setCriteriaInputs(base.criteria); // 그 사이 손댄 칸이 있으면 서버가 기억한 값으로 되돌린다.
void run(base.logic, true, base.weights, base.criteria);
return;
}
params.apply(
copy(choice.snapshot),
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
);
void (async () => {
if (busy || !opening) return;
busy = true;
render();
try {
const plan = await loadPlan().catch(() => null);
// 서버가 초기값 노선을 못 주면, 고친 적 없는 노선일 때만 창을 연 노선이 곧 초기값이다.
const snapshot = initialRouteSnapshot(plan?.initial_route) ?? (edited ? null : opening);
if (!snapshot) {
showToast("전처리 초기값 노선을 읽지 못했습니다.", "error");
return;
}
if (!resetButton.isConnected) return;
choice = {
logic: "follow",
weights: null,
snapshot: copy(snapshot),
metrics: null,
criteria: null,
criteriaOverrides: null,
};
writeInitialChoice(projectId, choice);
params.apply(
copy(snapshot),
"초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
);
} finally {
busy = false;
if (resetButton.isConnected) render();
}
})();
});
setCriteriaInputs(choice?.criteriaOverrides ?? null);
render();
return {
ready(snapshot, wasEdited, server) {
opening = copy(snapshot);
edited = wasEdited;
// 「현재 초기값」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다. 캐시는 같은 초기값 · 같은 제약 겹침의 노드를 들고 있을 때만 [초기화]가 쓴다.
// 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다.
if (server) {
base = serverBase(server);
if (choice && !sameBase(choice, base)) choice = null;
if (base.weights) weights = { ...base.weights };
// 화면 칸도 그때 넣었던 값(캐시가 살아 있으면 캐시, 아니면 서버가 기억한 값)으로.
setCriteriaInputs(choice?.criteriaOverrides ?? base.criteria);
openingChoice = choice;
writeInitialChoice(projectId, choice);
}
@@ -16,7 +16,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
type Vertex = [number, number];
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
follow: "예상노선 추종",
follow: "예상노선 추적",
earthwork: "토공 최소",
gentle: "기울기 순한 노선",
weighted: "비중 계산",
@@ -121,6 +121,52 @@ function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
};
}
/** 서버 `/route/plan` 의 `initial_route`(전처리 초기값 노선)를 편집값으로 편다. 없거나 모자라면 null. */
export function initialRouteSnapshot(initialRoute: unknown): RouteEditSnapshot | null {
const body = (initialRoute ?? {}) as Record<string, unknown>;
if (!Array.isArray(body.nodes) || body.nodes.length < 2) return null;
return snapshotOf(body.nodes, body.curves);
}
/** 미리 보기 지도 한 겹 — 점 목록과 종류. */
export interface MapLayer {
kind: "expected" | "current" | "preview";
points: Vertex[];
}
/** 미리 보기 지도의 SVG 선 — 모든 겹이 들어가게 폭 `width` · 높이 `height`(px) 안에 맞춘다.
* 북쪽이 위(y 뒤집기) · 가로세로 같은 축척 · 가장자리 `pad`. 점이 없으면 빈 목록. */
export function mapPaths(
layers: MapLayer[],
width: number,
height: number,
pad = 12,
): Array<{ kind: MapLayer["kind"]; d: string }> {
const all = layers.flatMap((layer) => layer.points);
if (!all.length) return [];
const xs = all.map(([x]) => x);
const ys = all.map(([, y]) => y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
const spanX = Math.max(1e-6, Math.max(...xs) - minX);
const spanY = Math.max(1e-6, Math.max(...ys) - minY);
const scale = Math.min((width - 2 * pad) / spanX, (height - 2 * pad) / spanY);
// 남는 쪽은 가운데로.
const offX = (width - spanX * scale) / 2;
const offY = (height - spanY * scale) / 2;
return layers
.filter((layer) => layer.points.length >= 2)
.map((layer) => ({
kind: layer.kind,
d: layer.points
.map(
([x, y], index) =>
`${index ? "L" : "M"}${(offX + (x - minX) * scale).toFixed(1)} ${(height - offY - (y - minY) * scale).toFixed(1)}`,
)
.join(""),
}));
}
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다).
* `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */
export function normalizeRouteInitial(
@@ -201,7 +247,7 @@ function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria |
return CRITERIA_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
}
/** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추종. */
/** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추적. */
export function serverBase(server: unknown): InitialBase {
const body = (server ?? {}) as Record<string, unknown>;
const logic =
@@ -304,7 +350,7 @@ export function relaxedLine(choice: InitialChoice | null): string {
return `지형상 ${limit} 로 못 이어져 상한을 ${fixed(bound, 1, "%")} 로 풂`;
}
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 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;
@@ -0,0 +1,310 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Pointer.ts
* 계획노선 편집 모달의 **캔버스 누르기 · 끌기 · 놓기** — 노드 · 곡선 손잡이 · 측점 눈금 ·
* 등고선을 집고, 노드와 손잡이를 끌어 옮긴다.
*
* `B05_Profile_UI_RouteEdit.ts` 가 700줄에 닿아 떼어낸 조각이다. 본문 로직과 수치는
* 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다. 배열은 모달이 쥔 것을
* 그대로 건네받아 **제자리에서** 고친다(되돌리기 사진첩이 같은 배열을 본다).
* ========================================================================== */
import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import {
dragHandleTo as curveDragTo,
type EditedCurve,
type EditedNode,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
import {
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import {
curveShortfalls,
radiusFloorM,
shortfallCrossed,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
const NODE_HIT_PX = 9;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11;
/** 누르기·끌기가 보는 편집값 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface RouteEditPointerState {
planned: Vertex[];
plannedLine: Vertex[];
nodeInfo: EditedNode[];
curveInfo: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
/** 자리를 못 박은 노드 — 켜진 자리는 고르기만 되고 안 끌린다. */
apexLock: boolean[];
limitRadiusM: number;
limitArcM: number;
picked: number;
pickedContour: number;
contours: RouteEditContours | null;
view: ViewState;
}
export interface RouteEditPointerParams {
canvas: HTMLCanvasElement;
status: HTMLElement;
stationIntervalM: number;
state: () => RouteEditPointerState;
setPicked: (index: number) => void;
setPickedContour: (index: number) => void;
toScreen: (vertex: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
/** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */
unrotate: (x: number, y: number) => [number, number];
contourStepM: () => number;
measure: { pick: (px: number, py: number) => unknown; clear: () => void };
/** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */
measureMode: () => boolean;
crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown };
slopeRefresh: () => void;
markEdited: () => void;
syncCurveBar: () => void;
draw: () => void;
routeHead: () => string;
curveHint: () => string;
contourHint: () => string;
/** 끌기 한 번을 되돌리기에 쌓는다. */
commit: () => void;
}
export interface RouteEditPointer {
/** 노드나 손잡이를 끌고 있나 — 끄는 동안 기울기 라벨을 숨긴다. */
dragging: () => boolean;
nodeAt: (px: number, py: number) => number;
segmentAt: (px: number, py: number) => number;
}
export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditPointer {
const { canvas, status, toScreen, toMetric } = params;
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => {
const { curveInfo, curveLock, apexLock } = params.state();
return handleAtScreen(
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
curveInfo.filter(
(entry) =>
(curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true,
),
toScreen,
px,
py,
NODE_HIT_PX + 2,
);
};
const nodeAt = (px: number, py: number): number =>
nodeAtScreen(params.state().planned, toScreen, px, py, NODE_HIT_PX);
const segmentAt = (px: number, py: number): number =>
segmentAtScreen(params.state().planned, toScreen, px, py, SEGMENT_HIT_PX);
/** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */
function dragHandleTo(
node: number,
which: "start" | "end",
to: Vertex,
): { apex: Vertex; radius: number } | null {
const { curveInfo, planned } = params.state();
const curve = curveInfo.find((entry) => entry.node_first === node);
if (!curve) return null;
const before = planned[node - 1];
const after = planned[node + 1];
if (!before || !after) return null;
return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to);
}
let dragNode = -1;
/** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */
let dragHandle: { node: number; end: "start" | "end" } | null = null;
/** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다
* (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */
let dragMoved = false;
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const rect = canvas.getBoundingClientRect();
const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (event.shiftKey || params.measureMode()) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void params.measure.pick(px, py);
return;
}
const state = params.state();
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
const hitNode = nodeAt(px, py);
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1;
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false;
if (hitNode >= 0) {
params.setPicked(hitNode); // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
params.measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
params.syncCurveBar();
params.draw();
} else if (dragHandle) {
params.setPicked(dragHandle.node);
params.measure.clear();
params.syncCurveBar();
params.draw();
} else if (
// 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나
// 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다.
((): boolean => {
if (state.picked >= 0) {
params.setPicked(-1);
params.syncCurveBar();
}
return false;
})()
) {
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
const chainage = stationAtScreen(
state.plannedLine.length ? state.plannedLine : state.planned,
toScreen,
params.stationIntervalM,
px,
py,
STATION_HIT_PX,
);
if (chainage === null) return false;
void params.crossPreview.open(chainage);
return true;
})()
) {
/* 횡단 창이 떴다 — 더 집지 않는다. */
} else if (state.contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
const hit = hitPreparedLayer(
state.contours.layer,
state.view,
px,
py,
CONTOUR_HIT_PX,
params.contourStepM(),
);
if (hit !== state.pickedContour) {
params.setPickedContour(hit);
status.textContent = `${params.routeHead()} — ${params.contourHint()} ${params.curveHint()}`;
params.draw();
}
}
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const state = params.state();
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const node = dragHandle.node;
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
state.planned[node] = moved.apex;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
state.curveRadius[node] = Math.max(
radiusFloorM(state.nodeInfo[node]?.inner_angle_deg, state.limitRadiusM, state.limitArcM),
Math.round(moved.radius * 100) / 100,
);
state.curveOn[node] = true;
params.setPicked(node);
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
dragMoved = true;
params.markEdited();
params.syncCurveBar();
status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`;
params.draw();
}
return;
}
if (dragNode >= 0) {
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
const previous = state.planned[dragNode];
dragMoved = true;
state.planned[dragNode] = toMetric(px, py);
params.markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
const after = params.state();
if (
shortfallCrossed(
before,
curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM),
)
) {
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
state.planned[dragNode] = previous;
params.markEdited();
status.textContent =
`${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`;
params.draw();
return;
}
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`;
params.draw();
return;
}
const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
overNode >= 0
? state.apexLock[overNode]
? "not-allowed"
: "grab"
: handleAt(px, py)
? "grab"
: "default";
});
const endDrag = (event: PointerEvent): void => {
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
// 눌러야 한다. 놓는 순간에만 쌓는다.
if (dragMoved) {
params.commit();
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void params.crossPreview.refresh();
}
const moved = dragMoved;
dragNode = -1;
dragHandle = null;
dragMoved = false;
// 끄는 동안 숨겨 둔 기울기 라벨 — 놓은 노선 높이를 한 번 묻게 다시 그린다(계획서 18 ①).
if (moved) {
params.slopeRefresh();
params.draw();
}
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
return { dragging: () => dragNode >= 0 || dragHandle !== null, nodeAt, segmentAt };
}
@@ -0,0 +1,353 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend.ts
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2) — 알약 넷(예상노선 추적 · 토공 최소 ·
* 기울기 순한 · 비중 계산) · 비중 슬라이더 셋 · 공통 제약 네 칸 · 미리 보기 지도 · 비교값 ·
* 못 지킨 구간 · [적용] · [닫기].
*
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 편집 창의 노선은
* 건드리지 않는다 — [적용]을 눌러야 `onApply` 로 편집값에 실리고(되돌리기 한 걸음), [닫기]는
* 미리 보기를 버린다. 다시 계산(배수유역부터)은 여기서 안 돈다 — 편집 창 [확인]에서만.
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
* ========================================================================== */
import { openModal } from "@ui/ui_template_modal";
import { showToast } from "@ui/ui_template_elements";
import type {
RouteInitialCriteria,
RouteInitialLogic,
RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import {
choiceLabel,
criteriaLine,
CRITERIA_KEYS,
LOGIC_LABEL,
mapPaths,
metricsLine,
rebalanceWeights,
relaxedLine,
violationsLine,
violationText,
WEIGHT_KEYS,
type CriteriaKey,
type InitialChoice,
type InitialCriteria,
type MapLayer,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
const PILLS: RouteInitialLogic[] = ["follow", "earthwork", "gentle", "weighted"];
const PILL_TITLE: Record<RouteInitialLogic, string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
earthwork: "절토 · 성토가 가장 적은 노선",
gentle: "종단기울기가 가장 순한 노선",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
};
const WEIGHT_NAME: Record<WeightKey, string> = {
expected: "예상",
earthwork: "토공",
grade: "기울기",
};
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
const CRITERIA_FIELD: Record<
CriteriaKey,
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
> = {
max_grade_pct: {
label: "기울기",
unit: "%",
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
of: (c) => c.maxGradePct,
},
min_radius_m: {
label: "최소R",
unit: "m",
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
of: (c) => c.minRadiusM,
},
stream_offset_m: {
label: "계류",
unit: "m",
title: "계류 이격(m) — 비우면 기본값 100m",
of: (c) => c.streamOffsetM,
},
grade_smooth_m: {
label: "지반",
unit: "m",
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
of: (c) => c.gradeSmoothM,
},
};
const MAP_W = 460;
const MAP_H = 340;
export interface RecommendParams {
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
criteria: RouteInitialCriteria | null;
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */
defaults: Partial<Record<CriteriaKey, number>>;
weights: RouteInitialWeights;
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
current: Vertex[];
/** 서버에 갈래 하나를 묻고 편집값 한 벌로 펴 돌려준다(던지면 토스트). */
request: (
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 편집 창에 놓는다. */
onApply: (choice: InitialChoice) => void;
}
export function openRecommendModal(params: RecommendParams): void {
let logic: RouteInitialLogic = "follow";
let weights: RouteInitialWeights = { ...params.weights };
let preview: InitialChoice | null = null;
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
let dirty = false;
let closed = false;
let seq = 0;
let bodyEl: HTMLElement | null = null;
openModal({
title: "노선 추천",
closeLabel: "✕",
dialogClass: "b05-routerec",
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
beforeClose: () => {
closed = true;
return true;
},
mount: (body, close) => {
bodyEl = body;
body.classList.add("b05-routerec__body");
body.innerHTML = `
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
${PILLS.map(
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
).join("")}
</div>
<div class="b05-routerec__main">
<div class="b05-routerec__mapbox">
<svg class="b05-routerec__map" viewBox="0 0 ${MAP_W} ${MAP_H}" role="img"
aria-label="추천 노선 미리 보기"></svg>
<div class="b05-routerec__legend">
<span><i class="is-expected"></i>예상노선(원본)</span>
<span><i class="is-current"></i>창을 연 노선</span>
<span><i class="is-preview"></i>미리 보기</span>
</div>
</div>
<div class="b05-routerec__side">
<div class="b05-routerec__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("")}
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<div class="b05-routerec__criteria">
${CRITERIA_KEYS.map(
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
<input type="number" inputmode="decimal" step="0.1" min="0"
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
).join("")}
</div>
<button type="button" class="ui-btn ui-btn--glass" data-act="rerun" hidden>다시 셈</button>
</div>
</div>
<div class="b05-routerec__lines">
<div data-line="metrics"></div>
<div data-line="violations" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div data-line="criteria"></div>
</div>
<div class="b05-routerec__foot">
<span class="b05-routerec__state" data-state></span>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
</div>`;
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
const svg = body.querySelector<SVGSVGElement>(".b05-routerec__map")!;
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
const applyButton = q<HTMLButtonElement>('[data-act="apply"]');
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
criteriaInputs.forEach((input) => {
const value = params.criteria?.[input.dataset.criteria as CriteriaKey];
input.value = value === undefined ? "" : String(value);
});
/** 칸에 지금 든 값만(빈 칸은 안 담김) — 서버에 실어 보낼 겹침. */
function currentCriteria(): RouteInitialCriteria {
const out: RouteInitialCriteria = {};
criteriaInputs.forEach((input) => {
const raw = input.value.trim();
if (raw === "") return;
const value = Number(raw);
if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value;
});
return out;
}
function drawMap(): void {
const layers: MapLayer[] = [
{ kind: "expected", points: params.expected },
{ kind: "current", points: params.current },
];
if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned });
svg.innerHTML = mapPaths(layers, MAP_W, MAP_H)
.map((path) => `<path class="is-${path.kind}" d="${path.d}" />`)
.join("");
}
function render(): void {
pills.forEach((pill) => {
const on = pill.dataset.logic === logic;
pill.setAttribute("aria-checked", String(on));
pill.classList.toggle("is-on", on);
pill.disabled = busy;
});
weightsBox.hidden = logic !== "weighted";
for (const key of WEIGHT_KEYS) {
q<HTMLInputElement>(`[data-weight="${key}"]`).value = String(weights[key]);
q<HTMLOutputElement>(`[data-weight-out="${key}"]`).value = `${weights[key]}`;
}
q<HTMLElement>("[data-weight-sum]").textContent =
`합 ${weights.expected + weights.earthwork + weights.grade}`;
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
input.placeholder =
params.defaults[key] !== undefined ? String(params.defaults[key]) : "기본값";
input.disabled = busy;
});
rerun.hidden = !dirty;
rerun.disabled = busy;
applyButton.disabled = busy || dirty || !preview;
line("metrics").textContent = preview
? preview.metrics
? metricsLine(preview.metrics)
: "비교값 — 서버 답에 없습니다."
: "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다.";
const violations = violationsLine(preview?.metrics ?? null);
line("violations").textContent = violations;
line("violations").title = (preview?.metrics?.violations ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
line("violations").hidden = !violations;
const relaxed = relaxedLine(preview);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
line("criteria").textContent = preview
? criteriaLine(preview.criteria, params.planMinRadiusM)
: "";
line("criteria").hidden = !preview;
drawMap();
}
async function run(): Promise<void> {
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
busy = true;
dirty = false;
preview = null;
stateEl.textContent = `${choiceLabel(logic, sentWeights)} 셈하는 중…`;
render();
try {
const choice = await params.request(
logic,
sentWeights,
Object.keys(criteria).length ? criteria : null,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
for (const key of CRITERIA_KEYS) {
if (criteria[key] !== undefined) continue;
const resolved = choice.criteria ? CRITERIA_FIELD[key].of(choice.criteria) : null;
if (resolved !== null) params.defaults[key] = resolved;
}
preview = choice;
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`;
} catch (error) {
if (closed || mine !== seq) return;
stateEl.textContent = "셈하지 못했습니다.";
showToast(
error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.",
"error",
);
} finally {
if (!closed && mine === seq) {
busy = false;
render();
}
}
}
pills.forEach((pill) => {
pill.addEventListener("click", () => {
if (busy) return;
logic = pill.dataset.logic as RouteInitialLogic;
void run();
});
});
body.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
input.addEventListener("input", () => {
weights = rebalanceWeights(
weights,
input.dataset.weight as WeightKey,
Number(input.value),
);
dirty = preview !== null;
render();
});
});
criteriaInputs.forEach((input) => {
input.addEventListener("input", () => {
dirty = preview !== null;
render();
});
});
rerun.addEventListener("click", () => void run());
applyButton.addEventListener("click", () => {
if (!preview) return;
params.onApply(preview);
closed = true;
close();
});
q<HTMLButtonElement>('[data-act="close"]').addEventListener("click", () => {
closed = true;
close();
});
render();
// 처음 고른 상태 = 예상노선 추적 — 열자마자 그 미리 보기를 받는다.
void run();
},
});
// 편집 창 단축키(Ctrl+Z 등)가 뒤에서 노선을 되돌리지 않게 막는다. 칸 안의 글자 되돌리기는 그대로.
(bodyEl as HTMLElement | null)
?.closest(".ui-modal__backdrop")
?.addEventListener("keydown", (event) => {
const key = event as KeyboardEvent;
if (key.ctrlKey || key.metaKey) key.stopPropagation();
});
}
@@ -125,113 +125,6 @@
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);
}
/* 공통 제약 겹침 네 칸(계획서 20 확정) — 로직 단추 바로 아래. 비우면 placeholder 가 기본값. */
.b05-routeedit__criteria-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-12);
}
.b05-routeedit__criteria-row label {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.b05-routeedit__criteria-row input {
width: 3.6em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
text-align: right;
}
.b05-routeedit__criteria-row input::placeholder {
color: var(--color-text-secondary);
}
.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;
}
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
.b05-routeedit__initial-line.is-warn {
color: var(--color-warning);
font-weight: 600;
}
.b05-routeedit__legend {
display: inline-flex;
align-items: center;
@@ -0,0 +1,223 @@
/* 노선 추천 모달(계획서 23-2) — 알약 넷 · 미리 보기 지도 · 비중 · 제약 칸 · 비교값.
색은 전부 테마 토큰을 쓴다(밝음 · 어두움). 계획노선 편집 창(`.b05-routeedit`) 위에 뜬다. */
/* 편집 창 제목 옆 「현재 추천: ○○」. */
.b05-routeedit__rec-now {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-caption);
white-space: nowrap;
}
.ui-modal.b05-routerec {
width: min(900px, 94vw);
max-height: 92vh;
}
.b05-routerec__body {
gap: var(--spacing-12);
}
/* 알약 — 하나만 고른 상태(라디오 묶음). */
.b05-routerec__pills {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.b05-routerec__pill {
padding: 6px var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body-sm);
cursor: pointer;
}
.b05-routerec__pill:hover:not(:disabled) {
border-color: var(--color-primary);
}
.b05-routerec__pill.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-on-primary, #fff);
font-weight: 600;
}
.b05-routerec__pill:disabled {
cursor: progress;
opacity: 0.7;
}
.b05-routerec__main {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: var(--spacing-12);
align-items: start;
}
.b05-routerec__mapbox {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
.b05-routerec__map {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
}
.b05-routerec__map path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.b05-routerec__map path.is-expected {
stroke: var(--color-text-secondary, #9ca3af);
stroke-dasharray: 5 4;
stroke-width: 1.5;
}
.b05-routerec__map path.is-current {
stroke: var(--map-route, #f97316);
stroke-width: 2;
opacity: 0.55;
}
.b05-routerec__map path.is-preview {
stroke: var(--color-primary);
stroke-width: 2.5;
}
.b05-routerec__legend {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routerec__legend i {
display: inline-block;
width: 22px;
margin-right: 6px;
vertical-align: middle;
}
.b05-routerec__legend i.is-expected {
border-top: 2px dashed var(--color-text-secondary, #9ca3af);
}
.b05-routerec__legend i.is-current {
border-top: 2px solid var(--map-route, #f97316);
opacity: 0.55;
}
.b05-routerec__legend i.is-preview {
border-top: 3px solid var(--color-primary);
}
.b05-routerec__side {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routerec__weights {
display: grid;
gap: 4px;
}
.b05-routerec__weights label {
display: grid;
grid-template-columns: 3.5em 1fr 2.5em;
align-items: center;
gap: var(--spacing-8);
}
.b05-routerec__weights output {
color: var(--color-text-body);
text-align: right;
}
.b05-routerec__sum {
text-align: right;
}
/* 공통 제약 네 칸 — 비우면 placeholder 가 기본값. */
.b05-routerec__criteria {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-12);
}
.b05-routerec__criteria label {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.b05-routerec__criteria input {
width: 4em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
text-align: right;
}
.b05-routerec__criteria input::placeholder {
color: var(--color-text-secondary);
}
.b05-routerec__side .ui-btn {
align-self: flex-start;
}
.b05-routerec__lines {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.45;
}
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
.b05-routerec__lines .is-warn {
color: var(--color-warning);
font-weight: 600;
}
.b05-routerec__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-8);
}
.b05-routerec__state {
flex: 1 1 12em;
color: var(--color-text-body);
font-size: var(--text-caption);
}
/* 좁은 폭 — 지도 아래로 조작 칸. */
@media (max-width: 720px) {
.b05-routerec__main {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -213,6 +213,31 @@ const sameAsServer = {
weightedOther: model.sameBase(planShape, bases.weighted),
};
// ⑥ 전처리 초기값 노선(`/route/plan` 의 `initial_route`)과 미리 보기 지도 선.
const initialRoute = {
ok: model.initialRouteSnapshot({
nodes: [
{ x: 0, y: 0 },
{ x: 10, y: 5, radius_m: 20 },
],
curves: [],
}),
none: model.initialRouteSnapshot(undefined),
tooShort: model.initialRouteSnapshot({ nodes: [{ x: 0, y: 0 }], curves: [] }),
};
const mapLines = {
fit: model.mapPaths(
[
{ kind: "expected", points: [[0, 0], [100, 0]] },
{ kind: "preview", points: [[0, 0], [100, 50]] },
],
460,
340,
),
empty: model.mapPaths([], 460, 340),
oneDot: model.mapPaths([{ kind: "preview", points: [[5, 5]] }], 460, 340),
};
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
@@ -251,5 +276,7 @@ process.stdout.write(
cleared,
bases,
sameAsServer,
initialRoute,
mapLines,
}),
);
+22 -1
View File
@@ -75,7 +75,7 @@ def test_노드가_모자라면_싣지_않음(results):
def test_비교값과_기준값_글(results):
labels = results["labels"]
assert labels["follow"] == "예상노선 추종"
assert labels["follow"] == "예상노선 추적"
assert labels["weighted"] == "비중 계산 60/20/20"
assert labels["metrics"] == (
"연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥"
@@ -142,3 +142,24 @@ def test_캐시는_결과_통_프로젝트_칸(results):
assert results["other"] is None
assert results["badLogic"] is None
assert results["cleared"] is None
def test_전처리_초기값_노선을_편집값으로(results):
snap = results["initialRoute"]["ok"]
assert snap["planned"] == [[0, 0], [10, 5]]
assert snap["curveRadius"] == [None, 20]
assert snap["picked"] == -1
# 서버가 안 주거나 노드가 모자라면 null — 화면이 [초기화]를 안 한다.
assert results["initialRoute"]["none"] is None
assert results["initialRoute"]["tooShort"] is None
def test_미리_보기_지도_선(results):
lines = results["mapLines"]
# 폭 460 · 높이 340 · 가장자리 12 — 남는 쪽은 가운데 · 북쪽이 위(y 뒤집기).
assert lines["fit"] == [
{"kind": "expected", "d": "M12.0 279.0L448.0 279.0"},
{"kind": "preview", "d": "M12.0 279.0L448.0 61.0"},
]
assert lines["empty"] == []
assert lines["oneDot"] == [] # 점 하나로는 선이 안 됨
@@ -1,11 +1,11 @@
"""초기노선 갈래 저장 왕복 — [확인]에 실은 초기값이 다시 열기 뒤 그대로인가(계획서 20).
환경 배정 storage 프로젝트(데스크탑_메인)의 **사본**에서 화면과 같은 차례로 서버 길을 부른다.
갈래(추종 · 토공 최소 · 기울기 순한 · 비중 50/25/25)마다
갈래(추적 · 토공 최소 · 기울기 순한 · 비중 50/25/25)마다
① `POST /route/initial` → 비교값이 다 있음
② 화면 코드로 [확인] 몸통을 만들어 `POST /route/replan` → 서버가 initial 을 적음
③ `GET /route/plan` → 현재 초기값 = 보낸 것 · 다시 연 노드 = 보낸 노드
④ [초기화] — 캐시가 있으면 그 노드, 없으면(다른 PC) 서버가 같은 갈래로 같은 노드를 냄
④ [초기화] — 서버 `initial_route`(전처리 초기값 노선)를 놓음 · 재계산 없음
화면 쪽 셈은 `helper_b05_routeedit_initial_apply.cjs` 가 화면 코드 그대로 돌린다.
화면 제약 칸(종단기울기 · 최소 반지름 · 계류 이격 · 지반 고름, 계획서 20 확정)에 겹친 값도
@@ -187,7 +187,7 @@ def rounds(api):
return {"before": before, "rounds": out}
def test_원본은_처음_추종(rounds):
def test_원본은_처음_추적(rounds):
assert rounds["before"]["current_initial"]["logic"] == "follow"
@@ -250,21 +250,20 @@ def test_다시_연_노선은_확인한_노선(rounds):
assert _close(item["reopened"]["planned"], sent), item["logic"]
def test_초기화는_마지막_초기값으로(rounds, api):
def test_초기화는_전처리_초기값_노선으로(rounds):
"""[초기화] 는 재계산 없이 `GET /route/plan` 의 `initial_route` 를 놓는다 — 무엇을 확인했든
같은 노선이고, 확인 전(수정본 없음) 노드와 같다."""
before = rounds["before"]
assert before["edited"] is False
first = [[n["x"], n["y"]] for n in before["nodes"]]
for item in rounds["rounds"]:
assert item["plan"]["edited"] is True
initial = item["plan"]["initial_route"]
assert len(initial["nodes"]) >= 2 and isinstance(initial["curves"], list)
assert _close([[n["x"], n["y"]] for n in initial["nodes"]], first), item["logic"]
# 확인한 마지막 노선은 초기값과 다르다 — 되돌림이 실제로 노선을 바꾼다.
last = rounds["rounds"][-1]
cached = last["applied"]["choice"]["snapshot"]["planned"]
# 캐시가 있으면 그 노드 — 마지막 적용(비중 50/25/25).
assert _close(cached, [[v["x"], v["y"]] for v in last["applied"]["body"]["vertices"]])
# 캐시가 없으면(다른 PC) 서버가 적어 둔 갈래 · 비중으로 다시 물음 → 같은 노드.
base = last["reopened"]["base"]
again = _initial(api, base["logic"], base["weights"])
[redo] = _screen(
[{"kind": "apply", "response": again, "logic": base["logic"], "weights": base["weights"]}]
)
assert _close(redo["choice"]["snapshot"]["planned"], cached)
# 다른 갈래들과는 다른 노선이다(되돌림이 실제로 갈래를 가림).
others = [r["applied"]["choice"]["snapshot"]["planned"] for r in rounds["rounds"][:-1]]
assert all(not _close(o, cached) for o in others)
assert not _close(last["reopened"]["planned"], first)
def test_제약_겹침도_확인_뒤_그대로(api, rounds):