사용자 지적 ③④⑤⑥ 과 추가 지시(되돌리기)를 한 묶음으로 처리. - **③ R 라벨** — 모달 맨 아랫줄이던 곡선 편집칸을 걷고, 고른 꺾임점 **옆에 뜨는 라벨**로 바꿈(`_Label.ts` 신설). 캔버스 밖으로 나가면 반대쪽으로 접어 넣음. 확대·이동·창 크기가 바뀌어도 고른 노드를 따라감. - **④ 노드가 손잡이보다 먼저 잡히게** — 반대였던 탓에 헤어핀처럼 곡선이 몰린 데서는 노드를 아예 못 집었음. 손잡이는 **고른 곡선에만** 그림. 머리말 안내에 조작 여섯을 적음. - **⑤ 줌·팬을 배수유역도와 동일하게**(`_Input.ts` 신설) — 휠은 **당기면 확대**(반대였음), 계수 1.15/0.87, 상한은 화면 폭 16m 기준, 하한 0.5, **팬은 가운데 버튼 전용**. ⚠ 커서 고정 계산이 좌상단 기준이라 확대할수록 지점이 밀리던 것도 중심 기준으로 고침. - **⑥ 등고선을 노선 ±300m 띠로** — 창 크기와 무관한 고정 띠라 창을 늘려도 안 깨짐. 화면 밖 걸러내기는 종전대로 `drawPreparedLayer` 가 함. - **되돌리기·다시하기·초기화**(`_History.ts` 신설) — [확인]이 무거워 되돌릴 길이 없으므로 창 안에서 물릴 수 있게 함. **끌기 한 번이 한 걸음**이고 클릭만으로는 걸음이 안 생김. [초기화]는 **이 창을 연 상태**로 (≠ [예상노선으로]). Ctrl+Z / Ctrl+Y · Ctrl+Shift+Z. - 700줄 제한 — 집기(hit test)를 `_Input.ts` 로 옮겨 본체 695줄. 실화면 검증(5174) — 라벨이 노드 오른쪽 [+18,−38]px 에 뜸 / 휠 당기니 중심에서 373.1 → 428.1px 로 확대 / 왼쪽 끌기로는 지도 안 움직임 / 가운데 버튼 끌기는 [60,40] 그대로 따라옴 / 단추 켜짐이 여섯 단계 모두 맞음. 전체 554 passed · 18 skipped, tsc 통과. 정본 안 건드림. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
5.4 KiB
TypeScript
121 lines
5.4 KiB
TypeScript
/* =============================================================================
|
|
* 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;
|
|
},
|
|
};
|
|
}
|