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

This commit is contained in:
2026-09-29 19:53:10 +09:00
25 changed files with 1790 additions and 607 deletions
@@ -208,6 +208,18 @@ export interface StationTickOptions {
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
uprightRad?: number;
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
onLabel?: (box: StationLabelBox) => void;
}
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
export interface StationLabelBox {
chainage: number;
x: number;
y: number;
half: number;
uprightRad: number;
}
export function drawStationTicks(
@@ -299,6 +311,7 @@ export function drawStationTicks(
context.stroke();
}
drawn.push({ x: lx, y: ly, half });
options.onLabel?.({ chainage, x: lx, y: ly, half, uprightRad: options.uprightRad ?? 0 });
context.save();
if (options.uprightRad) {
context.translate(lx, ly);
+6
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 {
@@ -164,6 +166,8 @@ export interface CrossPreviewResponse {
uphill_side: string | null;
plan_radius_m: number | null;
curve_widening_m: number | null;
/** 샘플이 덮은 횡단 반폭(m) — 요청값, 비었으면 서버 기본값. */
cross_half_width_m: number;
/** 원지반 횡단 샘플. */
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
@@ -180,6 +184,8 @@ export interface CrossPreviewRequest {
chainage_m: number;
min_radius_m: number;
station_interval_m: number;
/** 횡단 반폭(m) — 없으면 서버 기본값. */
cross_half_width_m?: number;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
+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),
}
+9 -3
View File
@@ -118,6 +118,8 @@ class CrossPreviewRequest(BaseModel):
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
min_radius_m: float = Field(12.0, gt=0)
station_interval_m: float | None = None
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
cross_half_width_m: float | None = Field(None, gt=0)
def _cross_preview(
@@ -155,10 +157,12 @@ def _cross_preview(
radii=[vertex.radius_m for vertex in request.vertices],
)
interval = request.station_interval_m
options = (
SectionGenerationOptions(station_interval_m=float(interval))
defaults = SectionGenerationOptions()
options = SectionGenerationOptions(
station_interval_m=float(interval)
if interval and interval > 0
else SectionGenerationOptions()
else defaults.station_interval_m,
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
)
result = generate_sections(built.vertices, sampler, options)
sections = result["cross_sections"]
@@ -184,6 +188,8 @@ def _cross_preview(
"uphill_side": section.get("uphill_side"),
"plan_radius_m": section.get("plan_radius_m"),
"curve_widening_m": section.get("curve_widening_m"),
# 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다.
"cross_half_width_m": options.cross_half_width_m,
"samples": section["samples"],
"design": design,
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
+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>
+31 -4
View File
@@ -21,9 +21,9 @@ import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_
import {
buildCrossSvg,
CROSS_PLOT_PAD,
designHalfWidths,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { readByKey, stateKey } from "../A00_Common/b_page_state";
import { stationLabel as formatStation } from "../common_util/common_util_station";
export interface CrossPreviewParams {
@@ -40,7 +40,7 @@ export interface CrossPreviewParams {
onShown?: () => void;
}
/** 위 판에 떠 있는 측점과 그 좌·우 설계 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
/** 위 판에 떠 있는 측점과 그 좌·우 횡단 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
export interface CrossWatch {
chainageM: number;
leftM: number;
@@ -64,6 +64,26 @@ interface CrossPane {
wait: (text: string) => void;
}
/**
* 우측 패널 「횡단 반폭(m)」에 **적용해 둔 값**(m). 적용한 적이 없으면 undefined — 서버가 기본값을 쓴다.
*
* B06 이 [적용]에서 노선 단위 세션 키에 적는다. 이 창은 노선 번호를 몰라 프로젝트 접두어로
* 훑고, 노선이 여럿 남아 있으면 번호가 가장 큰(최신) 것을 쓴다.
*/
function appliedHalfWidth(projectId: string): number | undefined {
const prefix = stateKey("cross-display", projectId, "");
if (!prefix) return undefined;
try {
const latest = Object.keys(window.sessionStorage)
.filter((key) => key.startsWith(prefix))
.sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0];
const value = Number(latest ? readByKey(latest) : NaN);
return Number.isFinite(value) && value > 0 ? value : undefined;
} catch {
return undefined;
}
}
function createPane(title: string, empty: string): CrossPane {
const root = document.createElement("section");
root.className = "b05-routeedit__cross";
@@ -139,6 +159,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
const preview = await fetchCrossPreview(params.projectId, {
...request,
chainage_m: chainageM,
cross_half_width_m: appliedHalfWidth(params.projectId),
});
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
shown = preview;
@@ -164,8 +185,14 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
await load(watching, true);
},
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
// 반폭은 마지막으로 셈한 횡단 것이고, 노드를 놓으면 `refresh` 가 새 값으로 바꾼다.
// 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다.
watched: () =>
watching === null || !shown ? null : { chainageM: watching, ...designHalfWidths(shown) },
watching === null || !shown
? null
: {
chainageM: watching,
leftM: shown.cross_half_width_m,
rightM: shown.cross_half_width_m,
},
};
}
@@ -172,6 +172,30 @@ function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolea
return points;
}
/** 곡선의 호를 점열로 — 시작점 · 원호 위 점 · 끝점. 그려지는 선(`buildEditedPolyline`)과 같은
* 5° 걸음이라 「호를 눌렀나」 판정이 눈에 보이는 선과 어긋나지 않는다. 호는 늘 180° 미만이라
* 두 접선점 사이 **짧은 쪽**으로 돈다(도는 방향을 따로 안 받는다). */
export function curveArcVertices(curve: EditedCurve): Vertex[] {
const toStart = unit(curve.apex, curve.start);
const toEnd = unit(curve.apex, curve.end);
const bisector: Vertex = [toStart[0] + toEnd[0], toStart[1] + toEnd[1]];
const bisectorLength = Math.hypot(bisector[0], bisector[1]);
const half = Math.sin((curve.inner_angle_deg * RAD_PER_DEG) / 2);
if (bisectorLength <= 1e-9 || !(half > 1e-9)) return [curve.start, curve.end];
const centerDistance = curve.radius_m / half;
const center: Vertex = [
curve.apex[0] + (bisector[0] / bisectorLength) * centerDistance,
curve.apex[1] + (bisector[1] / bisectorLength) * centerDistance,
];
const sweep = Math.atan2(
(curve.start[0] - center[0]) * (curve.end[1] - center[1]) -
(curve.start[1] - center[1]) * (curve.end[0] - center[0]),
(curve.start[0] - center[0]) * (curve.end[0] - center[0]) +
(curve.start[1] - center[1]) * (curve.end[1] - center[1]),
);
return [curve.start, ...arcPoints(center, curve.start, curve.end, sweep < 0), curve.end];
}
/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */
export interface EditedNode {
inner_angle_deg: number | null;
@@ -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,9 +16,9 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
type Vertex = [number, number];
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
follow: "예상노선 추종",
follow: "예상노선 추적",
earthwork: "토공 최소",
gentle: "기울기 순한 노선",
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;
@@ -15,6 +15,8 @@
import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import { curveArcVertices, type EditedCurve } from "./B05_Profile_UI_RouteEdit_Curve";
/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */
const ZOOM_IN_FACTOR = 1.15;
@@ -189,6 +191,41 @@ export function handleAtScreen(
return best;
}
/** 호(곡선 부분)를 눌렀다고 볼 거리(px) — 노선 선 굵기보다 넉넉히. */
export const ARC_HIT_PX = 8;
/** 화면 좌표에 가장 가까운 **호**를 가진 곡선의 노드 번호(`node_first`). 문턱 밖이면 -1.
*
* 호는 그려지는 선과 같은 점열(`curveArcVertices`)을 화면으로 옮겨 선분 거리로 잰다.
* 손잡이(접선점)는 호의 양 끝이라 이 판정에도 걸린다 — 손잡이를 먼저 보는 것은 부르는 쪽 몫. */
export function arcAtScreen(
curves: ReadonlyArray<EditedCurve>,
toScreen: ScreenOf,
px: number,
py: number,
hitPx: number = ARC_HIT_PX,
): number {
let best = -1;
let bestDistance = hitPx;
for (const curve of curves) {
const arc = curveArcVertices(curve).map(([x, y]) => toScreen([x, y]));
for (let index = 0; index < arc.length - 1; index += 1) {
const [ax, ay] = arc[index];
const [bx, by] = arc[index + 1];
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
if (distance <= bestDistance) {
bestDistance = distance;
best = curve.node_first;
}
}
}
return best;
}
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */
export function segmentAtScreen(
points: Array<[number, number]>,
@@ -275,6 +312,59 @@ export function stationAtScreen(
px: number,
py: number,
maxPx: number,
labels: ReadonlyArray<StationLabelBox> = [],
): number | null {
// 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이
// 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다.
const shown = labels.length
? new Set(labels.map((box) => Math.round(box.chainage / intervalM)))
: null;
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown);
// 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다.
return tick ?? stationLabelAtScreen(labels, px, py);
}
/** 그려 둔 측점 라벨 글자 상자 — 그릴 때마다 `Render` 가 새로 적는다. 창(캔버스)마다 따로. */
const labelBoxes = new WeakMap<HTMLCanvasElement, ReadonlyArray<StationLabelBox>>();
export const setStationLabelBoxes = (
canvas: HTMLCanvasElement,
boxes: ReadonlyArray<StationLabelBox>,
): void => void labelBoxes.set(canvas, boxes);
export const stationLabelBoxes = (canvas: HTMLCanvasElement): ReadonlyArray<StationLabelBox> =>
labelBoxes.get(canvas) ?? [];
/** 라벨 글자 상자 안을 눌렀나 — 상자는 가운데를 축으로 `uprightRad` 만큼 돌아 있어, 눌린 자리를
* 거꾸로 돌려 세운 뒤 가로 반폭 · 세로 반폭(8) 안인지 본다. 여럿이면 가운데가 가까운 것. */
export function stationLabelAtScreen(
labels: ReadonlyArray<StationLabelBox>,
px: number,
py: number,
): number | null {
let best: number | null = null;
let bestDistance = Infinity;
for (const box of labels) {
const dx = px - box.x;
const dy = py - box.y;
const cos = Math.cos(-box.uprightRad);
const sin = Math.sin(-box.uprightRad);
if (Math.abs(dx * cos - dy * sin) > box.half || Math.abs(dx * sin + dy * cos) > 8) continue;
const distance = Math.hypot(dx, dy);
if (distance < bestDistance) {
bestDistance = distance;
best = box.chainage;
}
}
return best;
}
function stationTickAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
intervalM: number,
px: number,
py: number,
maxPx: number,
shown: ReadonlySet<number> | null,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
@@ -289,6 +379,7 @@ export function stationAtScreen(
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
if (shown && !shown.has(Math.round(chainage / intervalM))) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
@@ -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();
});
}
+11 -3
View File
@@ -20,9 +20,12 @@ import {
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
drawStationTicks,
type StationLabelBox,
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
@@ -344,7 +347,8 @@ function drawMeasureMarks(
}
/**
* 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 그 측점 설계 반폭.
* 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 우측 패널 「횡단 반폭」
* (평면 거리 · 횡단 미리보기의 원지반 선 범위와 같다).
*
* 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버
* `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다.
@@ -442,6 +446,9 @@ function drawStationMarks(
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
// 라벨 글자 상자는 그릴 때마다 새로 적는다 — 누름 판정(`stationAtScreen`)이 이것을 본다.
const labels: StationLabelBox[] = [];
setStationLabelBoxes(context.canvas, labels);
if (line.length < 2) return;
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
drawStationTicks(
@@ -452,6 +459,7 @@ function drawStationMarks(
pxPerMeter: scene.pxPerMeter,
toScreen: (x, y) => scene.toScreen([x, y]),
uprightRad: scene.uprightRad,
onLabel: (box) => labels.push(box),
},
);
const total = polylineLengthM(line);
@@ -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);
}
}
@@ -0,0 +1,122 @@
/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다.
* 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
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;
},
};
}
const session = memoryStorage();
Object.defineProperty(session, "length", { get: () => session._keys().length });
session._keys = () => {
const keys = [];
for (let i = 0; i < 1000; i += 1) {
const key = session.key(i);
if (key === null) break;
keys.push(key);
}
return keys;
};
// `Object.keys(sessionStorage)` 가 저장된 키를 돌려주도록 실제 브라우저처럼 속성으로도 둔다.
const sessionProxy = new Proxy(session, {
ownKeys: () => [...new Set(session._keys())],
getOwnPropertyDescriptor: (target, key) =>
session._keys().includes(key)
? { enumerable: true, configurable: true, value: target.getItem(key) }
: Reflect.getOwnPropertyDescriptor(target, key),
});
globalThis.window = { localStorage: memoryStorage(), sessionStorage: sessionProxy };
const fakeEl = () => ({
className: "",
textContent: "",
innerHTML: "",
querySelector: () => fakeEl(),
replaceChildren() {},
append() {},
getBoundingClientRect: () => ({ width: 300, height: 200 }),
});
globalThis.document = { createElement: fakeEl };
const asked = [];
const STUBS = {
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} },
"../common_util/common_util_station": { stationLabel: (m) => String(m) },
"./B05_Profile_UI_RouteEdit_Cross_Draw": {
buildCrossSvg: () => ({ svg: {}, layer: {}, width: 1, height: 1, content: {} }),
CROSS_PLOT_PAD: 0,
summarizeCross: () => "",
},
// 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20.
"./B05_Profile_Api_Replan": {
fetchCrossPreview: async (_project, request) => {
asked.push(request.cross_half_width_m ?? null);
return {
chainage_m: request.chainage_m,
cross_half_width_m: request.cross_half_width_m ?? 20,
samples: [],
design: null,
};
},
},
};
const cache = new Map();
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 { createCrossPreview } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"),
);
async function run() {
const cross = createCrossPreview({
projectId: "p-1",
side: { append() {} },
request: () => ({ vertices: [], min_radius_m: 12, station_interval_m: 20 }),
});
const out = {};
out.before = cross.watched();
await cross.open(100);
out.empty = { asked: asked.at(-1), watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:3", "15");
await cross.refresh();
out.applied15 = { asked: asked.at(-1), watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "8.5");
session.setItem("aislo:draft:cross-display:p-2:99", "40");
await cross.refresh();
out.latestRoute = { asked: asked.at(-1), watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "abc");
await cross.refresh();
out.badValue = { asked: asked.at(-1), watched: cross.watched() };
return out;
}
run().then((out) => console.log(JSON.stringify(out)));
@@ -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,
}),
);
@@ -0,0 +1,161 @@
/* 계획노선 편집 창 **측점 라벨 · 호 고르기**(계획서 22-2 · 22-3) 단위검증 헬퍼 —
* `_RouteEdit_Input.ts` · `_RouteEdit_Curve.ts` · B04 `drawStationTicks` 를 트랜스파일해 Node 로 돌린다.
* 브라우저 전용 팔레트·표기 함수는 가짜로 채운다 — 셈만 본다.
*/
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();
const STUBS = {
"@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback },
"@util/common_util_svg": {
stationLabel: (chainage, interval) => `No.${Math.round(chainage / interval)}`,
},
};
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 input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts"));
const curve = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Curve.ts"));
const overlays = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapOverlays.ts"));
// 화면 = 사업지 m × 2, y 뒤집음(실제 `metricToScreen` 처럼 y 가 화면에서 뒤집힌다).
const toScreen = ([x, y]) => [x * 2, 400 - y * 2];
const near = (a, b, tol) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= tol;
// 노드 세 개 · 가운데 R=30 곡선 하나.
const nodes = [
[0, 0],
[100, 0],
[100, 100],
];
const built = curve.buildEditedPolyline(nodes, [true, true, true], [null, 30, null], 12);
const c = built.curves[0];
// ① 호 점열 == 그려지는 선에서 시작~끝점 사이 조각(거울).
const drawn = built.vertices;
const iStart = drawn.findIndex((v) => near(v, c.start, 1e-9));
const iEnd = drawn.findIndex((v) => near(v, c.end, 1e-9));
const arc = curve.curveArcVertices(c);
const arcMatchesDrawn =
arc.length === iEnd - iStart + 1 && arc.every((v, i) => near(v, drawn[iStart + i], 1e-9));
// ② 호 누름 — 호 가운데 · 호에서 5px 비킨 곳(잡힘) · 곧은 선 · 빈 곳.
const mid = drawn[Math.floor((iStart + iEnd) / 2)];
const [mx, my] = toScreen(mid);
const arcAt = (px, py) => input.arcAtScreen(built.curves, toScreen, px, py);
const pick = {
onArc: arcAt(mx, my),
nearArc: arcAt(mx + 3, my + 3),
farFromArc: arcAt(mx - 60, my + 60),
onStraight: arcAt(...toScreen([50, 0])),
empty: arcAt(5, 5),
noCurves: input.arcAtScreen([], toScreen, mx, my),
};
// ③ 반대로 도는(오른쪽 꺾임) 곡선도 같은 판정.
const built2 = curve.buildEditedPolyline(
[
[0, 0],
[100, 0],
[100, -100],
],
[true, true, true],
[null, 30, null],
12,
);
const mid2 = built2.vertices[Math.floor(built2.vertices.length / 2)];
const pick2 = input.arcAtScreen(built2.curves, toScreen, ...toScreen(mid2));
// ④ 측점 라벨 — 가짜 캔버스로 `drawStationTicks` 를 돌려 상자를 받고, 그린 사각형과 같은지 본다.
const rects = [];
const fakeContext = {
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {},
translate() {},
rotate() {},
fillText() {},
fillRect: (x, y, w, h) => rects.push([x, y, w, h]),
measureText: (text) => ({ width: text.length * 6 }),
};
const line = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
];
const boxes = [];
overlays.drawStationTicks(fakeContext, line, {
intervalM: 20,
pxPerMeter: 4, // 간격 80px → 두 칸에 하나(step 2)
toScreen: (x, y) => toScreen([x, y]),
uprightRad: 0.5,
onLabel: (box) => boxes.push(box),
});
const labelsMatchRects =
boxes.length === rects.length &&
boxes.every(
(box, i) =>
Math.abs(rects[i][0] - (box.x - box.half)) < 1e-9 &&
Math.abs(rects[i][1] - (box.y - 8)) < 1e-9 &&
Math.abs(rects[i][2] - box.half * 2) < 1e-9,
);
// ⑤ 라벨 누름 — 라벨 가운데 · 라벨 밖 · 돌린 상자 모서리 안팎 · 눈금 우선.
const target = boxes[1];
const stationLine = [
[0, 0],
[100, 0],
];
const stationAt = (px, py, labels) =>
input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, labels);
const rot = (dx, dy, rad) => [
dx * Math.cos(rad) - dy * Math.sin(rad),
dx * Math.sin(rad) + dy * Math.cos(rad),
];
// 돌린 상자의 오른쪽 위 모서리 안쪽(-1px) · 바깥쪽(+3px) — 세워 본 좌표(half, 8)를 상자 각만큼 돌린 자리.
const insideCorner = rot(target.half - 1, 7, target.uprightRad);
const outsideCorner = rot(target.half + 3, 11, target.uprightRad);
const tickScreen = toScreen([40, 0]); // 라벨이 그려진 측점(0 · 40 · 80)의 눈금
const hiddenTickScreen = toScreen([20, 0]); // 솎여 라벨이 없는 측점의 눈금
const label = {
count: boxes.length,
chainages: boxes.map((b) => b.chainage),
onLabel: stationAt(target.x, target.y, boxes),
insideCorner: stationAt(target.x + insideCorner[0], target.y + insideCorner[1], boxes),
outsideCorner: stationAt(target.x + outsideCorner[0], target.y + outsideCorner[1], boxes),
// 눈금 자리를 누르면 다른 측점 라벨 상자와 겹쳐도 눈금 몫이다.
tickBeatsLabel: stationAt(tickScreen[0], tickScreen[1], [
target,
{ chainage: 80, x: tickScreen[0], y: tickScreen[1], half: 30, uprightRad: 0 },
]),
// 안 보이는(솎인) 눈금은 누름을 가로채지 않는다 — 라벨 정보가 없을 때만 전부 본다.
hiddenTick: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], boxes),
hiddenTickNoInfo: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], []),
emptyNoLabels: stationAt(target.x, target.y, []),
emptyElsewhere: stationAt(5, 5, boxes),
};
process.stdout.write(
JSON.stringify({ arcMatchesDrawn, pick, pick2, node: c.node_first, labelsMatchRects, label }),
);
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값.
선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다.
Node 헬퍼(`helper_b05_routeedit_cross_width.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_cross_width.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_측점을_고르기_전에는_선이_없다(results):
assert results["before"] is None
def test_적용한_적_없으면_서버_기본값_양쪽(results):
assert results["empty"]["asked"] is None
assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20}
def test_적용값이_있으면_그_값_양쪽(results):
assert results["applied15"]["asked"] == 15
assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15}
def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results):
assert results["latestRoute"]["asked"] == 8.5
assert results["latestRoute"]["watched"]["leftM"] == 8.5
def test_망가진_값은_기본값으로(results):
assert results["badValue"]["asked"] is None
assert results["badValue"]["watched"]["rightM"] == 20
+41 -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,43 @@ 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"] == [] # 점 하나로는 선이 안 됨
def test_도구_막대는_노선_추천_하나만():
"""편집 창 도구 막대 — [노선 추천] 하나 · 추천 묶음(로직 단추 · 슬라이더 · 제약 칸) 없음 ·
「연 상태로」 없음. 그 묶음은 모달(`_Recommend`)로 옮겼다."""
page = os.path.join(HERE, "..", "..", "B05_Profile")
chrome = open(os.path.join(page, "B05_Profile_UI_RouteEdit_Chrome.ts"), encoding="utf-8").read()
assert chrome.count('data-act="recommend"') == 1
assert "연 상태로" not in chrome and "history-reset" not in chrome
assert chrome.index('data-act="recommend"') < chrome.index('data-act="contour-minor"')
initial = open(
os.path.join(page, "B05_Profile_UI_RouteEdit_Initial.ts"), encoding="utf-8"
).read()
assert "data-criteria" not in initial and "data-weight" not in initial
recommend = open(
os.path.join(page, "B05_Profile_UI_RouteEdit_Recommend.ts"), encoding="utf-8"
).read()
for name in ("follow", "earthwork", "gentle", "weighted"):
assert f'"{name}"' in recommend
@@ -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):
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **측점 라벨 고르기**(계획서 22-2) · **호 고르기**(22-3) 회귀검증.
Node 헬퍼(`helper_b05_routeedit_pick.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_pick.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_호_점열은_그려지는_선의_시작_끝_조각과_같다(results):
assert results["arcMatchesDrawn"] is True
def test_호를_누르면_그_곡선_노드_번호(results):
pick = results["pick"]
assert pick["onArc"] == results["node"]
assert pick["nearArc"] == results["node"]
assert results["pick2"] == results["node"] # 반대로 도는 곡선도
def test_곧은_선_빈_곳_곡선_없음은_안_고름(results):
pick = results["pick"]
assert pick["farFromArc"] == -1
assert pick["onStraight"] == -1
assert pick["empty"] == -1
assert pick["noCurves"] == -1
def test_라벨_상자는_실제로_그린_사각형과_같다(results):
assert results["labelsMatchRects"] is True
assert results["label"]["chainages"] == [0, 40, 80]
def test_라벨_글자를_누르면_그_측점(results):
label = results["label"]
assert label["onLabel"] == 40
assert label["insideCorner"] == 40 # 돌린 상자 모서리 안쪽
assert label["outsideCorner"] is None # 모서리 바깥
def test_눈금이_라벨보다_먼저(results):
assert results["label"]["tickBeatsLabel"] == 40
def test_솎여_안_보이는_눈금은_안_잡힘(results):
assert results["label"]["hiddenTick"] is None
assert results["label"]["hiddenTickNoInfo"] == 20 # 라벨 정보가 없으면 예전처럼 전부
def test_라벨이_없거나_빈_곳은_안_고름(results):
label = results["label"]
assert label["emptyNoLabels"] is None
assert label["emptyElsewhere"] is None