Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user