사용자 지시 5건. ⚠ **화면 검증은 아직 못 함** — 다른 창의 분배 조율로 git sync 를 먼저 하게 되어 미커밋으로 두지 않으려고 먼저 커밋함. 타입 검사·시험은 통과 상태임. - **1. 패널을 화면으로** — `document.body` 에 `position: fixed` 로 띄움. 모달이 `overflow: hidden` 이라 안에 두면 가장자리에서 잘렸음. 이제 모달 밖으로도 넘어감. - **2. 너무 가까움 + 위치 제어** — 여백 14 → 40px 로 넓히고 방위별 상자 반지름만큼 더 밀어냄. **머리를 잡아 손으로 옮길 수 있음**. 옮긴 자리는 꺾임점 기준으로 기억해 지도를 옮기거나 확대해도 따라오고, 다른 꺾임점을 고르면 자동 자리로 돌아감. - **3. 자동 위치를 16방위로** — 4방위는 대각 자리에서 곡선을 물었음. 곡선 중심의 반대 방향을 22.5° 단위로 맞춤. - **4. 「법정 하한」 삭제**, 「칸을 비우면 자동」을 **다음 줄**로 뺌. 대신 무엇을 붙들고 있는지(고정 없음 / 반지름 고정 / 곡선 길이 고정)를 그 줄에 냄. - **5. 반지름·곡선 길이 고정 단추** — 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직임. 그래서 **셋 중 하나**임: 자동 / R 고정(길이가 따라감) / 길이 고정(R 을 L/Δ 로 다시 잡음). 붙들어 둔 곡선은 **손잡이로도 안 바뀜**(끌면 R 이 바뀌므로 집기에서 제외). 잠금·길이는 되돌리기 사진에도 담김. - 새 모듈 `_Edits.ts` — 잠금 반영·상태줄 요약·서버 응답 펴기. 본체 692줄로 700줄 유지. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
693 lines
30 KiB
TypeScript
693 lines
30 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 { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
|
|
import {
|
|
bindRouteEditNavigation,
|
|
contourBandRect,
|
|
handleAtScreen,
|
|
nodeAtScreen,
|
|
segmentAtScreen,
|
|
} from "./B05_Profile_UI_RouteEdit_Input";
|
|
import {
|
|
centerDirectionOf,
|
|
createCurveLabel,
|
|
deflectionRad,
|
|
} from "./B05_Profile_UI_RouteEdit_Label";
|
|
import {
|
|
applyArcLocks,
|
|
curveSummary,
|
|
flattenServerPlan,
|
|
type CurveLock,
|
|
} from "./B05_Profile_UI_RouteEdit_Edits";
|
|
import {
|
|
bindHistoryControls,
|
|
createRouteEditHistory,
|
|
type RouteEditHistory,
|
|
type RouteEditSnapshot,
|
|
} from "./B05_Profile_UI_RouteEdit_History";
|
|
import "./B05_Profile_UI_Style_RouteEdit.css";
|
|
|
|
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
|
|
const NODE_HIT_PX = 9;
|
|
/** 노드 반지름(px). */
|
|
const NODE_R = 4;
|
|
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
|
|
const SEGMENT_HIT_PX = 12;
|
|
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
|
|
*
|
|
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
|
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
|
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
|
const CONTOUR_BAND_M = 300;
|
|
/** 곡선 시작·끝점 손잡이 크기(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">
|
|
노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
|
|
노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
|
|
</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__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="undo" title="되돌리기 (Ctrl+Z)"
|
|
disabled>↶ 되돌리기</button>
|
|
<button type="button" class="b05-routeedit__btn" data-act="redo" title="다시하기 (Ctrl+Y)"
|
|
disabled>↷ 다시하기</button>
|
|
<button type="button" class="b05-routeedit__btn" data-act="history-reset"
|
|
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
|
<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> = [];
|
|
/** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m).
|
|
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
|
let curveLock: CurveLock[] = [];
|
|
let curveArc: Array<number | null> = [];
|
|
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
|
let picked = -1;
|
|
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
|
let history: RouteEditHistory | null = null;
|
|
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);
|
|
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
|
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
|
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();
|
|
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
|
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
|
const band = meta
|
|
? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M)
|
|
: null;
|
|
if (band) {
|
|
context.beginPath();
|
|
context.rect(band.x, band.y, band.width, band.height);
|
|
context.clip();
|
|
}
|
|
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) => {
|
|
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
|
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
|
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
|
const on = curveOn[curve.node_first] !== false;
|
|
if (!on) return; // 곡선을 지운 자리에는 접선점도 없다.
|
|
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
|
const isPicked = curve.node_first === picked;
|
|
[curve.start, curve.end].forEach((point) => {
|
|
const [x, y] = toScreen([point[0], point[1]]);
|
|
context.beginPath();
|
|
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
|
context.rect(x - size, y - size, size * 2, size * 2);
|
|
context.fillStyle = isPicked
|
|
? style.getPropertyValue("--map-route") || "#f97316"
|
|
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
|
context.fill();
|
|
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
|
context.stroke();
|
|
});
|
|
});
|
|
context.restore();
|
|
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
|
syncCurveBar();
|
|
}
|
|
|
|
/** 잡기 — 셈은 `_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),
|
|
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 사용자 지적 ①②).
|
|
*
|
|
* 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부
|
|
* 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다.
|
|
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
|
|
* 서버가 정본으로 다시 내는 것은 그대로다. */
|
|
function markEdited(): void {
|
|
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
|
|
applyArcLocks(planned, curveLock, curveArc, curveRadius);
|
|
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
|
|
plannedLine = built.vertices;
|
|
curveInfo = built.curves;
|
|
nodeInfo = built.nodes;
|
|
}
|
|
|
|
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
|
|
const curveHint = (): string =>
|
|
curveSummary({
|
|
nodeCount: planned.length,
|
|
curveOn,
|
|
curveRadius,
|
|
curveLock,
|
|
curveCount: curveInfo.length,
|
|
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
|
minRadiusM,
|
|
fresh: nodeInfo.length === 0,
|
|
});
|
|
|
|
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
|
|
const curveLabelBox = createCurveLabel({
|
|
onRadius: (value) => {
|
|
if (picked < 0) return;
|
|
curveRadius[picked] = value;
|
|
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
|
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
|
},
|
|
onArcLength: (value) => {
|
|
if (picked < 0) return;
|
|
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
|
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
|
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
|
curveArc[picked] = value;
|
|
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
|
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
|
|
},
|
|
onLock: (lock) => {
|
|
if (picked < 0) return;
|
|
curveLock[picked] = lock;
|
|
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
|
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
|
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
|
if (lock === "arc") {
|
|
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
|
}
|
|
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
|
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
|
applyEdit(
|
|
lock === "radius"
|
|
? "반지름을 고정했습니다."
|
|
: lock === "arc"
|
|
? "곡선 길이를 고정했습니다."
|
|
: "고정을 풀었습니다.",
|
|
);
|
|
},
|
|
onCurveOn: (on) => {
|
|
if (picked < 0) return;
|
|
curveOn[picked] = on;
|
|
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
|
},
|
|
});
|
|
|
|
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
|
function syncCurveBar(): void {
|
|
if (!(picked > 0 && picked < planned.length - 1)) {
|
|
curveLabelBox.hide();
|
|
return;
|
|
}
|
|
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
|
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
|
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
|
const rect = canvas.getBoundingClientRect();
|
|
const [screenX, screenY] = toScreen(planned[picked]);
|
|
curveLabelBox.show({
|
|
seat: picked,
|
|
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다.
|
|
at: [screenX + rect.left, screenY + rect.top],
|
|
centerDirection: pickedCurve
|
|
? centerDirectionOf(
|
|
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
|
toScreen(pickedCurve.start),
|
|
toScreen(pickedCurve.end),
|
|
)
|
|
: null,
|
|
curveOn: curveOn[picked] !== false,
|
|
radiusShown: shown,
|
|
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
|
lock: curveLock[picked] ?? null,
|
|
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
|
});
|
|
}
|
|
|
|
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
|
function applyEdit(message: string, record = true): void {
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`;
|
|
draw();
|
|
if (record) history?.commit(snapshotNow());
|
|
historyControls.sync();
|
|
}
|
|
|
|
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
|
function snapshotNow(): RouteEditSnapshot {
|
|
return { planned, curveOn, curveRadius, curveLock, curveArc, picked };
|
|
}
|
|
|
|
const historyControls = bindHistoryControls({
|
|
overlay,
|
|
getHistory: () => history,
|
|
restore: (snapshot, message) => {
|
|
planned = snapshot.planned;
|
|
curveOn = snapshot.curveOn;
|
|
curveRadius = snapshot.curveRadius;
|
|
curveLock = snapshot.curveLock;
|
|
curveArc = snapshot.curveArc;
|
|
picked = snapshot.picked;
|
|
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
|
},
|
|
});
|
|
|
|
// ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
|
// 지도 이동·확대는 `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 = event.clientX - rect.left;
|
|
const py = event.clientY - rect.top;
|
|
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
|
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
|
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
|
|
dragNode = nodeAt(px, py);
|
|
dragHandle = dragNode >= 0 ? null : handleAt(px, py);
|
|
dragMoved = false;
|
|
if (dragNode >= 0) {
|
|
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
|
syncCurveBar();
|
|
draw();
|
|
} else if (dragHandle) {
|
|
picked = dragHandle.node;
|
|
syncCurveBar();
|
|
draw();
|
|
}
|
|
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 node = dragHandle.node;
|
|
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
|
|
if (moved) {
|
|
planned[node] = moved.apex;
|
|
curveRadius[node] = Math.round(moved.radius * 100) / 100;
|
|
curveOn[node] = true;
|
|
picked = node;
|
|
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
|
|
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
|
|
dragMoved = true;
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`;
|
|
draw();
|
|
}
|
|
return;
|
|
}
|
|
if (dragNode >= 0) {
|
|
dragMoved = true;
|
|
planned[dragNode] = toMetric(px, py);
|
|
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
|
|
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
|
|
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
|
|
status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`;
|
|
draw();
|
|
return;
|
|
}
|
|
canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default";
|
|
});
|
|
|
|
const endDrag = (event: PointerEvent): void => {
|
|
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
|
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
|
|
// 눌러야 한다. 놓는 순간에만 쌓는다.
|
|
if (dragMoved) {
|
|
history?.commit(snapshotNow());
|
|
historyControls.sync();
|
|
}
|
|
dragNode = -1;
|
|
dragHandle = null;
|
|
dragMoved = false;
|
|
};
|
|
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);
|
|
curveLock.splice(segment + 1, 0, null);
|
|
curveArc.splice(segment + 1, 0, null);
|
|
picked = segment + 1;
|
|
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
|
});
|
|
|
|
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);
|
|
curveLock.splice(index, 1);
|
|
curveArc.splice(index, 1);
|
|
picked = -1;
|
|
applyEdit("노드를 지웠습니다(직선 삭제).");
|
|
});
|
|
|
|
// 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용.
|
|
bindRouteEditNavigation({
|
|
canvas,
|
|
getView: () => view,
|
|
setView: (next) => {
|
|
view = next;
|
|
},
|
|
getMeta: () => meta,
|
|
draw,
|
|
});
|
|
|
|
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;
|
|
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
|
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
|
planned = flat.planned;
|
|
nodeInfo = flat.nodes;
|
|
curveInfo = flat.curves;
|
|
curveOn = flat.curveOn;
|
|
curveRadius = flat.curveRadius;
|
|
curveLock = flat.curveLock;
|
|
curveArc = flat.curveArc;
|
|
picked = -1;
|
|
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
|
history = createRouteEditHistory(snapshotNow());
|
|
historyControls.sync();
|
|
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 : "노선을 읽지 못했습니다.";
|
|
}
|
|
}
|