fix(B05): 노선 편집 접선점을 늘 보이게, 라벨을 R+곡선 길이로, 중심 반대쪽에 배치
사용자 지적 3건 반영. - **접선점 표기 복구** — 손잡이를 「고른 곡선만」으로 줄이면서 직선↔R 만나는 자리의 **표기까지 없앴음**. 접선점은 손잡이이기 이전에 읽을 정보라 **늘 그림**(고른 곡선은 속을 채워 도드라지게). 노드를 못 집던 문제는 집기 우선순위로 이미 풀려 겹치지 않음. - **라벨을 반지름 + 곡선 길이 두 칸으로, [자동] 단추 삭제** — 교각 Δ 는 앞뒤 직선이 정하므로 L = R·Δ 로 묶임. 길이를 받으면 R 로 바꿔 한 값만 보관. 칸을 비우면 자동. - **라벨 자리를 곡선 중심의 반대쪽으로**(상하좌우) — 중심 쪽에 두면 곡선을 가림. 중심 방향은 접선점 두 방향 단위벡터의 합(각 이등분선)으로 구함. - ⚠ 라벨 글자가 안 읽히던 것 — `--color-surface-2` 가 이 테마에 없어 밝은 기본값으로 떨어졌음. 모달 본체와 같은 토큰으로 교체(실측 배경 rgb(37,31,56)·글자 rgb(228,224,240)). - 상자 높이가 늦게 자라 자리가 11px 어긋나던 것도 다음 프레임에 다시 맞추게 함. - 700줄 유지 — 교각·중심방향은 `_Label`, 등고선 띠는 `_Input` 으로 옮겨 본체 687줄. 실화면 검증 — 접선점이 고르기 전에도 보임 / 곡선 길이 40 → 반지름 203.7m 로 따라옴 / 라벨이 중심 반대쪽(위)에 붙음 / [자동] 없음. 554 passed · 18 skipped, tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,11 +32,16 @@ import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
|
||||
import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
bindRouteEditNavigation,
|
||||
contourBandRect,
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import {
|
||||
bindHistoryControls,
|
||||
createRouteEditHistory,
|
||||
@@ -199,34 +204,6 @@ 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);
|
||||
@@ -237,7 +214,9 @@ export async function openRouteEditModal(
|
||||
context.save();
|
||||
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
||||
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
||||
const band = contourBandRect();
|
||||
const band = meta
|
||||
? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M)
|
||||
: null;
|
||||
if (band) {
|
||||
context.beginPath();
|
||||
context.rect(band.x, band.y, band.width, band.height);
|
||||
@@ -293,21 +272,21 @@ export async function openRouteEditModal(
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
context.lineWidth = 2;
|
||||
curveInfo.forEach((curve) => {
|
||||
// **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서
|
||||
// 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다.
|
||||
if (curve.node_first !== picked) return;
|
||||
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
||||
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
||||
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
||||
const on = curveOn[curve.node_first] !== false;
|
||||
if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다.
|
||||
if (!on) return; // 곡선을 지운 자리에는 접선점도 없다.
|
||||
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
||||
const isPicked = curve.node_first === picked;
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
x - CURVE_HANDLE_PX,
|
||||
y - CURVE_HANDLE_PX,
|
||||
CURVE_HANDLE_PX * 2,
|
||||
CURVE_HANDLE_PX * 2,
|
||||
);
|
||||
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
||||
context.rect(x - size, y - size, size * 2, size * 2);
|
||||
context.fillStyle = isPicked
|
||||
? style.getPropertyValue("--map-route") || "#f97316"
|
||||
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
context.fill();
|
||||
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.stroke();
|
||||
@@ -320,7 +299,7 @@ export async function openRouteEditModal(
|
||||
|
||||
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
|
||||
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
||||
handleAtScreen(curveInfo, picked, toScreen, px, py, NODE_HIT_PX + 2);
|
||||
handleAtScreen(curveInfo, 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 =>
|
||||
@@ -374,7 +353,7 @@ export async function openRouteEditModal(
|
||||
);
|
||||
}
|
||||
|
||||
// ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ──
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
|
||||
const curveLabelBox = createCurveLabel(canvas.parentElement!, {
|
||||
onRadius: (value) => {
|
||||
if (picked < 0) return;
|
||||
@@ -382,16 +361,19 @@ export async function openRouteEditModal(
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
onAuto: () => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다.
|
||||
applyEdit("반지름을 자동으로 되돌렸습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
@@ -401,13 +383,23 @@ export async function openRouteEditModal(
|
||||
return;
|
||||
}
|
||||
const forced = curveRadius[picked];
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = forced ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveLabelBox.show({
|
||||
seat: picked,
|
||||
at: toScreen(planned[picked]),
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
toScreen(pickedCurve.start),
|
||||
toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
canvas: { width: view.width, height: view.height },
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown:
|
||||
forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
forced: forced !== null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
minRadiusM,
|
||||
|
||||
@@ -126,14 +126,14 @@ export function nodeAtScreen(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null.
|
||||
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(접선점). 없으면 null.
|
||||
*
|
||||
* **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라
|
||||
* **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한
|
||||
* 곡선을 가리킨다. */
|
||||
* 곡선 **전부**를 본다 — 접선점은 늘 그려지므로 늘 잡혀야 한다. 노드를 못 집던 문제는
|
||||
* 부르는 쪽에서 **노드를 먼저** 보는 것으로 풀었다(2026-09-07).
|
||||
* 잡은 것은 곡선 목록 자리가 아니라 **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로
|
||||
* 자리로 들면 끄는 도중 엉뚱한 곡선을 가리킨다. */
|
||||
export function handleAtScreen(
|
||||
curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>,
|
||||
picked: number,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
@@ -142,7 +142,6 @@ export function handleAtScreen(
|
||||
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]]);
|
||||
@@ -181,3 +180,34 @@ export function segmentAtScreen(
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null.
|
||||
*
|
||||
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
|
||||
* 미터로 정해 두므로 **창 크기와 무관**하다(2026-09-07 사용자 지시 ⑥). */
|
||||
export function contourBandRect(
|
||||
line: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
bandM: number,
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
if (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 - bandM, minY - bandM]);
|
||||
const [right, top] = toScreen([maxX + bandM, maxY + bandM]);
|
||||
return {
|
||||
x: Math.min(left, right),
|
||||
y: Math.min(top, bottom),
|
||||
width: Math.abs(right - left),
|
||||
height: Math.abs(bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Label.ts
|
||||
* 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다.
|
||||
* 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다.
|
||||
*
|
||||
* 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이
|
||||
* 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다.
|
||||
* 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다.
|
||||
*
|
||||
* 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을
|
||||
* 골라도 입력칸이 잘리지 않아야 한다.
|
||||
* **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이
|
||||
* 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」
|
||||
* 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다.
|
||||
*
|
||||
* **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을
|
||||
* 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
|
||||
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
|
||||
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
|
||||
return ((180 - innerAngleDeg) * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다.
|
||||
*
|
||||
* 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라
|
||||
* 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 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 = 14;
|
||||
/** 캔버스 가장자리에서 이만큼은 띄운다(px). */
|
||||
@@ -20,11 +52,16 @@ export interface CurveLabelState {
|
||||
seat: number;
|
||||
/** 그 꺾임점의 화면 좌표(캔버스 기준 px). */
|
||||
at: [number, number];
|
||||
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다.
|
||||
* 곡선이 없으면 null — 그때는 오른쪽에 둔다. */
|
||||
centerDirection: [number, number] | null;
|
||||
/** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */
|
||||
canvas: { width: number; height: number };
|
||||
curveOn: boolean;
|
||||
/** 지금 보일 반지름(m). 곡선이 없으면 null. */
|
||||
radiusShown: number | null;
|
||||
/** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||||
arcLengthShown: number | null;
|
||||
/** 사용자가 못박은 값인지(아니면 자동). */
|
||||
forced: boolean;
|
||||
innerAngleDeg: number | null;
|
||||
@@ -32,12 +69,12 @@ export interface CurveLabelState {
|
||||
}
|
||||
|
||||
export interface CurveLabelHandlers {
|
||||
/** R 칸을 고쳤다 — null 이면 「자동」. */
|
||||
/** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */
|
||||
onRadius: (value: number | null) => void;
|
||||
/** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */
|
||||
onArcLength: (value: number | null) => void;
|
||||
/** 곡선을 지우거나 넣었다. */
|
||||
onCurveOn: (on: boolean) => void;
|
||||
/** 반지름을 자동으로 되돌렸다. */
|
||||
onAuto: () => void;
|
||||
}
|
||||
|
||||
export interface CurveLabel {
|
||||
@@ -55,10 +92,13 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
|
||||
<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
|
||||
<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__label-auto" data-act="curve-auto">자동</button>
|
||||
</label>
|
||||
<label class="b05-routeedit__curve-field">곡선 길이
|
||||
<input type="number" class="b05-routeedit__curve-arc" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
host.append(root);
|
||||
@@ -66,7 +106,7 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
|
||||
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 arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
|
||||
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||||
|
||||
// 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
@@ -75,23 +115,36 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
|
||||
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);
|
||||
});
|
||||
const numberOf = (input: HTMLInputElement): number | null => {
|
||||
const value = Number(input.value);
|
||||
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
};
|
||||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
|
||||
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
|
||||
auto.addEventListener("click", () => handlers.onAuto());
|
||||
|
||||
function place(at: [number, number], canvas: { width: number; height: number }): void {
|
||||
// 먼저 보여야 크기를 잴 수 있다 — 그 다음 자리를 잡는다.
|
||||
/** 라벨을 **곡선 중심의 반대쪽**에 붙인다 — 상하좌우 넷 중 하나. */
|
||||
function place(state: CurveLabelState): 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));
|
||||
const [nx, ny] = state.at;
|
||||
const direction = state.centerDirection;
|
||||
let left = nx + GAP_PX; // 곡선이 없으면 오른쪽이 기본이다.
|
||||
let top = ny - height / 2;
|
||||
if (direction) {
|
||||
const [dx, dy] = direction;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
// 중심이 오른쪽이면 라벨은 왼쪽으로.
|
||||
left = dx > 0 ? nx - GAP_PX - width : nx + GAP_PX;
|
||||
top = ny - height / 2;
|
||||
} else {
|
||||
left = nx - width / 2;
|
||||
top = dy > 0 ? ny - GAP_PX - height : ny + GAP_PX;
|
||||
}
|
||||
}
|
||||
// 캔버스 밖으로 나가면 안으로 접는다 — 노선 끝을 골라도 칸이 잘리지 않게.
|
||||
left = Math.max(EDGE_PX, Math.min(left, state.canvas.width - width - EDGE_PX));
|
||||
top = Math.max(EDGE_PX, Math.min(top, state.canvas.height - height - EDGE_PX));
|
||||
root.style.left = `${Math.round(left)}px`;
|
||||
root.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
@@ -103,15 +156,22 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
|
||||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
radius.disabled = !state.curveOn;
|
||||
auto.disabled = !state.curveOn || !state.forced;
|
||||
arc.disabled = !state.curveOn;
|
||||
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;
|
||||
info.textContent = state.curveOn
|
||||
? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
|
||||
` · 법정 하한 ${state.minRadiusM}m`
|
||||
` · 법정 하한 ${state.minRadiusM}m · 칸을 비우면 자동`
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
place(state.at, state.canvas);
|
||||
place(state);
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다
|
||||
// (2026-09-07 실측: 처음 잰 높이 120px, 실제 131px 라 11px 어긋났음).
|
||||
requestAnimationFrame(() => {
|
||||
if (!root.hidden) place(state);
|
||||
});
|
||||
},
|
||||
hide() {
|
||||
root.hidden = true;
|
||||
|
||||
@@ -140,13 +140,16 @@
|
||||
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);
|
||||
min-width: 12rem;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
/* 모달 본체와 **같은 토큰**을 쓴다 — `--color-surface-2` 는 이 테마에 없어 밝은
|
||||
기본값으로 떨어지면서 다크 화면에서 글자가 안 읽혔다(2026-09-07 실화면). */
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 45%);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__label-head {
|
||||
@@ -156,21 +159,18 @@
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b05-routeedit__label-toggle,
|
||||
.b05-routeedit__label-auto {
|
||||
.b05-routeedit__label-toggle {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
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);
|
||||
.b05-routeedit__label-toggle:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -180,28 +180,32 @@
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-field {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-radius {
|
||||
width: 4.5rem;
|
||||
.b05-routeedit__curve-radius,
|
||||
.b05-routeedit__curve-arc {
|
||||
width: 5rem;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-radius:disabled {
|
||||
background: var(--color-surface-3, #f3f4f6);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
.b05-routeedit__curve-radius:disabled,
|
||||
.b05-routeedit__curve-arc:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-info {
|
||||
max-width: 16rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
max-width: 15rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user