R 하한만 막고 L 하한은 칸 입력에서만 막던 것을 고침. L = R·Δ 라 한 자리의 하한을 반지름 하나로 모아 봄(`radiusFloorM` = max(R 하한, L 하한/Δ)) — 값 끌어올리기· 모자란 양 판정·칸 막기가 모두 이 값을 씀. 내각 155° 이상은 별표2 Ⅰ.2.다.(1) 상 곡선을 안 둬도 되는 자리라 L 하한을 안 걺 — 걸면 교각이 0 에 가까워 R 이 수십 배로 부풀어 안 고친 구간이 휨. 그 자리는 곡선 패널에 「155° 이상 — 곡선 생략 가능」으로 적어 냄. `deflectionRad` 를 화면 파일(`_Label`)에서 셈 파일(`_Edits`)로 옮김 — 순수 함수가 DOM 파일에 얹혀 있어 시험이 화면째 들여와야 했음(부르던 자리는 재수출로 지킴). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
323 lines
16 KiB
TypeScript
323 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_RouteEdit_Label.ts
|
||
* 고른 꺾임점 옆에 뜨는 **곡선 조작 패널** — 반지름·곡선 길이를 보고 고치고 붙든다.
|
||
*
|
||
* 왜 옆인가(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이라 지금 고른 것이
|
||
* 지도 어디인지 눈으로 안 이어졌다.
|
||
*
|
||
* **R 과 곡선 길이는 한 쌍**(L = R·Δ, Δ 는 앞뒤 직선이 정하는 교각) — 한쪽을 고치면 다른
|
||
* 쪽이 따라온다. 「자동」 단추는 없다: 칸을 비우는 것이 곧 자동이다. 어느 쪽을 붙들지는
|
||
* **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고).
|
||
*
|
||
* **자리**(2026-09-07 사용자 지시)
|
||
* · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라
|
||
* 안에 두면 가장자리에서 **잘린다**.
|
||
* · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단
|
||
* 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와
|
||
* 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다.
|
||
* · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다).
|
||
* · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고,
|
||
* 다른 꺾임점을 고르면 자동 자리로 돌아간다.
|
||
* ========================================================================== */
|
||
|
||
import { CURVE_OPTIONAL_INNER_ANGLE_DEG, type CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
|
||
|
||
// `deflectionRad` 는 화면을 안 만지는 셈이라 `_Edits.ts` 로 옮겼다(2026-09-12) — 순수
|
||
// 함수가 DOM 파일에 얹혀 있으면 시험이 화면째 들여와야 한다. 재수출로 부르던 자리를 지킨다.
|
||
export { deflectionRad } from "./B05_Profile_UI_RouteEdit_Edits";
|
||
|
||
/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 패널을 그 반대쪽에 붙이는 데 쓴다.
|
||
*
|
||
* 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라
|
||
* 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */
|
||
export function centerDirectionOf(
|
||
apex: [number, number],
|
||
start: [number, number],
|
||
end: [number, number],
|
||
): [number, number] | null {
|
||
const arm = (point: [number, number]): [number, number] => {
|
||
const dx = point[0] - apex[0];
|
||
const dy = point[1] - apex[1];
|
||
const length = Math.hypot(dx, dy);
|
||
return length <= 1e-9 ? [0, 0] : [dx / length, dy / length];
|
||
};
|
||
const [ux, uy] = arm(start);
|
||
const [vx, vy] = arm(end);
|
||
const sx = ux + vx;
|
||
const sy = uy + vy;
|
||
const length = Math.hypot(sx, sy);
|
||
return length <= 1e-9 ? null : [sx / length, sy / length];
|
||
}
|
||
|
||
/** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */
|
||
const GAP_PX = 40;
|
||
/** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */
|
||
const COMPASS_STEPS = 16;
|
||
|
||
export interface CurveLabelState {
|
||
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
|
||
seat: number;
|
||
/** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */
|
||
at: [number, number];
|
||
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
|
||
centerDirection: [number, number] | null;
|
||
/** 패널이 넘어가면 안 되는 테두리(화면 좌표) — 보통 모달의 지도 칸. 없으면 안 가둔다. */
|
||
bounds?: { left: number; top: number; right: number; bottom: number };
|
||
curveOn: boolean;
|
||
radiusShown: number | null;
|
||
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||
arcLengthShown: number | null;
|
||
lock: CurveLock;
|
||
/** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */
|
||
apexLocked: boolean;
|
||
/** 내각 155° 이상이라 **곡선을 안 둬도 되는** 자리인가(별표2 Ⅰ.2.다.(1)). */
|
||
curveOptional: boolean;
|
||
innerAngleDeg: number | null;
|
||
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||
limitRadiusM?: number;
|
||
limitArcM?: number;
|
||
}
|
||
|
||
export interface CurveLabelHandlers {
|
||
/** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */
|
||
onClose: () => void;
|
||
onRadius: (value: number | null) => void;
|
||
onArcLength: (value: number | null) => void;
|
||
onCurveOn: (on: boolean) => void;
|
||
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
|
||
onLock: (lock: CurveLock) => void;
|
||
/** 교각점 자리를 못 박거나 푼다. */
|
||
onApexLock: (locked: boolean) => void;
|
||
}
|
||
|
||
export interface CurveLabel {
|
||
show: (state: CurveLabelState) => void;
|
||
hide: () => void;
|
||
/** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
|
||
destroy: () => void;
|
||
}
|
||
|
||
/** 방향을 16방위 중 가장 가까운 것으로 맞춘다. */
|
||
function quantize(dx: number, dy: number): [number, number] {
|
||
const step = (2 * Math.PI) / COMPASS_STEPS;
|
||
const angle = Math.round(Math.atan2(dy, dx) / step) * step;
|
||
return [Math.cos(angle), Math.sin(angle)];
|
||
}
|
||
|
||
/** 가운데에서 그 방향으로 상자 가장자리까지의 거리 — 방위마다 다르다. */
|
||
function boxReach(dx: number, dy: number, width: number, height: number): number {
|
||
const byX = Math.abs(dx) < 1e-9 ? Infinity : width / 2 / Math.abs(dx);
|
||
const byY = Math.abs(dy) < 1e-9 ? Infinity : height / 2 / Math.abs(dy);
|
||
return Math.min(byX, byY);
|
||
}
|
||
|
||
/** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */
|
||
export function createCurveLabel(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>
|
||
<button type="button" class="b05-routeedit__label-close" data-act="curve-close"
|
||
aria-label="닫기" title="닫기">✕</button>
|
||
</div>
|
||
<label class="b05-routeedit__curve-field">반지름
|
||
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
||
<span>m</span>
|
||
<button type="button" class="b05-routeedit__lock" data-act="lock-radius"
|
||
title="반지름 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||
</label>
|
||
<label class="b05-routeedit__curve-field">곡선 길이
|
||
<input type="number" class="b05-routeedit__curve-arc" min="1" step="0.5" />
|
||
<span>m</span>
|
||
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
|
||
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||
</label>
|
||
<div class="b05-routeedit__curve-field">교각점
|
||
<button type="button" class="b05-routeedit__lock" data-act="lock-apex"
|
||
title="교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다">고정</button>
|
||
</div>
|
||
<span class="b05-routeedit__curve-info"></span>`;
|
||
document.body.append(root);
|
||
|
||
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
|
||
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 arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
|
||
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
|
||
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
|
||
const lockApex = root.querySelector<HTMLButtonElement>('[data-act="lock-apex"]')!;
|
||
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||
|
||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
|
||
root.addEventListener(type, (event) => event.stopPropagation());
|
||
}
|
||
|
||
let curveOn = true;
|
||
let lock: CurveLock = null;
|
||
let apexLocked = false;
|
||
let seat = -1;
|
||
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
|
||
let manual: [number, number] | null = null;
|
||
let anchor: [number, number] = [0, 0];
|
||
/** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */
|
||
let limitRadius = 0;
|
||
let limitArc = 0;
|
||
/** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */
|
||
let limit: CurveLabelState["bounds"];
|
||
|
||
/** 칸에서 읽은 값. **하한 아래는 하한에서 멈추고, 멈춘 값을 칸에 되적어 보인다**
|
||
* (2026-09-12 사용자 확정 「아예 못 넘게 막음」) — 조용히 바꾸면 왜 안 먹었는지 모른다. */
|
||
const numberOf = (input: HTMLInputElement, floor: number): number | null => {
|
||
const value = Number(input.value);
|
||
if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null;
|
||
if (floor > 0 && value < floor) {
|
||
input.value = String(floor);
|
||
return floor;
|
||
}
|
||
return value;
|
||
};
|
||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
|
||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
|
||
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
|
||
root
|
||
.querySelector('[data-act="curve-close"]')!
|
||
.addEventListener("click", () => handlers.onClose());
|
||
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
|
||
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
|
||
// 교각점 고정은 **곡선 유무와 무관**하다 — 곡선을 지운 자리도 꺾임점 자리는 못 박을 수 있다.
|
||
lockApex.addEventListener("click", () => handlers.onApexLock(!apexLocked));
|
||
|
||
// ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ──
|
||
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
||
head.addEventListener("pointerdown", (event) => {
|
||
if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로.
|
||
dragFrom = {
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
left: root.offsetLeft,
|
||
top: root.offsetTop,
|
||
};
|
||
head.setPointerCapture(event.pointerId);
|
||
event.preventDefault();
|
||
});
|
||
head.addEventListener("pointermove", (event) => {
|
||
if (!dragFrom) return;
|
||
// 끄는 동안에도 테두리를 지킨다 — 놓은 뒤에만 가두면 손이 간 자리에서 패널이 튄다.
|
||
const [left, top] = clamp(
|
||
dragFrom.left + event.clientX - dragFrom.x,
|
||
dragFrom.top + event.clientY - dragFrom.y,
|
||
root.offsetWidth,
|
||
root.offsetHeight,
|
||
limit,
|
||
);
|
||
root.style.left = `${Math.round(left)}px`;
|
||
root.style.top = `${Math.round(top)}px`;
|
||
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
|
||
manual = [left - anchor[0], top - anchor[1]];
|
||
});
|
||
const stopDrag = (event: PointerEvent): void => {
|
||
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
|
||
dragFrom = null;
|
||
};
|
||
head.addEventListener("pointerup", stopDrag);
|
||
head.addEventListener("pointercancel", stopDrag);
|
||
|
||
/** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다.
|
||
* 마지막에 **테두리 안으로 가둔다** — 자동 자리든 손으로 옮긴 자리든 같이 갇힌다. */
|
||
function place(state: CurveLabelState): void {
|
||
const width = root.offsetWidth;
|
||
const height = root.offsetHeight;
|
||
const [nx, ny] = state.at;
|
||
const away = state.centerDirection
|
||
? quantize(-state.centerDirection[0], -state.centerDirection[1])
|
||
: ([1, 0] as [number, number]);
|
||
const distance = GAP_PX + boxReach(away[0], away[1], width, height);
|
||
anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2];
|
||
const [left, top] = clamp(
|
||
anchor[0] + (manual ? manual[0] : 0),
|
||
anchor[1] + (manual ? manual[1] : 0),
|
||
width,
|
||
height,
|
||
state.bounds,
|
||
);
|
||
root.style.left = `${Math.round(left)}px`;
|
||
root.style.top = `${Math.round(top)}px`;
|
||
}
|
||
|
||
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
|
||
function clamp(
|
||
left: number,
|
||
top: number,
|
||
width: number,
|
||
height: number,
|
||
bounds: CurveLabelState["bounds"],
|
||
): [number, number] {
|
||
if (!bounds) return [left, top];
|
||
return [
|
||
Math.max(bounds.left, Math.min(left, bounds.right - width)),
|
||
Math.max(bounds.top, Math.min(top, bounds.bottom - height)),
|
||
];
|
||
}
|
||
|
||
return {
|
||
show(state) {
|
||
if (state.seat !== seat) {
|
||
seat = state.seat;
|
||
manual = null; // 다른 꺾임점이면 자동 자리부터 다시.
|
||
}
|
||
curveOn = state.curveOn;
|
||
lock = state.lock;
|
||
apexLocked = state.apexLocked;
|
||
limit = state.bounds;
|
||
limitRadius = state.limitRadiusM ?? 0;
|
||
limitArc = state.limitArcM ?? 0;
|
||
// 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다.
|
||
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
|
||
arc.min = limitArc > 0 ? String(limitArc) : "1";
|
||
root.hidden = false;
|
||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||
radius.disabled = !state.curveOn;
|
||
arc.disabled = !state.curveOn;
|
||
lockRadius.disabled = !state.curveOn;
|
||
lockArc.disabled = !state.curveOn;
|
||
lockRadius.classList.toggle("is-on", lock === "radius");
|
||
lockArc.classList.toggle("is-on", lock === "arc");
|
||
lockApex.classList.toggle("is-on", apexLocked);
|
||
radius.value =
|
||
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
|
||
arc.value =
|
||
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
|
||
const inner = state.innerAngleDeg;
|
||
// 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로,
|
||
// 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다.
|
||
// 내각 155° 이상은 법이 곡선을 안 둬도 된다고 한 자리다 — L 하한을 안 거는 까닭이
|
||
// 여기서 보여야 한다(2026-09-12 사용자 확정).
|
||
info.textContent = state.curveOn
|
||
? inner
|
||
? `내각 ${Math.round(inner)}°` +
|
||
(state.curveOptional
|
||
? ` · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능`
|
||
: "")
|
||
: ""
|
||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||
place(state);
|
||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
|
||
requestAnimationFrame(() => {
|
||
if (!root.hidden) place(state);
|
||
});
|
||
},
|
||
hide() {
|
||
root.hidden = true;
|
||
},
|
||
destroy() {
|
||
root.remove();
|
||
},
|
||
};
|
||
}
|