main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12
@@ -30,14 +30,33 @@ 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,
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label";
|
||||
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 DRAG_THRESHOLD_PX = 3;
|
||||
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(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;
|
||||
@@ -56,28 +75,24 @@ export async function openRouteEditModal(
|
||||
<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__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="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>
|
||||
@@ -110,6 +125,8 @@ export async function openRouteEditModal(
|
||||
let curveRadius: Array<number | null> = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
let history: RouteEditHistory | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let sheets: PreparedLayer[] = [];
|
||||
let view: ViewState = {
|
||||
@@ -125,6 +142,7 @@ export async function openRouteEditModal(
|
||||
const close = (): void => {
|
||||
closed = true;
|
||||
window.removeEventListener("resize", resize);
|
||||
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
@@ -181,6 +199,34 @@ export async function openRouteEditModal(
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 등고선을 보일 화면 사각형 — 노선 경계에 `CONTOUR_BAND_M` 를 두른 것.
|
||||
*
|
||||
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 노선이
|
||||
* 아직 없으면 null(전체를 그린다). */
|
||||
function contourBandRect(): { x: number; y: number; width: number; height: number } | null {
|
||||
const line = plannedLine.length ? plannedLine : planned;
|
||||
if (!meta || line.length < 2) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const [x, y] of line) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
// 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다.
|
||||
const [left, bottom] = toScreen([minX - CONTOUR_BAND_M, minY - CONTOUR_BAND_M]);
|
||||
const [right, top] = toScreen([maxX + CONTOUR_BAND_M, maxY + CONTOUR_BAND_M]);
|
||||
return {
|
||||
x: Math.min(left, right),
|
||||
y: Math.min(top, bottom),
|
||||
width: Math.abs(right - left),
|
||||
height: Math.abs(bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (closed) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
@@ -189,6 +235,14 @@ export async function openRouteEditModal(
|
||||
context.fillRect(0, 0, view.width, view.height);
|
||||
|
||||
context.save();
|
||||
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
||||
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
||||
const band = contourBandRect();
|
||||
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");
|
||||
@@ -239,6 +293,9 @@ export async function openRouteEditModal(
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
context.lineWidth = 2;
|
||||
curveInfo.forEach((curve) => {
|
||||
// **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서
|
||||
// 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다.
|
||||
if (curve.node_first !== picked) return;
|
||||
const on = curveOn[curve.node_first] !== false;
|
||||
if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다.
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
@@ -257,28 +314,17 @@ export async function openRouteEditModal(
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
||||
syncCurveBar();
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null.
|
||||
*
|
||||
* 잡은 것을 **노드 번호**로 기억한다 — 곡선 목록은 고칠 때마다 다시 만들어지므로 목록
|
||||
* 자리(index)로 들고 있으면 끄는 도중 엉뚱한 곡선을 가리키게 된다. */
|
||||
function handleAt(px: number, py: number): { node: number; end: "start" | "end" } | null {
|
||||
let best: { node: number; end: "start" | "end" } | null = null;
|
||||
let bestDistance = NODE_HIT_PX + 2;
|
||||
curveInfo.forEach((curve) => {
|
||||
(["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 = { node: curve.node_first, end: which };
|
||||
}
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
|
||||
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
||||
handleAtScreen(curveInfo, picked, 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(
|
||||
@@ -294,41 +340,6 @@ export async function openRouteEditModal(
|
||||
return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to);
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -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;
|
||||
}
|
||||
|
||||
/** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②).
|
||||
*
|
||||
* 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부
|
||||
@@ -363,100 +374,101 @@ export async function openRouteEditModal(
|
||||
);
|
||||
}
|
||||
|
||||
// ── 곡선 편집줄 — 고른 자리의 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"]')!;
|
||||
// ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ──
|
||||
const curveLabelBox = createCurveLabel(canvas.parentElement!, {
|
||||
onRadius: (value) => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
onAuto: () => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다.
|
||||
applyEdit("반지름을 자동으로 되돌렸습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
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;
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
curveLabelBox.hide();
|
||||
return;
|
||||
}
|
||||
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`
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
curveLabelBox.show({
|
||||
seat: picked,
|
||||
at: toScreen(planned[picked]),
|
||||
canvas: { width: view.width, height: view.height },
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown:
|
||||
forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null,
|
||||
forced: forced !== null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
minRadiusM,
|
||||
});
|
||||
}
|
||||
|
||||
curveRadiusInput.addEventListener("change", () => {
|
||||
if (picked < 0) return;
|
||||
const value = Number(curveRadiusInput.value);
|
||||
curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
||||
function applyEdit(message: string, record = true): void {
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`;
|
||||
status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`;
|
||||
draw();
|
||||
if (record) history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
}
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
function snapshotNow(): RouteEditSnapshot {
|
||||
return { planned, curveOn, curveRadius, picked };
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
overlay,
|
||||
getHistory: () => history,
|
||||
restore: (snapshot, message) => {
|
||||
planned = snapshot.planned;
|
||||
curveOn = snapshot.curveOn;
|
||||
curveRadius = snapshot.curveRadius;
|
||||
picked = snapshot.picked;
|
||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
// ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
// 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다.
|
||||
let dragNode = -1;
|
||||
/** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */
|
||||
/** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */
|
||||
let dragHandle: { node: number; end: "start" | "end" } | null = null;
|
||||
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | 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;
|
||||
// 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작).
|
||||
dragHandle = handleAt(px, py);
|
||||
dragNode = dragHandle ? -1 : nodeAt(px, py);
|
||||
if (dragHandle) {
|
||||
// **노드가 손잡이보다 먼저다**(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();
|
||||
} else if (dragNode >= 0) {
|
||||
picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다.
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else {
|
||||
panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY };
|
||||
}
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
@@ -476,6 +488,7 @@ export async function openRouteEditModal(
|
||||
picked = node;
|
||||
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
|
||||
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
|
||||
dragMoved = true;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`;
|
||||
@@ -484,6 +497,7 @@ export async function openRouteEditModal(
|
||||
return;
|
||||
}
|
||||
if (dragNode >= 0) {
|
||||
dragMoved = true;
|
||||
planned[dragNode] = toMetric(px, py);
|
||||
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
|
||||
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
|
||||
@@ -492,24 +506,20 @@ export async function openRouteEditModal(
|
||||
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";
|
||||
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;
|
||||
panFrom = null;
|
||||
dragMoved = false;
|
||||
};
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
@@ -525,10 +535,7 @@ export async function openRouteEditModal(
|
||||
curveOn.splice(segment + 1, 0, true);
|
||||
curveRadius.splice(segment + 1, 0, null);
|
||||
picked = segment + 1;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`;
|
||||
draw();
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
@@ -544,33 +551,19 @@ export async function openRouteEditModal(
|
||||
curveOn.splice(index, 1);
|
||||
curveRadius.splice(index, 1);
|
||||
picked = -1;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`;
|
||||
draw();
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
});
|
||||
|
||||
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();
|
||||
// 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용.
|
||||
bindRouteEditNavigation({
|
||||
canvas,
|
||||
getView: () => view,
|
||||
setView: (next) => {
|
||||
view = next;
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
getMeta: () => meta,
|
||||
draw,
|
||||
});
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
@@ -667,6 +660,9 @@ export async function openRouteEditModal(
|
||||
if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat });
|
||||
});
|
||||
picked = -1;
|
||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
historyControls.sync();
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_History.ts
|
||||
* 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시).
|
||||
*
|
||||
* 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니
|
||||
* **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로
|
||||
* 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다.
|
||||
*
|
||||
* 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로
|
||||
* 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다.
|
||||
* ========================================================================== */
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** 되돌릴 수 있는 편집 상태 한 벌. */
|
||||
export interface RouteEditSnapshot {
|
||||
planned: Vertex[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
picked: number;
|
||||
}
|
||||
|
||||
/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */
|
||||
const MAX_STEPS = 100;
|
||||
|
||||
export interface RouteEditHistory {
|
||||
/** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */
|
||||
commit: (snapshot: RouteEditSnapshot) => void;
|
||||
/** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */
|
||||
undo: () => RouteEditSnapshot | null;
|
||||
/** 한 걸음 앞으로. 없으면 null. */
|
||||
redo: () => RouteEditSnapshot | null;
|
||||
/** 창을 연 상태로. 이미 그 상태면 null. */
|
||||
reset: () => RouteEditSnapshot | null;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
/** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
/** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */
|
||||
function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
||||
return {
|
||||
planned: snapshot.planned.map(([x, y]): Vertex => [x, y]),
|
||||
curveOn: [...snapshot.curveOn],
|
||||
curveRadius: [...snapshot.curveRadius],
|
||||
picked: snapshot.picked,
|
||||
};
|
||||
}
|
||||
|
||||
/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */
|
||||
function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
||||
if (a.planned.length !== b.planned.length) return false;
|
||||
for (let index = 0; index < a.planned.length; index += 1) {
|
||||
if (a.planned[index][0] !== b.planned[index][0]) return false;
|
||||
if (a.planned[index][1] !== b.planned[index][1]) return false;
|
||||
if (a.curveOn[index] !== b.curveOn[index]) return false;
|
||||
if (a.curveRadius[index] !== b.curveRadius[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 첫 사진(창을 연 상태)으로 이력을 연다. */
|
||||
export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory {
|
||||
const steps: RouteEditSnapshot[] = [clone(initial)];
|
||||
let at = 0;
|
||||
|
||||
return {
|
||||
commit(snapshot) {
|
||||
// 되돌린 뒤 새로 고치면 앞쪽 갈래는 버린다 — 흔한 되돌리기 규칙 그대로.
|
||||
steps.length = at + 1;
|
||||
steps.push(clone(snapshot));
|
||||
if (steps.length > MAX_STEPS) steps.shift();
|
||||
at = steps.length - 1;
|
||||
},
|
||||
undo() {
|
||||
if (at <= 0) return null;
|
||||
at -= 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
redo() {
|
||||
if (at >= steps.length - 1) return null;
|
||||
at += 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
reset() {
|
||||
// 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다.
|
||||
if (sameRoute(steps[at], steps[0])) return null;
|
||||
// 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다.
|
||||
steps.length = at + 1;
|
||||
steps.push(clone(steps[0]));
|
||||
at = steps.length - 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
canUndo: () => at > 0,
|
||||
canRedo: () => at < steps.length - 1,
|
||||
isDirty: () => !sameRoute(steps[at], steps[0]),
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryControlsParams {
|
||||
/** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */
|
||||
overlay: HTMLElement;
|
||||
/** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */
|
||||
getHistory: () => RouteEditHistory | null;
|
||||
/** 사진 한 벌을 화면에 되살린다. */
|
||||
restore: (snapshot: RouteEditSnapshot, message: string) => void;
|
||||
}
|
||||
|
||||
/** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다.
|
||||
*
|
||||
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을
|
||||
* 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */
|
||||
export function bindHistoryControls(params: HistoryControlsParams): {
|
||||
sync: () => void;
|
||||
dispose: () => void;
|
||||
} {
|
||||
const { overlay, getHistory, restore } = params;
|
||||
const undoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="undo"]')!;
|
||||
const redoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="redo"]')!;
|
||||
const resetBtn = overlay.querySelector<HTMLButtonElement>('[data-act="history-reset"]')!;
|
||||
|
||||
const sync = (): void => {
|
||||
const history = getHistory();
|
||||
undoBtn.disabled = !history?.canUndo();
|
||||
redoBtn.disabled = !history?.canRedo();
|
||||
resetBtn.disabled = !history?.isDirty();
|
||||
};
|
||||
|
||||
const step = (which: "undo" | "redo" | "reset"): void => {
|
||||
const history = getHistory();
|
||||
if (!history) return;
|
||||
const snapshot = history[which]();
|
||||
if (!snapshot) return;
|
||||
restore(
|
||||
snapshot,
|
||||
which === "undo"
|
||||
? "되돌렸습니다."
|
||||
: which === "redo"
|
||||
? "다시 했습니다."
|
||||
: "창을 연 상태로 돌렸습니다.",
|
||||
);
|
||||
sync();
|
||||
};
|
||||
|
||||
undoBtn.addEventListener("click", () => step("undo"));
|
||||
redoBtn.addEventListener("click", () => step("redo"));
|
||||
resetBtn.addEventListener("click", () => step("reset"));
|
||||
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "z" && !event.shiftKey) step("undo");
|
||||
else if (key === "y" || (key === "z" && event.shiftKey)) step("redo");
|
||||
else return;
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return { sync, dispose: () => window.removeEventListener("keydown", onKey) };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Input.ts
|
||||
* 노선 편집 모달의 **지도 조작** — 휠 확대/축소와 가운데 버튼 팬.
|
||||
*
|
||||
* ⚠ 배수유역도(`B05_Profile_UI_Drainage_Interact.ts`)와 **같은 동작**이어야 한다
|
||||
* (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다.
|
||||
* · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87
|
||||
* · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5
|
||||
* · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가
|
||||
* 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다.
|
||||
*
|
||||
* ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고
|
||||
* 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그).
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */
|
||||
const ZOOM_IN_FACTOR = 1.15;
|
||||
const ZOOM_OUT_FACTOR = 0.87;
|
||||
/** 배율 하한 — 배수유역도와 같다(전체보다 조금 더 뒤로 뺄 수 있게). */
|
||||
const MIN_SCALE = 0.5;
|
||||
/** 배율 상한을 못 구할 때 쓸 값 — 배수유역도와 같은 자리에 같은 수. */
|
||||
const MAX_SCALE_FALLBACK = 16;
|
||||
|
||||
export interface RouteEditNavigationParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
getView: () => ViewState;
|
||||
setView: (next: ViewState) => void;
|
||||
getMeta: () => VWorldMeta | null;
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */
|
||||
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
|
||||
const { canvas, getView, setView, getMeta, draw } = params;
|
||||
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
const view = getView();
|
||||
const maxScale = computeMaxScale(
|
||||
getMeta(),
|
||||
view.mapRect.width,
|
||||
view.width,
|
||||
MAX_SCALE_FALLBACK,
|
||||
);
|
||||
const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
|
||||
const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor));
|
||||
const ratio = scale / view.scale;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
|
||||
const cursorX = event.clientX - rect.left - rect.width / 2;
|
||||
const cursorY = event.clientY - rect.top - rect.height / 2;
|
||||
setView({
|
||||
...view,
|
||||
scale,
|
||||
offsetX: cursorX * (1 - ratio) + view.offsetX * ratio,
|
||||
offsetY: cursorY * (1 - ratio) + view.offsetY * ratio,
|
||||
});
|
||||
draw();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
// 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다.
|
||||
if (event.button === 1) event.preventDefault();
|
||||
// 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫.
|
||||
if (event.pointerType === "mouse" && event.button !== 1) return;
|
||||
const view = getView();
|
||||
dragStart = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
offsetX: view.offsetX,
|
||||
offsetY: view.offsetY,
|
||||
};
|
||||
canvas.style.cursor = "grabbing";
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!dragStart) return;
|
||||
setView({
|
||||
...getView(),
|
||||
offsetX: dragStart.offsetX + event.clientX - dragStart.x,
|
||||
offsetY: dragStart.offsetY + event.clientY - dragStart.y,
|
||||
});
|
||||
draw();
|
||||
});
|
||||
|
||||
const stop = (): void => {
|
||||
if (!dragStart) return;
|
||||
dragStart = null;
|
||||
canvas.style.removeProperty("cursor");
|
||||
};
|
||||
canvas.addEventListener("pointerup", stop);
|
||||
canvas.addEventListener("pointercancel", stop);
|
||||
}
|
||||
|
||||
/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */
|
||||
|
||||
export type ScreenOf = (point: [number, number]) => [number, number];
|
||||
|
||||
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */
|
||||
export function nodeAtScreen(
|
||||
points: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
hitPx: number,
|
||||
): number {
|
||||
let best = -1;
|
||||
let bestDistance = hitPx;
|
||||
points.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;
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null.
|
||||
*
|
||||
* **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라
|
||||
* **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한
|
||||
* 곡선을 가리킨다. */
|
||||
export function handleAtScreen(
|
||||
curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>,
|
||||
picked: number,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
hitPx: number,
|
||||
): { node: number; end: "start" | "end" } | null {
|
||||
let best: { node: number; end: "start" | "end" } | null = null;
|
||||
let bestDistance = hitPx;
|
||||
curves.forEach((curve) => {
|
||||
if (curve.node_first !== picked) return;
|
||||
(["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 = { node: curve.node_first, end: which };
|
||||
}
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */
|
||||
export function segmentAtScreen(
|
||||
points: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
maxPx: number,
|
||||
): number {
|
||||
let best = -1;
|
||||
let bestDistance = maxPx;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const [ax, ay] = toScreen(points[index]);
|
||||
const [bx, by] = toScreen(points[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;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Label.ts
|
||||
* 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다.
|
||||
*
|
||||
* 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이
|
||||
* 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다.
|
||||
* 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다.
|
||||
*
|
||||
* 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을
|
||||
* 골라도 입력칸이 잘리지 않아야 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 라벨과 노드 사이 여백(px). */
|
||||
const GAP_PX = 14;
|
||||
/** 캔버스 가장자리에서 이만큼은 띄운다(px). */
|
||||
const EDGE_PX = 8;
|
||||
|
||||
export interface CurveLabelState {
|
||||
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
|
||||
seat: number;
|
||||
/** 그 꺾임점의 화면 좌표(캔버스 기준 px). */
|
||||
at: [number, number];
|
||||
/** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */
|
||||
canvas: { width: number; height: number };
|
||||
curveOn: boolean;
|
||||
/** 지금 보일 반지름(m). 곡선이 없으면 null. */
|
||||
radiusShown: number | null;
|
||||
/** 사용자가 못박은 값인지(아니면 자동). */
|
||||
forced: boolean;
|
||||
innerAngleDeg: number | null;
|
||||
minRadiusM: number;
|
||||
}
|
||||
|
||||
export interface CurveLabelHandlers {
|
||||
/** R 칸을 고쳤다 — null 이면 「자동」. */
|
||||
onRadius: (value: number | null) => void;
|
||||
/** 곡선을 지우거나 넣었다. */
|
||||
onCurveOn: (on: boolean) => void;
|
||||
/** 반지름을 자동으로 되돌렸다. */
|
||||
onAuto: () => void;
|
||||
}
|
||||
|
||||
export interface CurveLabel {
|
||||
show: (state: CurveLabelState) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */
|
||||
export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-routeedit__label";
|
||||
root.hidden = true;
|
||||
root.innerHTML = `
|
||||
<div class="b05-routeedit__label-head">
|
||||
<span class="b05-routeedit__curve-label"></span>
|
||||
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
|
||||
</div>
|
||||
<label class="b05-routeedit__curve-field">R
|
||||
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
<button type="button" class="b05-routeedit__label-auto" data-act="curve-auto">자동</button>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
host.append(root);
|
||||
|
||||
const seatText = root.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
|
||||
const toggle = root.querySelector<HTMLButtonElement>('[data-act="curve-toggle"]')!;
|
||||
const radius = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-radius")!;
|
||||
const auto = root.querySelector<HTMLButtonElement>('[data-act="curve-auto"]')!;
|
||||
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||||
|
||||
// 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
root.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
root.addEventListener("dblclick", (event) => event.stopPropagation());
|
||||
root.addEventListener("contextmenu", (event) => event.stopPropagation());
|
||||
|
||||
let curveOn = true;
|
||||
radius.addEventListener("change", () => {
|
||||
const value = Number(radius.value);
|
||||
handlers.onRadius(Number.isFinite(value) && value > 0 ? value : null);
|
||||
});
|
||||
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
|
||||
auto.addEventListener("click", () => handlers.onAuto());
|
||||
|
||||
function place(at: [number, number], canvas: { width: number; height: number }): void {
|
||||
// 먼저 보여야 크기를 잴 수 있다 — 그 다음 자리를 잡는다.
|
||||
const width = root.offsetWidth;
|
||||
const height = root.offsetHeight;
|
||||
// 기본은 오른쪽. 오른쪽이 좁으면 왼쪽으로 접는다(사용자 말대로 「우측에」가 기본).
|
||||
let left = at[0] + GAP_PX;
|
||||
if (left + width > canvas.width - EDGE_PX) left = at[0] - GAP_PX - width;
|
||||
left = Math.max(EDGE_PX, Math.min(left, canvas.width - width - EDGE_PX));
|
||||
let top = at[1] - height / 2;
|
||||
top = Math.max(EDGE_PX, Math.min(top, canvas.height - height - EDGE_PX));
|
||||
root.style.left = `${Math.round(left)}px`;
|
||||
root.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
show(state) {
|
||||
curveOn = state.curveOn;
|
||||
root.hidden = false;
|
||||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
radius.disabled = !state.curveOn;
|
||||
auto.disabled = !state.curveOn || !state.forced;
|
||||
radius.value =
|
||||
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
|
||||
const inner = state.innerAngleDeg;
|
||||
info.textContent = state.curveOn
|
||||
? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
|
||||
` · 법정 하한 ${state.minRadiusM}m`
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
place(state.at, state.canvas);
|
||||
},
|
||||
hide() {
|
||||
root.hidden = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -131,21 +131,47 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시).
|
||||
바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */
|
||||
.b05-routeedit__curve {
|
||||
/* 바닥줄과 같이 **줄어들지 않게** 둔다 — 안 그러면 캔버스(flex:1)가 자리를 다 먹고
|
||||
이 줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* R 라벨 — 고른 꺾임점 **옆에 떠 있는** 작은 상자(2026-09-07 사용자 지시 ③).
|
||||
예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다.
|
||||
자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */
|
||||
.b05-routeedit__label {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 11rem;
|
||||
padding: var(--spacing-8, 8px);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-6, 6px);
|
||||
background: var(--color-surface-2, #f9fafb);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 25%);
|
||||
font-size: var(--font-size-13, 13px);
|
||||
}
|
||||
|
||||
.b05-routeedit__label-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface-2, #f9fafb);
|
||||
font-size: var(--font-size-13, 13px);
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b05-routeedit__label-toggle,
|
||||
.b05-routeedit__label-auto {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: var(--font-size-12, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__label-toggle:disabled,
|
||||
.b05-routeedit__label-auto:disabled {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-label {
|
||||
@@ -161,7 +187,7 @@
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-radius {
|
||||
width: 5.5rem;
|
||||
width: 4.5rem;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
@@ -175,9 +201,7 @@
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-info {
|
||||
flex: 1 1 auto;
|
||||
max-width: 16rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user