Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts
T
eomsangdonandClaude Opus 5 1fafe2548c fix(b05): 곡선 길이(L) 하한도 지키게 — 내각 155° 이상은 예외
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
2026-09-12 21:48:39 +09:00

323 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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();
},
};
}