Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user