- 「예상노선으로」를 「초기화」로 — 마지막에 적용한 초기값(처음엔 예상노선 추종)으로 편집값만 되돌림 · 즉시 재계산 없앰 - 옛 「초기화」(창을 연 상태로)는 「연 상태로」 - 로직 단추 토공 최소 · 기울기 순한 노선 · 비중 계산(예상 · 토공 · 기울기 슬라이더 · 합 100 자동 · 기본 50/25/25 · [계산]) - 누르면 서버 갈래(POST /route/initial)를 불러 받은 노드를 편집값에 싣고(되돌리기 한 걸음) 결과 캐시(routeedit-initial)에 새 초기값으로 적음 - 「현재 초기값: ○○」 · 결과 비교값(연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격) · 공통 제약 기준값은 서버 값만 보임(확정 표시 없으면 「후보 · 사용자 확정 대기」) - 다시 계산은 노선이 바뀐 뒤 [확인]에서만 — 그대로면 계산 없이 닫음 · [확인] 몸통에 초기값(initial) 실음 · [취소]는 이 창에서 바꾼 초기값도 되돌림 - 서버 길이 아직 없으면 알림만(고친 적 없는 노선의 [초기화]는 창을 연 노선으로) - 700줄 한도 — 노드 끼우기·빼기 배선은 _Input(bindNodeSplice), 셈은 _Initial_Model - 시험 test_b05_routeedit_initial.py (비중 합 100 · 서버 답 두 꼴 펴기 · 글 · 캐시 칸) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
698 lines
30 KiB
TypeScript
698 lines
30 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit.ts
|
|
* 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다.
|
|
*
|
|
* 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지
|
|
* 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을
|
|
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**.
|
|
*
|
|
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
|
|
* [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`).
|
|
*
|
|
* 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은
|
|
* 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
computeMapRect,
|
|
hitPreparedLayer,
|
|
metricToScreen,
|
|
pickLevelStep,
|
|
type PreparedLayer,
|
|
type ViewState,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import {
|
|
bindMinorContourToggle,
|
|
loadRouteEditBackground,
|
|
type RouteEditContours,
|
|
} from "./B05_Profile_UI_RouteEdit_Contour";
|
|
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
|
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 { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
|
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
|
|
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
|
|
import { bindMeasureControls, createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
|
|
import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope";
|
|
import {
|
|
captureView,
|
|
createViewMemory,
|
|
fitCanvas,
|
|
openingView,
|
|
screenToMetric,
|
|
viewPxPerMeter,
|
|
} from "./B05_Profile_UI_RouteEdit_View";
|
|
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
|
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";
|
|
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 SEGMENT_HIT_PX = 12;
|
|
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
|
|
const CONTOUR_HIT_PX = 6;
|
|
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
|
|
const STATION_HIT_PX = 11;
|
|
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
|
* (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로
|
|
* 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */
|
|
const CONTOUR_LEVEL_BUDGET = 1200;
|
|
|
|
type Vertex = [number, number];
|
|
|
|
/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
|
export interface RouteEditOptions {
|
|
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
|
|
stationIntervalM?: number;
|
|
/** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */
|
|
surfaceModelId?: number | null;
|
|
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
|
|
contourIntervalM?: number;
|
|
smooth?: boolean;
|
|
}
|
|
|
|
export async function openRouteEditModal(
|
|
projectId: string,
|
|
onApplied: () => void | Promise<void>,
|
|
options: RouteEditOptions = {},
|
|
): Promise<void> {
|
|
const stationIntervalM = options.stationIntervalM ?? 20;
|
|
const chrome = createRouteEditChrome();
|
|
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
|
|
const { minorContourButton } = chrome;
|
|
const context = canvas.getContext("2d")!;
|
|
|
|
let expected: Vertex[] = [];
|
|
/** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */
|
|
let plannedLine: Vertex[] = [];
|
|
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
|
|
let planned: Vertex[] = [];
|
|
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
|
|
let nodeInfo: EditedNode[] = [];
|
|
let minRadiusM = 0;
|
|
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
|
|
let limitRadiusM = 0;
|
|
let limitArcM = 0;
|
|
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
|
|
let curveInfo: EditedCurve[] = [];
|
|
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
|
|
let curveOn: boolean[] = [];
|
|
let curveRadius: Array<number | null> = [];
|
|
/** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m).
|
|
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
|
let curveLock: CurveLock[] = [];
|
|
let curveArc: Array<number | null> = [];
|
|
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도
|
|
* 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */
|
|
let apexLock: boolean[] = [];
|
|
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
|
let picked = -1;
|
|
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
|
let history: RouteEditHistory | null = null;
|
|
let meta: VWorldMeta | null = null;
|
|
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
|
|
let contours: RouteEditContours | null = null;
|
|
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
|
|
let otherSheets: PreparedLayer[] = [];
|
|
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
|
let pickedContour = -1;
|
|
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
|
const crossPreview = createCrossPreview({
|
|
projectId,
|
|
side: overlay.querySelector<HTMLElement>(".b05-routeedit__side")!,
|
|
request: () => ({
|
|
vertices: planned.map(([x, y], index) => ({
|
|
x,
|
|
y,
|
|
curve: curveOn[index] !== false,
|
|
radius_m: curveRadius[index] ?? null,
|
|
})),
|
|
min_radius_m: minRadiusM || 12,
|
|
station_interval_m: stationIntervalM,
|
|
}),
|
|
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
|
|
});
|
|
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
|
const measure = createMeasureTool({
|
|
projectId,
|
|
stationIntervalM,
|
|
lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }),
|
|
toScreen: (vertex) => toScreen(vertex),
|
|
isClosed: () => closed,
|
|
onChange: () => {
|
|
measureControls.sync();
|
|
draw();
|
|
},
|
|
});
|
|
/** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */
|
|
const measureControls = bindMeasureControls({
|
|
measure,
|
|
box: measureBox,
|
|
text: measureText,
|
|
button: measureButton,
|
|
closeButton: overlay.querySelector<HTMLElement>(".b05-routeedit__measure-close")!,
|
|
onShown: () => {
|
|
if (picked < 0) return;
|
|
picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다.
|
|
syncCurveBar();
|
|
},
|
|
redraw: () => draw(),
|
|
});
|
|
let view: ViewState = {
|
|
width: 0,
|
|
height: 0,
|
|
scale: 1,
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
mapRect: computeMapRect(null, 0, 0),
|
|
};
|
|
let closed = false;
|
|
/** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */
|
|
const rotation = createMapRotation({
|
|
overlay,
|
|
size: () => view,
|
|
onChange: () => {
|
|
syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다.
|
|
draw();
|
|
},
|
|
});
|
|
/** 측점 사이 기울기 라벨 — 높이 묻기·그리기는 `_Slope` 몫(계획서 18 ①). */
|
|
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
|
|
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
|
|
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
|
|
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
|
|
let viewReady = false;
|
|
const viewMemory = createViewMemory(projectId, () =>
|
|
meta && viewReady ? captureView(meta, view, rotation.radians()) : null,
|
|
);
|
|
|
|
const close = (): void => {
|
|
viewMemory.flush();
|
|
closed = true;
|
|
window.removeEventListener("resize", resize);
|
|
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
|
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
|
overlay.remove();
|
|
};
|
|
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
|
|
const initial = createInitialPanel({
|
|
overlay,
|
|
projectId,
|
|
planMinRadiusM: () => minRadiusM,
|
|
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
|
});
|
|
const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다.
|
|
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel);
|
|
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel);
|
|
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
|
|
|
|
function resize(): void {
|
|
if (closed) return;
|
|
const { width, height } = fitCanvas(canvas, context);
|
|
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). 셈은 `_View` 몫. */
|
|
const toMetric = (px: number, py: number): Vertex =>
|
|
meta ? screenToMetric(meta, view, px, py) : [0, 0];
|
|
|
|
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */
|
|
const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1);
|
|
|
|
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
|
const contourStepM = (): number =>
|
|
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
|
|
|
|
function draw(): void {
|
|
if (closed) return;
|
|
drawRouteEditScene(context, {
|
|
view,
|
|
toScreen,
|
|
pxPerMeter: pxPerMeter(),
|
|
hasMeta: meta !== null,
|
|
contours,
|
|
otherSheets,
|
|
pickedContour,
|
|
contourStepM: contourStepM(),
|
|
rotationRad: rotation.radians(),
|
|
uprightRad: rotation.uprightRad(),
|
|
minorContours: showMinorContours() ? (contours?.minor?.() ?? null) : null,
|
|
overStations: (ctx) =>
|
|
slope.draw(ctx, {
|
|
line: plannedLine.length ? plannedLine : planned,
|
|
intervalM: stationIntervalM,
|
|
pxPerMeter: pxPerMeter(),
|
|
toScreen,
|
|
uprightRad: rotation.uprightRad(),
|
|
dragging: dragNode >= 0 || dragHandle !== null,
|
|
}),
|
|
measure: measure.marks(),
|
|
crossWidth: crossPreview.watched(),
|
|
expected,
|
|
plannedLine,
|
|
planned,
|
|
nodeInfo,
|
|
curveInfo,
|
|
curveOn,
|
|
picked,
|
|
stationIntervalM,
|
|
});
|
|
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
|
syncCurveBar();
|
|
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 사용자 지적 ①②).
|
|
*
|
|
* 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부
|
|
* 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다.
|
|
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
|
|
* 서버가 정본으로 다시 내는 것은 그대로다. */
|
|
function markEdited(): void {
|
|
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
|
|
applyArcLocks(planned, curveLock, curveArc, curveRadius);
|
|
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
|
|
// 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정).
|
|
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
|
|
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
|
|
plannedLine = built.vertices;
|
|
curveInfo = built.curves;
|
|
nodeInfo = built.nodes;
|
|
}
|
|
|
|
/** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로
|
|
* 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */
|
|
const routeHead = (): string =>
|
|
`예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` +
|
|
`계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
|
|
`노드 ${planned.length}개`;
|
|
|
|
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
|
|
const contourHint = (): string => {
|
|
if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
|
|
const level = contours?.layer.features[pickedContour]?.labelValue ?? null;
|
|
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
|
|
};
|
|
|
|
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
|
|
const curveHint = (): string =>
|
|
curveSummary({
|
|
nodeCount: planned.length,
|
|
curveOn,
|
|
curveRadius,
|
|
curveLock,
|
|
apexLock,
|
|
curveCount: curveInfo.length,
|
|
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
|
minRadiusM,
|
|
fresh: nodeInfo.length === 0,
|
|
});
|
|
|
|
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
|
|
const curveBar = createCurveBar({
|
|
canvas,
|
|
state: () => ({
|
|
picked,
|
|
planned,
|
|
nodeInfo,
|
|
curveInfo,
|
|
curveOn,
|
|
curveRadius,
|
|
curveLock,
|
|
curveArc,
|
|
apexLock,
|
|
limitRadiusM,
|
|
limitArcM,
|
|
}),
|
|
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
|
|
applyEdit: (message) => applyEdit(message),
|
|
onUnselect: () => {
|
|
picked = -1;
|
|
syncCurveBar();
|
|
draw();
|
|
},
|
|
});
|
|
const curveLabelBox = curveBar.label;
|
|
const syncCurveBar = curveBar.sync;
|
|
|
|
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
|
function applyEdit(message: string, record = true): void {
|
|
markEdited();
|
|
syncCurveBar();
|
|
status.textContent = `${routeHead()} — ${message} ${curveHint()}`;
|
|
draw();
|
|
if (record) history?.commit(snapshotNow());
|
|
historyControls.sync();
|
|
}
|
|
|
|
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
|
function snapshotNow(): RouteEditSnapshot {
|
|
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
|
|
}
|
|
|
|
/** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */
|
|
function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void {
|
|
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
|
|
applyEdit(message, record);
|
|
}
|
|
|
|
const historyControls = bindHistoryControls({
|
|
overlay,
|
|
getHistory: () => history,
|
|
restore: (snapshot, message) => restoreSnapshot(snapshot, 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, 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) {
|
|
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,
|
|
insert: (segment, px, py) => {
|
|
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
|
|
picked = segment + 1;
|
|
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
|
},
|
|
remove: (index) => {
|
|
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
|
spliceNode(snapshotNow(), index);
|
|
picked = -1;
|
|
applyEdit("노드를 지웠습니다(직선 삭제).");
|
|
},
|
|
});
|
|
|
|
// 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용.
|
|
bindRouteEditNavigation({
|
|
canvas,
|
|
getView: () => view,
|
|
setView: (next) => {
|
|
view = next;
|
|
},
|
|
getMeta: () => meta,
|
|
// 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
|
|
unrotateDelta: rotation.unrotateDelta,
|
|
draw,
|
|
});
|
|
|
|
bindRouteApply({
|
|
overlay,
|
|
busy,
|
|
projectId,
|
|
nodes: () => ({ planned, curveOn, curveRadius }),
|
|
changed: () => history?.isDirty() ?? false,
|
|
initial,
|
|
close,
|
|
onApplied,
|
|
});
|
|
|
|
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
|
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;
|
|
limitRadiusM = plan.limit_radius_m ?? 0;
|
|
limitArcM = plan.limit_curve_length_m ?? 0;
|
|
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
|
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
|
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat);
|
|
({ nodes: nodeInfo, curves: curveInfo } = flat);
|
|
picked = -1;
|
|
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
|
history = createRouteEditHistory(snapshotNow());
|
|
historyControls.sync();
|
|
initial.ready(snapshotNow(), plan.edited);
|
|
syncCurveBar();
|
|
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
|
meta = drainage.meta;
|
|
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
|
const background = await loadRouteEditBackground(projectId, drainage, {
|
|
surfaceModelId: options.surfaceModelId ?? null,
|
|
intervalM: options.contourIntervalM ?? 1,
|
|
smooth: options.smooth ?? false,
|
|
});
|
|
otherSheets = background.otherSheets;
|
|
contours = background.contours;
|
|
if (closed) return;
|
|
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
|
|
minorContourButton.disabled = !contours?.minor;
|
|
if (!contours?.minor) minorContourButton.title = "LAS 등고선이 없어 1m 선이 없습니다";
|
|
resize();
|
|
// 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다.
|
|
const opening = openingView(projectId, meta, view, planned);
|
|
view = opening.view;
|
|
rotation.set(opening.rotationRad);
|
|
viewReady = true;
|
|
status.textContent =
|
|
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
|
|
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
|
|
curveHint();
|
|
draw();
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
|
|
}
|
|
}
|