diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 2761b114..782cc902 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -15,13 +15,32 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */ const REPLAN_TIMEOUT_MS = 15 * 60 * 1000; +/** 사용자가 잡아 옮기는 제어점 하나 — 서버가 이 노드로 폴리라인을 만든다. */ +export interface RoutePlanNode { + x: number; + y: number; + /** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */ + inner_angle_deg: number | null; + /** 이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리(내각 155° 이상)는 null. */ + radius_m: number | null; + tangent_m: number | null; + /** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */ + violations: string[]; +} + export interface RoutePlanResponse { status: string; project_id: string; - /** 예상노선(원본) 정점 [[x, y], …] — 사업지 좌표계(m). */ + /** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */ expected: Array<[number, number]>; - /** 계획노선(수정본). 고친 적이 없으면 예상노선과 같은 값. */ + /** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */ planned: Array<[number, number]>; + /** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */ + nodes: RoutePlanNode[]; + /** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */ + min_radius_m: number; + curve_count: number; + violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ edited: boolean; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 6fbbae78..60d15825 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -76,7 +76,17 @@ export async function openRouteEditModal( const context = canvas.getContext("2d")!; let expected: Vertex[] = []; + /** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */ + let plannedLine: Vertex[] = []; + /** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */ let planned: Vertex[] = []; + /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ + let nodeInfo: Array<{ + radius_m: number | null; + inner_angle_deg: number | null; + violations: string[]; + }> = []; + let minRadiusM = 0; let meta: VWorldMeta | null = null; let sheets: PreparedLayer[] = []; let view: ViewState = { @@ -167,19 +177,31 @@ export async function openRouteEditModal( style.getPropertyValue("--color-text-secondary") || "#9ca3af", 1.6, ); - strokePolyline(planned, [], style.getPropertyValue("--map-route") || "#f97316", 2.4); + // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. + // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. + strokePolyline( + plannedLine.length ? plannedLine : planned, + [], + style.getPropertyValue("--map-route") || "#f97316", + 2.4, + ); context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; context.lineWidth = 1; - for (const vertex of planned) { + planned.forEach((vertex, index) => { const [x, y] = toScreen(vertex); + // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). + const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0; + context.fillStyle = bad + ? style.getPropertyValue("--color-danger") || "#dc2626" + : style.getPropertyValue("--map-route") || "#f97316"; context.beginPath(); context.arc(x, y, NODE_R, 0, Math.PI * 2); context.fill(); context.stroke(); - } + }); context.restore(); } @@ -218,6 +240,21 @@ export async function openRouteEditModal( return best; } + /** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다. + * 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */ + function markEdited(): void { + plannedLine = []; + nodeInfo = []; + } + + /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ + function curveHint(): string { + if (!nodeInfo.length) return minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; + const bad = nodeInfo.filter((node) => node.violations.length).length; + const curves = nodeInfo.filter((node) => node.radius_m !== null).length; + return `곡선 ${curves}곳(R ${minRadiusM}m)${bad ? ` · 기준 미달 ${bad}곳` : ""}`; + } + // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; @@ -238,6 +275,7 @@ export async function openRouteEditModal( const py = event.clientY - rect.top; if (dragNode >= 0) { planned[dragNode] = toMetric(px, py); + markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. draw(); return; } @@ -269,7 +307,8 @@ export async function openRouteEditModal( const segment = segmentAt(px, py); if (segment < 0) return; planned.splice(segment + 1, 0, toMetric(px, py)); - status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다.`; + markEdited(); + status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다. ${curveHint()}`; draw(); }); @@ -283,7 +322,8 @@ export async function openRouteEditModal( return; } planned.splice(index, 1); - status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다.`; + markEdited(); + status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다. ${curveHint()}`; draw(); }); @@ -346,7 +386,18 @@ export async function openRouteEditModal( ]); if (closed) return; expected = plan.expected as Vertex[]; - planned = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]); + plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]); + // 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다 + // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). + const nodes = plan.nodes ?? []; + planned = nodes.map((node) => [node.x, node.y] as Vertex); + nodeInfo = nodes.map((node) => ({ + radius_m: node.radius_m, + inner_angle_deg: node.inner_angle_deg, + violations: node.violations ?? [], + })); + minRadiusM = plan.min_radius_m ?? 0; + if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; const normalizer = createNormalizer(drainage.meta); sheets = drainage.layers @@ -367,7 +418,9 @@ export async function openRouteEditModal( view.height, ); view = { ...view, ...fitted }; - status.textContent = `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "예상노선과 같음"}`; + status.textContent = + `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + + curveHint(); draw(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";