실화면에서 잡음 — 곡선 하나만 지워도 나머지 반지름이 **R 12~199m → 전부 12m** 로 되돌아갔음. 원인은 편집 모델과 저장 모델이 다른 것이었음. 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는데, 그 곡선의 교각점은 앞뒤 직선을 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아님**. 편집은 「꺾임점 하나 = 곡선 하나」로 표현되므로, 묶인 채로 두면 한 번만 손대도 묶음이 낱개로 흩어지고 각 자리가 하한으로 떨어졌음. 모달이 자료를 읽을 때 **묶인 구간을 그 교각점 하나로 갈아 끼우게** 함 — 안쪽 꺾임점은 그 곡선이 대신하므로 뺌. 앞뒤 직선과 반지름이 그대로라 그려지는 선은 똑같고, 이제 손대도 안 흩어짐. 서버가 고른 반지름도 함께 들고 가 [확인] 때 되돌려 보냄. 시험 tmp/tests/test_route_polyline_apex_roundtrip.py 2건 — 갈아 끼워도 곡선 수·반지름이 같고 선이 **1mm 안**에서 일치, 그리고 안 갈아 끼우면 실제로 하한으로 눌리는 것(왜 필요한지)도 못박음. 실화면 확인(용화) — 되돌린 뒤 곡선 26곳 R 최대 198.9m → 곡선 하나 지우고 [확인] → 곡선 25곳, **R 42.5·51·198.9m 그대로**. tsc --noEmit 통과 · pytest 432 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
741 lines
31 KiB
TypeScript
741 lines
31 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit.ts
|
|
* 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다.
|
|
*
|
|
* 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지
|
|
* 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을
|
|
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**.
|
|
*
|
|
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
|
|
* [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산).
|
|
*
|
|
* 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은
|
|
* 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
computeMapRect,
|
|
computeRouteView,
|
|
drawPreparedLayer,
|
|
createNormalizer,
|
|
metricToScreen,
|
|
prepareLayer,
|
|
type PreparedLayer,
|
|
type ViewState,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
|
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
|
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
|
|
import "./B05_Profile_UI_Style_RouteEdit.css";
|
|
|
|
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
|
|
const NODE_HIT_PX = 9;
|
|
/** 노드 반지름(px). */
|
|
const NODE_R = 4;
|
|
/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */
|
|
const DRAG_THRESHOLD_PX = 3;
|
|
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
|
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
|
const CURVE_HANDLE_PX = 5;
|
|
|
|
type Vertex = [number, number];
|
|
|
|
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
|
export async function openRouteEditModal(
|
|
projectId: string,
|
|
onApplied: () => void | Promise<void>,
|
|
): Promise<void> {
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "b05-routeedit";
|
|
overlay.innerHTML = `
|
|
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
|
|
<div class="b05-routeedit__head">
|
|
<strong>계획노선 편집</strong>
|
|
<span class="b05-routeedit__hint">
|
|
노드를 끌어 옮기고, 선을 두 번 누르면 노드가 생깁니다. 노드 오른쪽 클릭은 삭제.
|
|
</span>
|
|
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
|
</div>
|
|
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
|
|
<div class="b05-routeedit__curve" hidden>
|
|
<span class="b05-routeedit__curve-label">고른 곡선</span>
|
|
<label class="b05-routeedit__curve-field">R
|
|
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
|
<span>m</span>
|
|
</label>
|
|
<span class="b05-routeedit__curve-info"></span>
|
|
<button type="button" class="b05-routeedit__btn" data-act="curve-off">곡선 지우기</button>
|
|
<button type="button" class="b05-routeedit__btn" data-act="curve-on" hidden>곡선 넣기</button>
|
|
<button type="button" class="b05-routeedit__btn" data-act="curve-auto">반지름 자동</button>
|
|
</div>
|
|
<div class="b05-routeedit__foot">
|
|
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
|
<span class="b05-routeedit__legend">
|
|
<i class="is-expected"></i> 예상노선(원본)
|
|
<i class="is-planned"></i> 계획노선
|
|
</span>
|
|
<button type="button" class="b05-routeedit__btn" data-act="reset">예상노선으로</button>
|
|
<button type="button" class="b05-routeedit__btn" data-act="cancel">취소</button>
|
|
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply">확인</button>
|
|
</div>
|
|
<div class="b05-routeedit__busy" hidden><span></span></div>
|
|
</div>`;
|
|
document.body.append(overlay);
|
|
|
|
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
|
|
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
|
|
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
|
|
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 curveInfo: RoutePlanCurve[] = [];
|
|
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
|
|
let curveOn: boolean[] = [];
|
|
let curveRadius: Array<number | null> = [];
|
|
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
|
let picked = -1;
|
|
let meta: VWorldMeta | null = null;
|
|
let sheets: PreparedLayer[] = [];
|
|
let view: ViewState = {
|
|
width: 0,
|
|
height: 0,
|
|
scale: 1,
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
mapRect: computeMapRect(null, 0, 0),
|
|
};
|
|
let closed = false;
|
|
|
|
const close = (): void => {
|
|
closed = true;
|
|
window.removeEventListener("resize", resize);
|
|
overlay.remove();
|
|
};
|
|
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
|
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close);
|
|
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
|
|
|
|
function resize(): void {
|
|
if (closed) return;
|
|
const wrap = canvas.parentElement!;
|
|
const ratio = window.devicePixelRatio || 1;
|
|
const width = wrap.clientWidth;
|
|
const height = wrap.clientHeight;
|
|
canvas.width = Math.round(width * ratio);
|
|
canvas.height = Math.round(height * ratio);
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
|
|
draw();
|
|
}
|
|
window.addEventListener("resize", resize);
|
|
|
|
const toScreen = (vertex: Vertex): [number, number] =>
|
|
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
|
|
|
|
/** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */
|
|
function toMetric(px: number, py: number): Vertex {
|
|
if (!meta) return [0, 0];
|
|
const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
|
|
const [x1, y1] = metricToScreen(
|
|
meta,
|
|
view,
|
|
meta.x_min + meta.width_meters,
|
|
meta.y_min + meta.height_meters,
|
|
);
|
|
const sx = (x1 - x0) / (meta.width_meters || 1);
|
|
const sy = (y1 - y0) / (meta.height_meters || 1);
|
|
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
|
|
}
|
|
|
|
function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void {
|
|
if (points.length < 2) return;
|
|
context.save();
|
|
context.setLineDash(dash);
|
|
context.strokeStyle = color;
|
|
context.lineWidth = width;
|
|
context.beginPath();
|
|
points.forEach((vertex, index) => {
|
|
const [x, y] = toScreen(vertex);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
|
|
function draw(): void {
|
|
if (closed) return;
|
|
const style = getComputedStyle(document.documentElement);
|
|
context.clearRect(0, 0, view.width, view.height);
|
|
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
|
|
context.fillRect(0, 0, view.width, view.height);
|
|
|
|
context.save();
|
|
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
|
|
context.lineWidth = 0.8;
|
|
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
|
|
context.restore();
|
|
|
|
strokePolyline(
|
|
expected,
|
|
[6, 5],
|
|
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
|
|
1.6,
|
|
);
|
|
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
|
|
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
|
|
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;
|
|
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, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.stroke();
|
|
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
|
|
if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) {
|
|
context.save();
|
|
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
|
context.beginPath();
|
|
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.restore();
|
|
}
|
|
});
|
|
|
|
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
|
|
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
|
context.lineWidth = 2;
|
|
curveInfo.forEach((curve) => {
|
|
const on = curveOn[curve.node_first] !== false;
|
|
if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다.
|
|
[curve.start, curve.end].forEach((point) => {
|
|
const [x, y] = toScreen([point[0], point[1]]);
|
|
context.beginPath();
|
|
context.rect(
|
|
x - CURVE_HANDLE_PX,
|
|
y - CURVE_HANDLE_PX,
|
|
CURVE_HANDLE_PX * 2,
|
|
CURVE_HANDLE_PX * 2,
|
|
);
|
|
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
|
context.fill();
|
|
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
|
context.stroke();
|
|
});
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */
|
|
function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null {
|
|
let best: { curve: number; end: "start" | "end" } | null = null;
|
|
let bestDistance = NODE_HIT_PX + 2;
|
|
curveInfo.forEach((curve, index) => {
|
|
(["start", "end"] as const).forEach((which) => {
|
|
const point = which === "start" ? curve.start : curve.end;
|
|
const [x, y] = toScreen([point[0], point[1]]);
|
|
const distance = Math.hypot(x - px, y - py);
|
|
if (distance <= bestDistance) {
|
|
bestDistance = distance;
|
|
best = { curve: index, end: which };
|
|
}
|
|
});
|
|
});
|
|
return best;
|
|
}
|
|
|
|
/** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」.
|
|
*
|
|
* 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께
|
|
* 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다).
|
|
*
|
|
* 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다.
|
|
* 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이
|
|
* T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다.
|
|
*
|
|
* ⚠ 이 셈은 **서버의 반대 방향**이다(서버는 교각점·R 에서 접선점을 낸다). 두 벌이 아니라
|
|
* 짝이며, 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다.
|
|
*/
|
|
function dragHandleTo(
|
|
curveIndex: number,
|
|
which: "start" | "end",
|
|
to: Vertex,
|
|
): { apex: Vertex; radius: number } | null {
|
|
const curve = curveInfo[curveIndex];
|
|
if (!curve) return null;
|
|
const node = curve.node_first;
|
|
const before = planned[node - 1];
|
|
const after = planned[node + 1];
|
|
if (!before || !after) return null;
|
|
const oldApex: Vertex = [curve.apex[0], curve.apex[1]];
|
|
// 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다.
|
|
const apex =
|
|
which === "start"
|
|
? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다
|
|
: intersect(before, oldApex, to, after); // 나가는 직선을 돌린다
|
|
if (!apex) return null;
|
|
const inner = innerAngleDeg(before, apex, after);
|
|
const halfTan = Math.tan(((180 - inner) * Math.PI) / 360);
|
|
if (!(halfTan > 1e-9)) return null;
|
|
const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]);
|
|
const radius = tangent / halfTan;
|
|
if (!(radius > 0) || !Number.isFinite(radius)) return null;
|
|
return { apex, radius };
|
|
}
|
|
|
|
/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
|
|
function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
|
|
const dx1 = a2[0] - a1[0];
|
|
const dy1 = a2[1] - a1[1];
|
|
const dx2 = b2[0] - b1[0];
|
|
const dy2 = b2[1] - b1[1];
|
|
const denominator = dx1 * dy2 - dy1 * dx2;
|
|
if (Math.abs(denominator) <= 1e-12) return null;
|
|
const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator;
|
|
return [a1[0] + dx1 * t, a1[1] + dy1 * t];
|
|
}
|
|
|
|
/** 세 점이 이루는 내각(도). 일직선이면 180. */
|
|
function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number {
|
|
const ax = before[0] - at[0];
|
|
const ay = before[1] - at[1];
|
|
const bx = after[0] - at[0];
|
|
const by = after[1] - at[1];
|
|
const la = Math.hypot(ax, ay);
|
|
const lb = Math.hypot(bx, by);
|
|
if (la <= 0 || lb <= 0) return 180;
|
|
const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb)));
|
|
return (Math.acos(cosine) * 180) / Math.PI;
|
|
}
|
|
|
|
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */
|
|
function nodeAt(px: number, py: number): number {
|
|
let best = -1;
|
|
let bestDistance = NODE_HIT_PX;
|
|
planned.forEach((vertex, index) => {
|
|
const [x, y] = toScreen(vertex);
|
|
const distance = Math.hypot(x - px, y - py);
|
|
if (distance <= bestDistance) {
|
|
bestDistance = distance;
|
|
best = index;
|
|
}
|
|
});
|
|
return best;
|
|
}
|
|
|
|
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */
|
|
function segmentAt(px: number, py: number): number {
|
|
let best = -1;
|
|
let bestDistance = 12;
|
|
for (let index = 0; index < planned.length - 1; index += 1) {
|
|
const [ax, ay] = toScreen(planned[index]);
|
|
const [bx, by] = toScreen(planned[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 = index;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다.
|
|
* 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */
|
|
function markEdited(): void {
|
|
plannedLine = [];
|
|
nodeInfo = [];
|
|
curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다.
|
|
}
|
|
|
|
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
|
|
function curveHint(): string {
|
|
const off = curveOn.filter(
|
|
(on, index) => !on && index > 0 && index < planned.length - 1,
|
|
).length;
|
|
const forced = curveRadius.filter((value) => value !== null).length;
|
|
const edits = [off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : ""]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
if (!nodeInfo.length) {
|
|
const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : "";
|
|
return edits ? `${base}${base ? " · " : ""}${edits}` : base;
|
|
}
|
|
const bad = nodeInfo.filter((node) => node.violations.length).length;
|
|
const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length;
|
|
return (
|
|
`곡선 ${curves}곳(하한 R ${minRadiusM}m)` +
|
|
`${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}`
|
|
);
|
|
}
|
|
|
|
// ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ──
|
|
const curveBar = overlay.querySelector<HTMLElement>(".b05-routeedit__curve")!;
|
|
const curveLabel = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
|
|
const curveRadiusInput = curveBar.querySelector<HTMLInputElement>(
|
|
".b05-routeedit__curve-radius",
|
|
)!;
|
|
const curveInfoText = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
|
const curveOffBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-off"]')!;
|
|
const curveOnBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-on"]')!;
|
|
const curveAutoBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-auto"]')!;
|
|
|
|
/** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */
|
|
function syncCurveBar(): void {
|
|
const editable = picked > 0 && picked < planned.length - 1;
|
|
curveBar.hidden = !editable;
|
|
if (!editable) return;
|
|
const on = curveOn[picked] !== false;
|
|
curveLabel.textContent = `${picked + 1}번째 꺾임점`;
|
|
curveOffBtn.hidden = !on;
|
|
curveOnBtn.hidden = on;
|
|
curveRadiusInput.disabled = !on;
|
|
curveAutoBtn.disabled = !on || curveRadius[picked] === null;
|
|
const forced = curveRadius[picked];
|
|
const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null;
|
|
curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10);
|
|
const inner = nodeInfo[picked]?.inner_angle_deg;
|
|
curveInfoText.textContent = on
|
|
? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
|
|
` · 법정 하한 ${minRadiusM}m`
|
|
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
|
}
|
|
|
|
curveRadiusInput.addEventListener("change", () => {
|
|
if (picked < 0) return;
|
|
const value = Number(curveRadiusInput.value);
|
|
curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null;
|
|
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`;
|
|
draw();
|
|
});
|
|
|
|
curveOffBtn.addEventListener("click", () => {
|
|
if (picked < 0) return;
|
|
curveOn[picked] = false;
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`;
|
|
draw();
|
|
});
|
|
|
|
curveOnBtn.addEventListener("click", () => {
|
|
if (picked < 0) return;
|
|
curveOn[picked] = true;
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`;
|
|
draw();
|
|
});
|
|
|
|
curveAutoBtn.addEventListener("click", () => {
|
|
if (picked < 0) return;
|
|
curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다.
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`;
|
|
draw();
|
|
});
|
|
|
|
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
|
let dragNode = -1;
|
|
/** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */
|
|
let dragHandle: { curve: number; end: "start" | "end" } | null = null;
|
|
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
|
|
|
canvas.addEventListener("pointerdown", (event) => {
|
|
if (event.button !== 0) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = event.clientX - rect.left;
|
|
const py = event.clientY - rect.top;
|
|
// 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작).
|
|
dragHandle = handleAt(px, py);
|
|
dragNode = dragHandle ? -1 : nodeAt(px, py);
|
|
if (dragHandle) {
|
|
picked = curveInfo[dragHandle.curve]?.node_first ?? -1;
|
|
syncCurveBar();
|
|
draw();
|
|
} else if (dragNode >= 0) {
|
|
picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다.
|
|
syncCurveBar();
|
|
draw();
|
|
} else {
|
|
panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY };
|
|
}
|
|
canvas.setPointerCapture(event.pointerId);
|
|
});
|
|
|
|
canvas.addEventListener("pointermove", (event) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = event.clientX - rect.left;
|
|
const py = event.clientY - rect.top;
|
|
if (dragHandle) {
|
|
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
|
|
const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py));
|
|
if (moved) {
|
|
const node = curveInfo[dragHandle.curve].node_first;
|
|
planned[node] = moved.apex;
|
|
curveRadius[node] = Math.round(moved.radius * 100) / 100;
|
|
curveOn[node] = true;
|
|
picked = node;
|
|
// 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다.
|
|
const which = dragHandle.end === "start" ? "start" : "end";
|
|
curveInfo[dragHandle.curve] = {
|
|
...curveInfo[dragHandle.curve],
|
|
apex: [moved.apex[0], moved.apex[1]],
|
|
radius_m: moved.radius,
|
|
[which]: toMetric(px, py),
|
|
} as (typeof curveInfo)[number];
|
|
plannedLine = [];
|
|
nodeInfo = [];
|
|
syncCurveBar();
|
|
draw();
|
|
}
|
|
return;
|
|
}
|
|
if (dragNode >= 0) {
|
|
planned[dragNode] = toMetric(px, py);
|
|
markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다.
|
|
draw();
|
|
return;
|
|
}
|
|
if (panFrom) {
|
|
if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return;
|
|
view = {
|
|
...view,
|
|
offsetX: panFrom.offsetX + (px - panFrom.x),
|
|
offsetY: panFrom.offsetY + (py - panFrom.y),
|
|
};
|
|
draw();
|
|
return;
|
|
}
|
|
canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default";
|
|
});
|
|
|
|
const endDrag = (event: PointerEvent): void => {
|
|
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
|
dragNode = -1;
|
|
dragHandle = null;
|
|
panFrom = null;
|
|
};
|
|
canvas.addEventListener("pointerup", endDrag);
|
|
canvas.addEventListener("pointercancel", endDrag);
|
|
|
|
canvas.addEventListener("dblclick", (event) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = event.clientX - rect.left;
|
|
const py = event.clientY - rect.top;
|
|
const segment = segmentAt(px, py);
|
|
if (segment < 0) return;
|
|
planned.splice(segment + 1, 0, toMetric(px, py));
|
|
// 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다.
|
|
curveOn.splice(segment + 1, 0, true);
|
|
curveRadius.splice(segment + 1, 0, null);
|
|
picked = segment + 1;
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`;
|
|
draw();
|
|
});
|
|
|
|
canvas.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
|
if (index < 0) return;
|
|
if (planned.length <= 2) {
|
|
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
|
return;
|
|
}
|
|
planned.splice(index, 1);
|
|
curveOn.splice(index, 1);
|
|
curveRadius.splice(index, 1);
|
|
picked = -1;
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`;
|
|
draw();
|
|
});
|
|
|
|
canvas.addEventListener(
|
|
"wheel",
|
|
(event) => {
|
|
event.preventDefault();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = event.clientX - rect.left;
|
|
const py = event.clientY - rect.top;
|
|
const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2;
|
|
const nextScale = Math.max(1, Math.min(2000, view.scale * factor));
|
|
const ratio = nextScale / view.scale;
|
|
// 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다.
|
|
view = {
|
|
...view,
|
|
scale: nextScale,
|
|
offsetX: px - (px - view.offsetX) * ratio,
|
|
offsetY: py - (py - view.offsetY) * ratio,
|
|
};
|
|
draw();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
|
busy.hidden = false;
|
|
busy.querySelector("span")!.textContent =
|
|
`${label} — 배수유역부터 다시 계산 중입니다. 몇 분 걸립니다.`;
|
|
try {
|
|
await task();
|
|
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
|
clearDrafts(projectId);
|
|
clearResults(projectId);
|
|
showToast("노선을 다시 계산했습니다.", "success");
|
|
close();
|
|
await onApplied();
|
|
} catch (error) {
|
|
busy.hidden = true;
|
|
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
|
|
}
|
|
}
|
|
|
|
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
|
|
if (planned.length < 2) {
|
|
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
|
return;
|
|
}
|
|
void runHeavy("계획노선 반영", () =>
|
|
replanRoute(
|
|
projectId,
|
|
planned.map(([x, y], index) => ({
|
|
x,
|
|
y,
|
|
curve: curveOn[index] !== false,
|
|
radius_m: curveRadius[index] ?? null,
|
|
})),
|
|
),
|
|
);
|
|
});
|
|
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
|
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
|
});
|
|
|
|
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
|
try {
|
|
const [plan, drainage] = await Promise.all([
|
|
fetchRoutePlan(projectId),
|
|
fetchDrainageLayers(projectId, () => {}),
|
|
]);
|
|
if (closed) return;
|
|
expected = plan.expected as Vertex[];
|
|
plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
|
|
// 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다
|
|
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
|
|
const nodes = plan.nodes ?? [];
|
|
minRadiusM = plan.min_radius_m ?? 0;
|
|
// 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07).
|
|
//
|
|
// 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을
|
|
// 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 =
|
|
// 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로
|
|
// 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m).
|
|
//
|
|
// 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로
|
|
// 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다.
|
|
const curves = plan.curves ?? [];
|
|
const replaced = new Map<number, (typeof curves)[number]>();
|
|
const dropped = new Set<number>();
|
|
curves.forEach((curve) => {
|
|
replaced.set(curve.node_first, curve);
|
|
for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) {
|
|
dropped.add(index);
|
|
}
|
|
});
|
|
planned = [];
|
|
nodeInfo = [];
|
|
curveOn = [];
|
|
curveRadius = [];
|
|
curveInfo = [];
|
|
nodes.forEach((node, index) => {
|
|
if (dropped.has(index)) return;
|
|
const curve = replaced.get(index);
|
|
const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y];
|
|
const seat = planned.length;
|
|
planned.push(at);
|
|
nodeInfo.push({
|
|
radius_m: curve ? curve.radius_m : node.radius_m,
|
|
inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg,
|
|
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
|
|
});
|
|
curveOn.push(true);
|
|
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
|
|
curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
|
|
if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat });
|
|
});
|
|
picked = -1;
|
|
syncCurveBar();
|
|
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
|
meta = drainage.meta;
|
|
const normalizer = createNormalizer(drainage.meta);
|
|
sheets = drainage.layers
|
|
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
|
|
.filter((layer): layer is PreparedLayer => layer !== null);
|
|
resize();
|
|
const xs = planned.map((vertex) => vertex[0]);
|
|
const ys = planned.map((vertex) => vertex[1]);
|
|
const fitted = computeRouteView(
|
|
meta,
|
|
{
|
|
x_min: Math.min(...xs),
|
|
x_max: Math.max(...xs),
|
|
y_min: Math.min(...ys),
|
|
y_max: Math.max(...ys),
|
|
},
|
|
view.width,
|
|
view.height,
|
|
);
|
|
view = { ...view, ...fitted };
|
|
status.textContent =
|
|
`노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
|
|
curveHint();
|
|
draw();
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
|
|
}
|
|
}
|