feat(b05): 계획노선 편집 손질 여섯 — 교각점 고정·횡단 벡터화·라벨 맨 위
- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`apexLock`) - 계획 횡단선을 절·성토 교차점에서 끊음 — 그 밖은 원지반이라 지반선과 겹쳐 그려지던 것. 교차점은 B06 이 쓰는 `toeOffsets` 를 열어 함께 씀 - 횡단 판을 캔버스에서 SVG 로 바꾸고 휠 줌·가운데 끌기 팬·더블클릭 원복을 붙임 - 줌·팬 본문을 `A00_Common/b_svg_zoom_pan.ts` 공용 조각으로 빼고 B06 은 감싸개만 남김 - 「이전 횡단」 판은 계획 평면선이 바뀔 때만 채움(같은 측점 두 번 누르기로는 안 채움) - 「거리 재기」 단추의 눌린 꼴 추가 · 등고선 높이 라벨을 맨 위로 올리고 더 촘촘히 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
This commit is contained in:
@@ -0,0 +1,219 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* b_svg_zoom_pan.ts
|
||||||
|
* SVG 그래프 한 장의 **확대·축소·팬** — 여러 화면이 한 코드를 쓴다.
|
||||||
|
*
|
||||||
|
* B06 횡단 카드(`B06_Section_UI_Cross_View_Zoom.ts`)에 있던 것을 옮겨 온 것이다
|
||||||
|
* (2026-09-12 사용자 지시 「공용화해줘」). 동작 규칙은 그때 정해진 것을 그대로 지킨다.
|
||||||
|
*
|
||||||
|
* · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼은 그 화면의 고르기·조작 몫이다.
|
||||||
|
* · **더블클릭이면 원배율**로 돌아간다.
|
||||||
|
* · **휠은 화면마다 다르다** — 카드가 수십 장 깔리는 B06 은 휠을 줌에 묶지 않는다
|
||||||
|
* (묶으면 목록을 훑을 수가 없다, 2026-08-02 사용자 확정). 판이 둘뿐인 B05 계획노선
|
||||||
|
* 횡단은 휠 = 줌이다(2026-09-12 사용자 지시 ③). `wheelZoom` 한 칸으로 가른다.
|
||||||
|
*
|
||||||
|
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||||||
|
export interface ZoomPanState {
|
||||||
|
scale: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 범위 밖에 있어 잘려 있는
|
||||||
|
* **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다
|
||||||
|
* (2026-08-23 사용자 지시).
|
||||||
|
*/
|
||||||
|
export interface ContentBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomPanHandle {
|
||||||
|
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
|
||||||
|
zoom: (factor: number) => void;
|
||||||
|
reset: () => void;
|
||||||
|
/** 현재 배율 — 원배율(1)일 때는 부르는 쪽 버튼이 배율 대신 표시 폭을 조절한다. */
|
||||||
|
scale: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 축 안쪽 여백(px) — 확대·축소의 중심이자 이동 한계의 기준이 된다. */
|
||||||
|
export interface ZoomPanPad {
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SvgZoomPanOptions {
|
||||||
|
svg: SVGSVGElement;
|
||||||
|
/** 밀고 키울 도형 묶음 — 여기에만 `transform` 이 적힌다. */
|
||||||
|
layer: SVGGElement;
|
||||||
|
/** `viewBox` 의 폭·높이(원배율 SVG 좌표). */
|
||||||
|
widthPx: number;
|
||||||
|
heightPx: number;
|
||||||
|
pad: ZoomPanPad;
|
||||||
|
/** 배율 상한. 안 주면 8. */
|
||||||
|
maxScale?: number;
|
||||||
|
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
||||||
|
initial?: ZoomPanState;
|
||||||
|
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
||||||
|
onChange?: (state: ZoomPanState) => void;
|
||||||
|
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||||
|
content?: ContentBounds;
|
||||||
|
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
|
||||||
|
wheelZoom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
|
||||||
|
const WHEEL_IN = 1.15;
|
||||||
|
const WHEEL_OUT = 0.87;
|
||||||
|
|
||||||
|
export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||||
|
const { svg, layer, widthPx, heightPx, pad, initial, onChange, content } = options;
|
||||||
|
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
|
||||||
|
const plot = {
|
||||||
|
x: pad.left,
|
||||||
|
y: pad.top,
|
||||||
|
width: Math.max(widthPx - pad.left - pad.right, 1),
|
||||||
|
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
||||||
|
};
|
||||||
|
const maxScale = options.maxScale ?? 8;
|
||||||
|
let scale = initial?.scale ?? 1;
|
||||||
|
let tx = initial?.tx ?? 0;
|
||||||
|
let ty = initial?.ty ?? 0;
|
||||||
|
const applyTransform = (): void => {
|
||||||
|
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||||
|
onChange?.({ scale, tx, ty });
|
||||||
|
};
|
||||||
|
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
|
||||||
|
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 범위 밖 캐시 보유분 포함)까지 늘어나,
|
||||||
|
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
|
||||||
|
const clampAxis = (
|
||||||
|
value: number,
|
||||||
|
start: number,
|
||||||
|
size: number,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
): number =>
|
||||||
|
Math.min(
|
||||||
|
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
|
||||||
|
Math.max(start + size - scale * to, start - scale * from),
|
||||||
|
);
|
||||||
|
const clampPan = (): void => {
|
||||||
|
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
|
||||||
|
// 범위까지 열어 둔다 — 캐시 보유분이 표시 폭보다 넓으면 1배에서도 끌어다 봐야 한다
|
||||||
|
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
|
||||||
|
const bounds = content
|
||||||
|
? {
|
||||||
|
x: Math.min(content.x, plot.x),
|
||||||
|
y: Math.min(content.y, plot.y),
|
||||||
|
right: Math.max(content.x + content.width, plot.x + plot.width),
|
||||||
|
bottom: Math.max(content.y + content.height, plot.y + plot.height),
|
||||||
|
}
|
||||||
|
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
|
||||||
|
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
|
||||||
|
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
|
||||||
|
const zoomAt = (factor: number, cx: number, cy: number): void => {
|
||||||
|
const next = Math.min(maxScale, Math.max(1, scale * factor));
|
||||||
|
tx = cx - ((cx - tx) / scale) * next;
|
||||||
|
ty = cy - ((cy - ty) / scale) * next;
|
||||||
|
scale = next;
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
};
|
||||||
|
// 버튼에는 마우스 자리가 없다 — 보이는 **플롯 영역의 중앙**을 붙잡는다.
|
||||||
|
const zoom = (factor: number): void =>
|
||||||
|
zoomAt(factor, plot.x + plot.width / 2, plot.y + plot.height / 2);
|
||||||
|
|
||||||
|
let panning = false;
|
||||||
|
let moved = false;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
|
||||||
|
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
|
||||||
|
svg.addEventListener("mousedown", (event) => {
|
||||||
|
if (event.button === 1) event.preventDefault();
|
||||||
|
});
|
||||||
|
svg.addEventListener("auxclick", (event) => {
|
||||||
|
if (event.button === 1) event.preventDefault();
|
||||||
|
});
|
||||||
|
svg.addEventListener("pointerdown", (event) => {
|
||||||
|
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
|
||||||
|
if (event.button !== 1) return;
|
||||||
|
event.preventDefault();
|
||||||
|
panning = true;
|
||||||
|
moved = false;
|
||||||
|
lastX = event.clientX;
|
||||||
|
lastY = event.clientY;
|
||||||
|
svg.classList.add("is-panning");
|
||||||
|
svg.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
svg.addEventListener("pointermove", (event) => {
|
||||||
|
if (!panning) return;
|
||||||
|
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
|
||||||
|
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
tx += ((event.clientX - lastX) / rect.width) * widthPx;
|
||||||
|
ty += ((event.clientY - lastY) / rect.height) * heightPx;
|
||||||
|
lastX = event.clientX;
|
||||||
|
lastY = event.clientY;
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
});
|
||||||
|
const endPan = (event: PointerEvent): void => {
|
||||||
|
if (!panning) return;
|
||||||
|
panning = false;
|
||||||
|
svg.classList.remove("is-panning");
|
||||||
|
try {
|
||||||
|
svg.releasePointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* 이미 해제됨 */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
svg.addEventListener("pointerup", endPan);
|
||||||
|
svg.addEventListener("pointercancel", endPan);
|
||||||
|
// 드래그(팬)로 끝난 클릭은 바깥(카드 고르기 등)으로 전파하지 않는다.
|
||||||
|
svg.addEventListener("click", (event) => {
|
||||||
|
if (moved) event.stopPropagation();
|
||||||
|
});
|
||||||
|
if (options.wheelZoom) {
|
||||||
|
svg.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
// 커서 아래 지점이 제자리에 남게 **그 자리**를 붙잡는다.
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
|
||||||
|
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
|
||||||
|
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
|
||||||
|
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentScale = (): number => scale;
|
||||||
|
const reset = (): void => {
|
||||||
|
scale = 1;
|
||||||
|
tx = 0;
|
||||||
|
ty = 0;
|
||||||
|
applyTransform();
|
||||||
|
};
|
||||||
|
// 더블클릭 원복.
|
||||||
|
svg.addEventListener("dblclick", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
return { zoom, reset, scale: currentScale };
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ const SEGMENT_HIT_PX = 12;
|
|||||||
const CONTOUR_HIT_PX = 6;
|
const CONTOUR_HIT_PX = 6;
|
||||||
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
|
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
|
||||||
const STATION_HIT_PX = 11;
|
const STATION_HIT_PX = 11;
|
||||||
|
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||||
|
* (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로
|
||||||
|
* 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */
|
||||||
|
const CONTOUR_LEVEL_BUDGET = 1200;
|
||||||
|
|
||||||
type Vertex = [number, number];
|
type Vertex = [number, number];
|
||||||
|
|
||||||
@@ -118,6 +122,9 @@ export async function openRouteEditModal(
|
|||||||
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
||||||
let curveLock: CurveLock[] = [];
|
let curveLock: CurveLock[] = [];
|
||||||
let curveArc: Array<number | null> = [];
|
let curveArc: Array<number | null> = [];
|
||||||
|
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도
|
||||||
|
* 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */
|
||||||
|
let apexLock: boolean[] = [];
|
||||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||||
let picked = -1;
|
let picked = -1;
|
||||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||||
@@ -258,7 +265,7 @@ export async function openRouteEditModal(
|
|||||||
|
|
||||||
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
||||||
const contourStepM = (): number =>
|
const contourStepM = (): number =>
|
||||||
contours ? pickLevelStep(contours.layer, view, contours.intervalM) : 0;
|
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
|
||||||
|
|
||||||
function draw(): void {
|
function draw(): void {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
@@ -291,7 +298,10 @@ export async function openRouteEditModal(
|
|||||||
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
||||||
handleAtScreen(
|
handleAtScreen(
|
||||||
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
|
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
|
||||||
curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null),
|
curveInfo.filter(
|
||||||
|
(entry) =>
|
||||||
|
(curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true,
|
||||||
|
),
|
||||||
toScreen,
|
toScreen,
|
||||||
px,
|
px,
|
||||||
py,
|
py,
|
||||||
@@ -354,6 +364,7 @@ export async function openRouteEditModal(
|
|||||||
curveOn,
|
curveOn,
|
||||||
curveRadius,
|
curveRadius,
|
||||||
curveLock,
|
curveLock,
|
||||||
|
apexLock,
|
||||||
curveCount: curveInfo.length,
|
curveCount: curveInfo.length,
|
||||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||||
minRadiusM,
|
minRadiusM,
|
||||||
@@ -372,6 +383,7 @@ export async function openRouteEditModal(
|
|||||||
curveRadius,
|
curveRadius,
|
||||||
curveLock,
|
curveLock,
|
||||||
curveArc,
|
curveArc,
|
||||||
|
apexLock,
|
||||||
limitRadiusM,
|
limitRadiusM,
|
||||||
limitArcM,
|
limitArcM,
|
||||||
}),
|
}),
|
||||||
@@ -398,7 +410,7 @@ export async function openRouteEditModal(
|
|||||||
|
|
||||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||||
function snapshotNow(): RouteEditSnapshot {
|
function snapshotNow(): RouteEditSnapshot {
|
||||||
return { planned, curveOn, curveRadius, curveLock, curveArc, picked };
|
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyControls = bindHistoryControls({
|
const historyControls = bindHistoryControls({
|
||||||
@@ -410,6 +422,7 @@ export async function openRouteEditModal(
|
|||||||
curveRadius = snapshot.curveRadius;
|
curveRadius = snapshot.curveRadius;
|
||||||
curveLock = snapshot.curveLock;
|
curveLock = snapshot.curveLock;
|
||||||
curveArc = snapshot.curveArc;
|
curveArc = snapshot.curveArc;
|
||||||
|
apexLock = snapshot.apexLock;
|
||||||
picked = snapshot.picked;
|
picked = snapshot.picked;
|
||||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||||
},
|
},
|
||||||
@@ -436,11 +449,13 @@ export async function openRouteEditModal(
|
|||||||
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
||||||
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
||||||
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
|
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
|
||||||
dragNode = nodeAt(px, py);
|
const hitNode = nodeAt(px, py);
|
||||||
dragHandle = dragNode >= 0 ? null : handleAt(px, py);
|
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
|
||||||
|
dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1;
|
||||||
|
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
if (dragNode >= 0) {
|
if (hitNode >= 0) {
|
||||||
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
||||||
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
|
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
|
||||||
syncCurveBar();
|
syncCurveBar();
|
||||||
draw();
|
draw();
|
||||||
@@ -538,7 +553,16 @@ export async function openRouteEditModal(
|
|||||||
draw();
|
draw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default";
|
const overNode = nodeAt(px, py);
|
||||||
|
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
|
||||||
|
canvas.style.cursor =
|
||||||
|
overNode >= 0
|
||||||
|
? apexLock[overNode]
|
||||||
|
? "not-allowed"
|
||||||
|
: "grab"
|
||||||
|
: handleAt(px, py)
|
||||||
|
? "grab"
|
||||||
|
: "default";
|
||||||
});
|
});
|
||||||
|
|
||||||
const endDrag = (event: PointerEvent): void => {
|
const endDrag = (event: PointerEvent): void => {
|
||||||
@@ -570,6 +594,7 @@ export async function openRouteEditModal(
|
|||||||
curveRadius.splice(segment + 1, 0, null);
|
curveRadius.splice(segment + 1, 0, null);
|
||||||
curveLock.splice(segment + 1, 0, null);
|
curveLock.splice(segment + 1, 0, null);
|
||||||
curveArc.splice(segment + 1, 0, null);
|
curveArc.splice(segment + 1, 0, null);
|
||||||
|
apexLock.splice(segment + 1, 0, false);
|
||||||
picked = segment + 1;
|
picked = segment + 1;
|
||||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||||
});
|
});
|
||||||
@@ -588,6 +613,7 @@ export async function openRouteEditModal(
|
|||||||
curveRadius.splice(index, 1);
|
curveRadius.splice(index, 1);
|
||||||
curveLock.splice(index, 1);
|
curveLock.splice(index, 1);
|
||||||
curveArc.splice(index, 1);
|
curveArc.splice(index, 1);
|
||||||
|
apexLock.splice(index, 1);
|
||||||
picked = -1;
|
picked = -1;
|
||||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||||
});
|
});
|
||||||
@@ -638,6 +664,7 @@ export async function openRouteEditModal(
|
|||||||
curveRadius = flat.curveRadius;
|
curveRadius = flat.curveRadius;
|
||||||
curveLock = flat.curveLock;
|
curveLock = flat.curveLock;
|
||||||
curveArc = flat.curveArc;
|
curveArc = flat.curveArc;
|
||||||
|
apexLock = flat.apexLock;
|
||||||
picked = -1;
|
picked = -1;
|
||||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||||
history = createRouteEditHistory(snapshotNow());
|
history = createRouteEditHistory(snapshotNow());
|
||||||
|
|||||||
@@ -16,8 +16,13 @@
|
|||||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
|
||||||
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||||
import { drawCross, summarizeCross } from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
import {
|
||||||
|
buildCrossSvg,
|
||||||
|
CROSS_PLOT_PAD,
|
||||||
|
summarizeCross,
|
||||||
|
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
||||||
import { formatStation } from "./B05_Profile_Util_Station";
|
import { formatStation } from "./B05_Profile_Util_Station";
|
||||||
|
|
||||||
export interface CrossPreviewParams {
|
export interface CrossPreviewParams {
|
||||||
@@ -33,7 +38,7 @@ export interface CrossPreviewParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CrossPreviewWindow {
|
export interface CrossPreviewWindow {
|
||||||
/** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */
|
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
|
||||||
open: (chainageM: number) => Promise<void>;
|
open: (chainageM: number) => Promise<void>;
|
||||||
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
|
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -55,16 +60,15 @@ function createPane(title: string, empty: string): CrossPane {
|
|||||||
<strong class="b05-routeedit__cross-title">${title}</strong>
|
<strong class="b05-routeedit__cross-title">${title}</strong>
|
||||||
<span class="b05-routeedit__cross-station"></span>
|
<span class="b05-routeedit__cross-station"></span>
|
||||||
</div>
|
</div>
|
||||||
<canvas class="b05-routeedit__cross-canvas" width="420" height="240"></canvas>
|
<div class="b05-routeedit__cross-box"></div>
|
||||||
<div class="b05-routeedit__cross-foot">${empty}</div>`;
|
<div class="b05-routeedit__cross-foot">${empty}</div>`;
|
||||||
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
||||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||||
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
|
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
|
||||||
const context = canvas.getContext("2d")!;
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
show(preview, intervalM) {
|
show(preview, intervalM) {
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
box.replaceChildren();
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
station.textContent = "";
|
station.textContent = "";
|
||||||
foot.textContent = empty;
|
foot.textContent = empty;
|
||||||
@@ -73,11 +77,30 @@ function createPane(title: string, empty: string): CrossPane {
|
|||||||
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
||||||
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
||||||
station.textContent = formatStation(preview.chainage_m, intervalM);
|
station.textContent = formatStation(preview.chainage_m, intervalM);
|
||||||
drawCross(context, canvas, preview);
|
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
|
||||||
|
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
|
||||||
|
const rect = box.getBoundingClientRect();
|
||||||
|
const plot = buildCrossSvg(
|
||||||
|
preview,
|
||||||
|
Math.max(Math.round(rect.width), 240),
|
||||||
|
Math.max(Math.round(rect.height), 150),
|
||||||
|
);
|
||||||
|
box.append(plot.svg);
|
||||||
|
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
|
||||||
|
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
|
||||||
|
attachSvgZoomPan({
|
||||||
|
svg: plot.svg,
|
||||||
|
layer: plot.layer,
|
||||||
|
widthPx: plot.width,
|
||||||
|
heightPx: plot.height,
|
||||||
|
pad: CROSS_PLOT_PAD,
|
||||||
|
content: plot.content,
|
||||||
|
wheelZoom: true,
|
||||||
|
});
|
||||||
foot.textContent = summarizeCross(preview);
|
foot.textContent = summarizeCross(preview);
|
||||||
},
|
},
|
||||||
wait(text) {
|
wait(text) {
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
box.replaceChildren();
|
||||||
foot.textContent = text;
|
foot.textContent = text;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -117,11 +140,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
async open(chainageM) {
|
async open(chainageM) {
|
||||||
// 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다.
|
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
|
||||||
const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6;
|
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
|
||||||
if (!sameStation) previous.show(null, params.request().station_interval_m);
|
previous.show(null, params.request().station_interval_m);
|
||||||
watching = chainageM;
|
watching = chainageM;
|
||||||
await load(chainageM, sameStation);
|
await load(chainageM, false);
|
||||||
},
|
},
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (watching === null) return;
|
if (watching === null) return;
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
|
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
|
||||||
* 횡단 한 장을 캔버스에 그린다 — **원지반선·기본 계획 횡단선**과 아래 한 줄 요약.
|
* 횡단 한 장을 **SVG 선**으로 짓는다 — 원지반선·기본 계획 횡단선과 아래 한 줄 요약.
|
||||||
*
|
*
|
||||||
* `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정).
|
* `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정).
|
||||||
* 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다.
|
* 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다.
|
||||||
|
*
|
||||||
|
* **왜 캔버스가 아니라 SVG 인가**(2026-09-12 사용자 지시 ③) — 캔버스는 한 번 찍으면 그림이라
|
||||||
|
* 확대하면 뭉개진다. SVG 는 선이 그대로 남아 휠로 키워도 또렷하다. 확대·이동은 **공용
|
||||||
|
* 조각**(`A00_Common/b_svg_zoom_pan.ts`)이 맡는다 — B06 횡단 카드와 한 코드다.
|
||||||
|
*
|
||||||
|
* **계획 횡단선은 절·성토 교차점까지만 그린다**(2026-09-12 사용자 지시 ②). 서버가 주는
|
||||||
|
* `design_line` 은 계산 반폭 끝까지 이어지는데 **사면 끝 바깥은 손대지 않는 원지반**이라,
|
||||||
|
* 그대로 그으면 지반선과 겹쳐 같은 지형이 두 번 그려진다. B06 횡단 카드는 2026-08-20 에
|
||||||
|
* 같은 지적을 받아 이미 이렇게 끊고 있다(`appendCrossDesignOverlay`) — 교차점 찾기도
|
||||||
|
* 그쪽이 쓰는 `toeOffsets` 를 그대로 부른다.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { svgElement, svgText } from "@util/common_util_svg";
|
||||||
|
import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
|
||||||
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
||||||
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
|
import { fillSlopeLengths, toeOffsets } from "./../B06_Section/B06_Section_UI_Cross_Fit";
|
||||||
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||||
|
|
||||||
/** 그림 가장자리 여백(px). */
|
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
|
||||||
const PAD = 24;
|
export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
|
||||||
|
|
||||||
|
/** 지은 한 판 — 확대·이동은 `layer` 에만 걸린다(범례·안내 글자는 제자리). */
|
||||||
|
export interface CrossPlot {
|
||||||
|
svg: SVGSVGElement;
|
||||||
|
layer: SVGGElement;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** 실제로 선이 놓인 범위 — 확대했을 때 팬이 갈 수 있는 데까지. */
|
||||||
|
content: ContentBounds;
|
||||||
|
}
|
||||||
|
|
||||||
/** 성토사면 길이·절성토 면적 한 줄. */
|
/** 성토사면 길이·절성토 면적 한 줄. */
|
||||||
export function summarizeCross(preview: CrossPreviewResponse): string {
|
export function summarizeCross(preview: CrossPreviewResponse): string {
|
||||||
@@ -19,10 +41,7 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
|
|||||||
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
||||||
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
||||||
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
||||||
const lengths = fillSlopeLengths({
|
const lengths = fillSlopeLengths(asSection(preview));
|
||||||
samples: preview.samples,
|
|
||||||
design,
|
|
||||||
} as unknown as CrossSection);
|
|
||||||
const sides = (["left", "right"] as const)
|
const sides = (["left", "right"] as const)
|
||||||
.filter((side) => lengths[side] !== null)
|
.filter((side) => lengths[side] !== null)
|
||||||
.map((side) => {
|
.map((side) => {
|
||||||
@@ -34,27 +53,81 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
|
|||||||
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`;
|
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
|
||||||
|
function asSection(preview: CrossPreviewResponse): CrossSection {
|
||||||
|
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Point = [number, number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+offset)가 화면 왼쪽이다
|
* 선을 **절·성토 교차점 사이만** 남긴다(2026-09-12 사용자 지시 ②).
|
||||||
|
*
|
||||||
|
* 교차점이 꼭짓점 사이에 떨어지면 그 자리에 점을 하나 만들어 **딱 거기서 끊는다** — 가까운
|
||||||
|
* 꼭짓점에서 끊으면 사면 끝이 지반에 닿기 전에 멈춘 것처럼 보인다.
|
||||||
|
* 그 측에서 교차점을 못 찾으면(사면이 지반을 못 만난 측점) 그쪽은 안 자른다.
|
||||||
|
*/
|
||||||
|
function clipToToes(line: Point[], low: number, high: number): Point[] {
|
||||||
|
if (line.length < 2) return line;
|
||||||
|
const inside = (point: Point): boolean => point[0] >= low - 1e-9 && point[0] <= high + 1e-9;
|
||||||
|
const cutAt = (from: Point, to: Point, edge: number): Point => {
|
||||||
|
const span = to[0] - from[0];
|
||||||
|
const ratio = Math.abs(span) <= 1e-9 ? 0 : (edge - from[0]) / span;
|
||||||
|
return [edge, from[1] + (to[1] - from[1]) * ratio];
|
||||||
|
};
|
||||||
|
const out: Point[] = [];
|
||||||
|
line.forEach((point, index) => {
|
||||||
|
if (inside(point)) out.push(point);
|
||||||
|
const next = line[index + 1];
|
||||||
|
if (!next) return;
|
||||||
|
// 경계를 넘어가는 구간은 경계 자리에 점을 하나 세운다. 가까운 경계부터 넣어야 순서가 산다.
|
||||||
|
[low, high]
|
||||||
|
.filter((edge) => (point[0] - edge) * (next[0] - edge) < 0)
|
||||||
|
.sort((a, b) => Math.abs(a - point[0]) - Math.abs(b - point[0]))
|
||||||
|
.forEach((edge) => out.push(cutAt(point, next, edge)));
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원지반선과 기본 계획 횡단선을 한 판에 짓는다. 좌(+offset)가 화면 왼쪽이다
|
||||||
* (`generate_sections` cad_exchange 규약과 같은 방향).
|
* (`generate_sections` cad_exchange 규약과 같은 방향).
|
||||||
*
|
*
|
||||||
* **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
* **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
||||||
* 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
* 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
||||||
*/
|
*/
|
||||||
export function drawCross(
|
export function buildCrossSvg(
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
preview: CrossPreviewResponse,
|
preview: CrossPreviewResponse,
|
||||||
): void {
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): CrossPlot {
|
||||||
|
const svg = svgElement("svg", {
|
||||||
|
viewBox: `0 0 ${width} ${height}`,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
preserveAspectRatio: "none",
|
||||||
|
class: "b05-routeedit__cross-svg",
|
||||||
|
});
|
||||||
|
const layer = svgElement("g", { class: "b05-routeedit__cross-plot" });
|
||||||
|
|
||||||
const ground = preview.samples
|
const ground = preview.samples
|
||||||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
|
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
|
||||||
const design = (preview.design?.design_line ?? []).map(
|
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||||||
(point) => [point.offset_m, point.elevation_m] as [number, number],
|
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
|
||||||
|
const design = clipToToes(
|
||||||
|
(preview.design?.design_line ?? []).map(
|
||||||
|
(point) => [point.offset_m, point.elevation_m] as Point,
|
||||||
|
),
|
||||||
|
toes.right ?? -Infinity, // −offset = 우
|
||||||
|
toes.left ?? Infinity, // +offset = 좌
|
||||||
);
|
);
|
||||||
|
|
||||||
const all = [...ground, ...design];
|
const all = [...ground, ...design];
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
if (all.length < 2) {
|
||||||
if (all.length < 2) return;
|
svg.append(layer);
|
||||||
|
return { svg, layer, width, height, content: { x: 0, y: 0, width, height } };
|
||||||
|
}
|
||||||
|
|
||||||
const offsets = all.map((point) => point[0]);
|
const offsets = all.map((point) => point[0]);
|
||||||
const heights = all.map((point) => point[1]);
|
const heights = all.map((point) => point[1]);
|
||||||
@@ -64,57 +137,80 @@ export function drawCross(
|
|||||||
const maxZ = Math.max(...heights);
|
const maxZ = Math.max(...heights);
|
||||||
const spanX = maxOffset - minOffset || 1;
|
const spanX = maxOffset - minOffset || 1;
|
||||||
const spanZ = maxZ - minZ || 1;
|
const spanZ = maxZ - minZ || 1;
|
||||||
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
|
const plotWidth = Math.max(width - CROSS_PLOT_PAD.left - CROSS_PLOT_PAD.right, 1);
|
||||||
|
const plotHeight = Math.max(height - CROSS_PLOT_PAD.top - CROSS_PLOT_PAD.bottom, 1);
|
||||||
|
const scale = Math.min(plotWidth / spanX, plotHeight / spanZ);
|
||||||
const centerOffset = (minOffset + maxOffset) / 2;
|
const centerOffset = (minOffset + maxOffset) / 2;
|
||||||
const centerZ = (minZ + maxZ) / 2;
|
const centerZ = (minZ + maxZ) / 2;
|
||||||
const toScreen = (point: [number, number]): [number, number] => [
|
const toScreen = (point: Point): Point => [
|
||||||
canvas.width / 2 + (centerOffset - point[0]) * scale,
|
width / 2 + (centerOffset - point[0]) * scale,
|
||||||
canvas.height / 2 + (centerZ - point[1]) * scale,
|
height / 2 + (centerZ - point[1]) * scale,
|
||||||
];
|
];
|
||||||
|
|
||||||
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
|
const path = (points: Point[], className: string): void => {
|
||||||
if (points.length < 2) return;
|
if (points.length < 2) return;
|
||||||
context.beginPath();
|
layer.append(
|
||||||
points.forEach((point, index) => {
|
svgElement("polyline", {
|
||||||
const [x, y] = toScreen(point);
|
class: className,
|
||||||
if (index === 0) context.moveTo(x, y);
|
points: points.map((point) => toScreen(point).join(",")).join(" "),
|
||||||
else context.lineTo(x, y);
|
}),
|
||||||
});
|
);
|
||||||
context.strokeStyle = color;
|
|
||||||
context.lineWidth = width;
|
|
||||||
context.stroke();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
||||||
const [centerX] = toScreen([0, centerZ]);
|
const [centerX] = toScreen([0, centerZ]);
|
||||||
context.save();
|
layer.append(
|
||||||
context.setLineDash([4, 4]);
|
svgElement("line", {
|
||||||
context.strokeStyle = "rgba(148,163,184,0.7)";
|
class: "b05-routeedit__cross-axis",
|
||||||
context.lineWidth = 1;
|
x1: centerX,
|
||||||
context.beginPath();
|
y1: CROSS_PLOT_PAD.top / 2,
|
||||||
context.moveTo(centerX, PAD / 2);
|
x2: centerX,
|
||||||
context.lineTo(centerX, canvas.height - PAD / 2);
|
y2: height - CROSS_PLOT_PAD.bottom / 2,
|
||||||
context.stroke();
|
}),
|
||||||
context.restore();
|
|
||||||
|
|
||||||
stroke(ground, "#94a3b8", 1.6); // 원지반
|
|
||||||
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
|
|
||||||
|
|
||||||
context.font = "11px system-ui, sans-serif";
|
|
||||||
context.textBaseline = "top";
|
|
||||||
context.fillStyle = "#94a3b8";
|
|
||||||
context.textAlign = "left";
|
|
||||||
context.fillText("원지반", PAD, 4);
|
|
||||||
context.fillStyle = "#f97316";
|
|
||||||
context.textAlign = "right";
|
|
||||||
context.fillText("기본 계획 횡단", canvas.width - PAD, 4);
|
|
||||||
context.fillStyle = "#94a3b8";
|
|
||||||
context.textAlign = "center";
|
|
||||||
context.textBaseline = "bottom";
|
|
||||||
context.fillText(
|
|
||||||
`좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
|
|
||||||
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
|
|
||||||
canvas.width / 2,
|
|
||||||
canvas.height - 2,
|
|
||||||
);
|
);
|
||||||
|
path(ground, "b05-routeedit__cross-ground");
|
||||||
|
path(design, "b05-routeedit__cross-design");
|
||||||
|
|
||||||
|
// 범례·눈금 안내는 **확대해도 제자리**다 — 확대 묶음 밖에 둔다.
|
||||||
|
svg.append(layer);
|
||||||
|
svg.append(
|
||||||
|
svgText("원지반", {
|
||||||
|
class: "b05-routeedit__cross-key is-ground",
|
||||||
|
x: CROSS_PLOT_PAD.left,
|
||||||
|
y: 13,
|
||||||
|
"text-anchor": "start",
|
||||||
|
}),
|
||||||
|
svgText("기본 계획 횡단", {
|
||||||
|
class: "b05-routeedit__cross-key is-design",
|
||||||
|
x: width - CROSS_PLOT_PAD.right,
|
||||||
|
y: 13,
|
||||||
|
"text-anchor": "end",
|
||||||
|
}),
|
||||||
|
svgText(
|
||||||
|
`좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
|
||||||
|
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
|
||||||
|
{
|
||||||
|
class: "b05-routeedit__cross-key",
|
||||||
|
x: width / 2,
|
||||||
|
y: height - 6,
|
||||||
|
"text-anchor": "middle",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const screens = all.map(toScreen);
|
||||||
|
const xs = screens.map((point) => point[0]);
|
||||||
|
const ys = screens.map((point) => point[1]);
|
||||||
|
return {
|
||||||
|
svg,
|
||||||
|
layer,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
content: {
|
||||||
|
x: Math.min(...xs),
|
||||||
|
y: Math.min(...ys),
|
||||||
|
width: Math.max(...xs) - Math.min(...xs),
|
||||||
|
height: Math.max(...ys) - Math.min(...ys),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface CurveBarState {
|
|||||||
curveRadius: Array<number | null>;
|
curveRadius: Array<number | null>;
|
||||||
curveLock: CurveLock[];
|
curveLock: CurveLock[];
|
||||||
curveArc: Array<number | null>;
|
curveArc: Array<number | null>;
|
||||||
|
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */
|
||||||
|
apexLock: boolean[];
|
||||||
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||||
limitRadiusM: number;
|
limitRadiusM: number;
|
||||||
limitArcM: number;
|
limitArcM: number;
|
||||||
@@ -93,6 +95,13 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
|||||||
: "고정을 풀었습니다.",
|
: "고정을 풀었습니다.",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onApexLock: (locked) => {
|
||||||
|
const { picked, apexLock } = params.state();
|
||||||
|
if (picked < 0) return;
|
||||||
|
apexLock[picked] = locked;
|
||||||
|
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
|
||||||
|
params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다.");
|
||||||
|
},
|
||||||
onCurveOn: (on) => {
|
onCurveOn: (on) => {
|
||||||
const { picked, curveOn } = params.state();
|
const { picked, curveOn } = params.state();
|
||||||
if (picked < 0) return;
|
if (picked < 0) return;
|
||||||
@@ -111,6 +120,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
|||||||
curveOn,
|
curveOn,
|
||||||
curveRadius,
|
curveRadius,
|
||||||
curveLock,
|
curveLock,
|
||||||
|
apexLock,
|
||||||
limitRadiusM,
|
limitRadiusM,
|
||||||
limitArcM,
|
limitArcM,
|
||||||
} = params.state();
|
} = params.state();
|
||||||
@@ -146,6 +156,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
|||||||
radiusShown: shown,
|
radiusShown: shown,
|
||||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||||
lock: curveLock[picked] ?? null,
|
lock: curveLock[picked] ?? null,
|
||||||
|
apexLocked: apexLock[picked] === true,
|
||||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||||
limitRadiusM,
|
limitRadiusM,
|
||||||
limitArcM,
|
limitArcM,
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export interface CurveSummaryInput {
|
|||||||
curveOn: boolean[];
|
curveOn: boolean[];
|
||||||
curveRadius: Array<number | null>;
|
curveRadius: Array<number | null>;
|
||||||
curveLock: CurveLock[];
|
curveLock: CurveLock[];
|
||||||
|
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
|
||||||
|
apexLock: boolean[];
|
||||||
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
|
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
|
||||||
curveCount: number;
|
curveCount: number;
|
||||||
/** 법정 기준을 못 맞춘 자리 수. */
|
/** 법정 기준을 못 맞춘 자리 수. */
|
||||||
@@ -129,7 +131,8 @@ export function curveSummary(input: CurveSummaryInput): string {
|
|||||||
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
|
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
|
||||||
).length;
|
).length;
|
||||||
const forced = input.curveRadius.filter((value) => value !== null).length;
|
const forced = input.curveRadius.filter((value) => value !== null).length;
|
||||||
const locked = input.curveLock.filter((lock) => lock !== null).length;
|
const locked =
|
||||||
|
input.curveLock.filter((lock) => lock !== null).length + input.apexLock.filter(Boolean).length;
|
||||||
const edits = [
|
const edits = [
|
||||||
off ? `곡선 지움 ${off}곳` : "",
|
off ? `곡선 지움 ${off}곳` : "",
|
||||||
forced ? `R 지정 ${forced}곳` : "",
|
forced ? `R 지정 ${forced}곳` : "",
|
||||||
@@ -157,6 +160,7 @@ export interface FlattenedPlan {
|
|||||||
curveRadius: Array<number | null>;
|
curveRadius: Array<number | null>;
|
||||||
curveLock: CurveLock[];
|
curveLock: CurveLock[];
|
||||||
curveArc: Array<number | null>;
|
curveArc: Array<number | null>;
|
||||||
|
apexLock: boolean[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerNode {
|
interface ServerNode {
|
||||||
@@ -198,6 +202,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
|
|||||||
curveRadius: [],
|
curveRadius: [],
|
||||||
curveLock: [],
|
curveLock: [],
|
||||||
curveArc: [],
|
curveArc: [],
|
||||||
|
apexLock: [],
|
||||||
};
|
};
|
||||||
nodes.forEach((node, index) => {
|
nodes.forEach((node, index) => {
|
||||||
if (dropped.has(index)) return;
|
if (dropped.has(index)) return;
|
||||||
@@ -215,6 +220,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
|
|||||||
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
|
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
|
||||||
out.curveLock.push(null);
|
out.curveLock.push(null);
|
||||||
out.curveArc.push(null);
|
out.curveArc.push(null);
|
||||||
|
out.apexLock.push(false);
|
||||||
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
|
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface RouteEditSnapshot {
|
|||||||
curveLock: Array<"radius" | "arc" | null>;
|
curveLock: Array<"radius" | "arc" | null>;
|
||||||
/** 길이를 붙들었을 때의 그 길이(m). */
|
/** 길이를 붙들었을 때의 그 길이(m). */
|
||||||
curveArc: Array<number | null>;
|
curveArc: Array<number | null>;
|
||||||
|
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
|
||||||
|
apexLock: boolean[];
|
||||||
picked: number;
|
picked: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
|||||||
curveRadius: [...snapshot.curveRadius],
|
curveRadius: [...snapshot.curveRadius],
|
||||||
curveLock: [...snapshot.curveLock],
|
curveLock: [...snapshot.curveLock],
|
||||||
curveArc: [...snapshot.curveArc],
|
curveArc: [...snapshot.curveArc],
|
||||||
|
apexLock: [...snapshot.apexLock],
|
||||||
picked: snapshot.picked,
|
picked: snapshot.picked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,7 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
|||||||
if (a.curveRadius[index] !== b.curveRadius[index]) return false;
|
if (a.curveRadius[index] !== b.curveRadius[index]) return false;
|
||||||
if (a.curveLock[index] !== b.curveLock[index]) return false;
|
if (a.curveLock[index] !== b.curveLock[index]) return false;
|
||||||
if (a.curveArc[index] !== b.curveArc[index]) return false;
|
if (a.curveArc[index] !== b.curveArc[index]) return false;
|
||||||
|
if (a.apexLock[index] !== b.apexLock[index]) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface CurveLabelState {
|
|||||||
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||||||
arcLengthShown: number | null;
|
arcLengthShown: number | null;
|
||||||
lock: CurveLock;
|
lock: CurveLock;
|
||||||
|
/** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */
|
||||||
|
apexLocked: boolean;
|
||||||
innerAngleDeg: number | null;
|
innerAngleDeg: number | null;
|
||||||
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||||
limitRadiusM?: number;
|
limitRadiusM?: number;
|
||||||
@@ -84,6 +86,8 @@ export interface CurveLabelHandlers {
|
|||||||
onCurveOn: (on: boolean) => void;
|
onCurveOn: (on: boolean) => void;
|
||||||
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
|
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
|
||||||
onLock: (lock: CurveLock) => void;
|
onLock: (lock: CurveLock) => void;
|
||||||
|
/** 교각점 자리를 못 박거나 푼다. */
|
||||||
|
onApexLock: (locked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurveLabel {
|
export interface CurveLabel {
|
||||||
@@ -131,6 +135,10 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
|||||||
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
|
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
|
||||||
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||||
</label>
|
</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>`;
|
<span class="b05-routeedit__curve-info"></span>`;
|
||||||
document.body.append(root);
|
document.body.append(root);
|
||||||
|
|
||||||
@@ -141,6 +149,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
|||||||
const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
|
const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
|
||||||
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
|
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
|
||||||
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
|
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")!;
|
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||||||
|
|
||||||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||||
@@ -150,6 +159,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
|||||||
|
|
||||||
let curveOn = true;
|
let curveOn = true;
|
||||||
let lock: CurveLock = null;
|
let lock: CurveLock = null;
|
||||||
|
let apexLocked = false;
|
||||||
let seat = -1;
|
let seat = -1;
|
||||||
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
|
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
|
||||||
let manual: [number, number] | null = null;
|
let manual: [number, number] | null = null;
|
||||||
@@ -179,6 +189,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
|||||||
.addEventListener("click", () => handlers.onClose());
|
.addEventListener("click", () => handlers.onClose());
|
||||||
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
|
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
|
||||||
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
|
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;
|
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
||||||
@@ -260,6 +272,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
|||||||
}
|
}
|
||||||
curveOn = state.curveOn;
|
curveOn = state.curveOn;
|
||||||
lock = state.lock;
|
lock = state.lock;
|
||||||
|
apexLocked = state.apexLocked;
|
||||||
limit = state.bounds;
|
limit = state.bounds;
|
||||||
limitRadius = state.limitRadiusM ?? 0;
|
limitRadius = state.limitRadiusM ?? 0;
|
||||||
limitArc = state.limitArcM ?? 0;
|
limitArc = state.limitArcM ?? 0;
|
||||||
@@ -275,6 +288,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
|||||||
lockArc.disabled = !state.curveOn;
|
lockArc.disabled = !state.curveOn;
|
||||||
lockRadius.classList.toggle("is-on", lock === "radius");
|
lockRadius.classList.toggle("is-on", lock === "radius");
|
||||||
lockArc.classList.toggle("is-on", lock === "arc");
|
lockArc.classList.toggle("is-on", lock === "arc");
|
||||||
|
lockApex.classList.toggle("is-on", apexLocked);
|
||||||
radius.value =
|
radius.value =
|
||||||
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
|
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
|
||||||
arc.value =
|
arc.value =
|
||||||
|
|||||||
@@ -160,20 +160,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
|||||||
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
|
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
|
||||||
context.lineWidth = 2.6;
|
context.lineWidth = 2.6;
|
||||||
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
|
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
|
||||||
drawPickedContourLabel(context, scene, view);
|
|
||||||
}
|
}
|
||||||
// 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③).
|
// 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
|
||||||
context.font = "10px system-ui, sans-serif";
|
|
||||||
context.textAlign = "center";
|
|
||||||
context.textBaseline = "middle";
|
|
||||||
drawPreparedLabels(
|
|
||||||
context,
|
|
||||||
scene.contours.layer,
|
|
||||||
view,
|
|
||||||
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
|
|
||||||
everyM,
|
|
||||||
scene.uprightRad,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
@@ -254,9 +242,44 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
|||||||
|
|
||||||
drawStationMarks(context, scene, line);
|
drawStationMarks(context, scene, line);
|
||||||
drawMeasureMarks(context, scene, line);
|
drawMeasureMarks(context, scene, line);
|
||||||
|
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
|
||||||
|
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
|
||||||
|
drawContourLabels(context, scene, band, style);
|
||||||
context.restore(); // 회전 끝
|
context.restore(); // 회전 끝
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 등고 높이값 라벨 — **그림 맨 위**에 얹는다(2026-09-12 사용자 지시 ⑦).
|
||||||
|
*
|
||||||
|
* 고른 가닥의 큰 이름표도 여기서 낸다. 줄과 라벨은 **같은 눈금**(`contourStepM`)으로 솎아
|
||||||
|
* 그린 줄에만 숫자가 붙는다 — 촘촘함은 부르는 쪽이 그 눈금으로 정한다. */
|
||||||
|
function drawContourLabels(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
scene: RouteEditScene,
|
||||||
|
band: { x: number; y: number; width: number; height: number } | null,
|
||||||
|
style: CSSStyleDeclaration,
|
||||||
|
): void {
|
||||||
|
if (!scene.contours) return;
|
||||||
|
context.save();
|
||||||
|
if (band) {
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(band.x, band.y, band.width, band.height);
|
||||||
|
context.clip();
|
||||||
|
}
|
||||||
|
context.font = "10px system-ui, sans-serif";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
drawPreparedLabels(
|
||||||
|
context,
|
||||||
|
scene.contours.layer,
|
||||||
|
scene.view,
|
||||||
|
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
|
||||||
|
scene.contourStepM,
|
||||||
|
scene.uprightRad,
|
||||||
|
);
|
||||||
|
if (scene.pickedContour >= 0) drawPickedContourLabel(context, scene, scene.view);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
|
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
|
||||||
function drawMeasureMarks(
|
function drawMeasureMarks(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
|
|||||||
@@ -190,15 +190,58 @@
|
|||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b05-routeedit__cross-canvas {
|
/* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③).
|
||||||
|
휠 = 확대·축소, 가운데 버튼 끌기 = 이동, 더블클릭 = 원복(공용 조각이 맡음). */
|
||||||
|
.b05-routeedit__cross-box {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 150px;
|
||||||
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-8, 6px);
|
border-radius: var(--radius-8, 6px);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.b05-routeedit__cross-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-routeedit__cross-svg.is-panning {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-routeedit__cross-axis {
|
||||||
|
stroke: rgb(148 163 184 / 70%);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 4 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-routeedit__cross-ground {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-text-secondary, #94a3b8);
|
||||||
|
stroke-width: 1.6;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-routeedit__cross-design {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--map-route, #f97316);
|
||||||
|
stroke-width: 2.2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-routeedit__cross-key {
|
||||||
|
fill: var(--color-text-secondary, #94a3b8);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-routeedit__cross-key.is-design {
|
||||||
|
fill: var(--map-route, #f97316);
|
||||||
|
}
|
||||||
|
|
||||||
.b05-routeedit__cross-foot {
|
.b05-routeedit__cross-foot {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -381,3 +424,11 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 「거리 재기」가 켜진 동안 — 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ⑥).
|
||||||
|
`is-active` 는 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
|
||||||
|
.b05-routeedit__actions .ui-btn.is-active {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--color-primary, #7c3aed);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,38 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
|
|||||||
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
|
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 절·성토 사면이 원지반과 **실제로 만나는** 좌·우 offset(m). 그 측에서 못 만나면 null.
|
||||||
|
*
|
||||||
|
* `toeFitHalfWidth` 와 같은 교차점을 쓰되 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) —
|
||||||
|
* 반폭 하나로 자르면 한쪽만 성토인 측점에서 반대쪽 원지반이 필요보다 넓게 남는다.
|
||||||
|
* **외삽으로 짚은 자리는 안 쓴다**(null) — 지반을 모르는 자리라 거기까지 그으면 없는 지반을
|
||||||
|
* 그리는 셈이 된다.
|
||||||
|
*/
|
||||||
|
export function toeOffsets(section: CrossSection): { left: number | null; right: number | null } {
|
||||||
|
const out: { left: number | null; right: number | null } = { left: null, right: null };
|
||||||
|
const design = section.design;
|
||||||
|
if (!design) return out;
|
||||||
|
const groundAt = groundInterpolator(section.samples);
|
||||||
|
const designAt = designInterpolator(design.design_line);
|
||||||
|
const edges = design.road_edges;
|
||||||
|
if (!groundAt || !designAt || !edges) return out;
|
||||||
|
const lineOffsets = design.design_line.map((point) => point.offset_m);
|
||||||
|
if (lineOffsets.length < 2) return out;
|
||||||
|
const { protectMax, protectMin } = protectedSpan(design, edges);
|
||||||
|
for (const side of ["left", "right"] as const) {
|
||||||
|
const outward = side === "left" ? 1 : -1;
|
||||||
|
const start = side === "left" ? protectMax : protectMin;
|
||||||
|
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
|
||||||
|
const meet = meetOffset(designAt, groundAt, start, limit, outward);
|
||||||
|
// 설계선 끝 밖으로 나간 값은 외삽이다 — 만난 것으로 치지 않는다.
|
||||||
|
if (Math.abs(meet) > Math.abs(limit) + 1e-9) continue;
|
||||||
|
if (Math.abs(designAt(meet) - groundAt(meet)) > MEET_TOLERANCE_M) continue;
|
||||||
|
out[side] = meet;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B06_Section_UI_Cross_View_Zoom.ts
|
* B06_Section_UI_Cross_View_Zoom.ts
|
||||||
* 횡단 카드 그래프의 **확대·축소·팬** 조작구. 카드 렌더러(`_UI_Cross_View.ts`)에서
|
* 횡단 카드 그래프의 **확대·축소·팬** 조작구.
|
||||||
* 700줄 제한으로 분리했다. 동작 규칙(휠은 줌이 아님)은 아래 주석 그대로다.
|
*
|
||||||
|
* ⚠ 줌·팬 **본문은 공용 조각**(`A00_Common/b_svg_zoom_pan.ts`)으로 옮겼다
|
||||||
|
* (2026-09-12 사용자 지시 「공용화해줘」) — 계획노선 편집 횡단이 같은 코드를 쓴다.
|
||||||
|
* 여기에는 **B06 것**만 남는다: 카드 여백(`CROSS_PAD`)을 씌운 감싸개, 측점별 배율 기억,
|
||||||
|
* 우측 상단 버튼 묶음. 동작 규칙(휠은 줌이 아님)은 그대로다 — 카드가 수십 장 깔리는
|
||||||
|
* 화면에서 휠을 줌에 묶으면 목록을 훑을 수가 없다(2026-08-02 사용자 확정).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachSvgZoomPan,
|
||||||
|
type ContentBounds,
|
||||||
|
type ZoomPanHandle,
|
||||||
|
type ZoomPanState,
|
||||||
|
} from "../A00_Common/b_svg_zoom_pan";
|
||||||
import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common";
|
import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common";
|
||||||
|
|
||||||
/**
|
export type { ContentBounds, ZoomPanHandle, ZoomPanState };
|
||||||
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
|
|
||||||
*
|
|
||||||
* **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에
|
|
||||||
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
|
|
||||||
*/
|
|
||||||
export interface ZoomPanHandle {
|
|
||||||
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
|
|
||||||
zoom: (factor: number) => void;
|
|
||||||
reset: () => void;
|
|
||||||
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
|
|
||||||
scale: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
|
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
|
||||||
@@ -34,28 +33,10 @@ export interface CrossWidthActions {
|
|||||||
fit: () => void;
|
fit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
|
||||||
export interface ZoomPanState {
|
|
||||||
scale: number;
|
|
||||||
tx: number;
|
|
||||||
ty: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 반폭 밖에 있어 잘려 있는
|
|
||||||
* **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다
|
|
||||||
* (2026-08-23 사용자 지시).
|
|
||||||
*/
|
|
||||||
export interface ContentBounds {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
|
/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
|
||||||
export const cardZoomStates = new Map<string, ZoomPanState>();
|
export const cardZoomStates = new Map<string, ZoomPanState>();
|
||||||
|
|
||||||
|
/** 카드 SVG 에 줌·팬을 붙인다 — 여백만 씌워 공용 조각에 넘긴다. 부르는 꼴은 그대로다. */
|
||||||
export function attachZoomPan(
|
export function attachZoomPan(
|
||||||
svg: SVGSVGElement,
|
svg: SVGSVGElement,
|
||||||
plotLayer: SVGGElement,
|
plotLayer: SVGGElement,
|
||||||
@@ -68,132 +49,18 @@ export function attachZoomPan(
|
|||||||
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||||
content?: ContentBounds,
|
content?: ContentBounds,
|
||||||
): ZoomPanHandle {
|
): ZoomPanHandle {
|
||||||
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
|
return attachSvgZoomPan({
|
||||||
const plot = {
|
svg,
|
||||||
x: CROSS_PAD.left,
|
layer: plotLayer,
|
||||||
y: CROSS_PAD.top,
|
widthPx,
|
||||||
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
|
heightPx,
|
||||||
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
|
pad: CROSS_PAD,
|
||||||
};
|
initial,
|
||||||
const maxScale = 8;
|
onChange,
|
||||||
let scale = initial?.scale ?? 1;
|
content,
|
||||||
let tx = initial?.tx ?? 0;
|
// 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
|
||||||
let ty = initial?.ty ?? 0;
|
wheelZoom: false,
|
||||||
const applyTransform = (): void => {
|
|
||||||
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
|
||||||
onChange?.({ scale, tx, ty });
|
|
||||||
};
|
|
||||||
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
|
|
||||||
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 반폭 밖 캐시 보유분 포함)까지 늘어나,
|
|
||||||
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
|
|
||||||
const clampAxis = (
|
|
||||||
value: number,
|
|
||||||
start: number,
|
|
||||||
size: number,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
): number =>
|
|
||||||
Math.min(
|
|
||||||
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
|
|
||||||
Math.max(start + size - scale * to, start - scale * from),
|
|
||||||
);
|
|
||||||
const clampPan = (): void => {
|
|
||||||
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
|
|
||||||
// 범위까지 열어 둔다 — 캐시 보유분이 표시 반폭보다 넓으면 1배에서도 끌어다 봐야 한다
|
|
||||||
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
|
|
||||||
// 캐시가 표시 폭과 같으면 범위가 플롯과 같아져 종전처럼 이동량 0으로 묶인다.
|
|
||||||
const bounds = content
|
|
||||||
? {
|
|
||||||
x: Math.min(content.x, plot.x),
|
|
||||||
y: Math.min(content.y, plot.y),
|
|
||||||
right: Math.max(content.x + content.width, plot.x + plot.width),
|
|
||||||
bottom: Math.max(content.y + content.height, plot.y + plot.height),
|
|
||||||
}
|
|
||||||
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
|
|
||||||
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
|
|
||||||
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
|
|
||||||
const zoom = (factor: number): void => {
|
|
||||||
const next = Math.min(maxScale, Math.max(1, scale * factor));
|
|
||||||
const cx = plot.x + plot.width / 2;
|
|
||||||
const cy = plot.y + plot.height / 2;
|
|
||||||
tx = cx - ((cx - tx) / scale) * next;
|
|
||||||
ty = cy - ((cy - ty) / scale) * next;
|
|
||||||
scale = next;
|
|
||||||
clampPan();
|
|
||||||
applyTransform();
|
|
||||||
};
|
|
||||||
|
|
||||||
let panning = false;
|
|
||||||
let moved = false;
|
|
||||||
let lastX = 0;
|
|
||||||
let lastY = 0;
|
|
||||||
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
|
|
||||||
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
|
|
||||||
svg.addEventListener("mousedown", (event) => {
|
|
||||||
if (event.button === 1) event.preventDefault();
|
|
||||||
});
|
});
|
||||||
svg.addEventListener("auxclick", (event) => {
|
|
||||||
if (event.button === 1) event.preventDefault();
|
|
||||||
});
|
|
||||||
svg.addEventListener("pointerdown", (event) => {
|
|
||||||
// 팬은 **가운데 버튼**만. 좌클릭은 측점 선택·면적 강조 몫이다.
|
|
||||||
if (event.button !== 1) return;
|
|
||||||
event.preventDefault();
|
|
||||||
panning = true;
|
|
||||||
moved = false;
|
|
||||||
lastX = event.clientX;
|
|
||||||
lastY = event.clientY;
|
|
||||||
svg.classList.add("is-panning");
|
|
||||||
svg.setPointerCapture(event.pointerId);
|
|
||||||
});
|
|
||||||
svg.addEventListener("pointermove", (event) => {
|
|
||||||
if (!panning) return;
|
|
||||||
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
|
|
||||||
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
|
|
||||||
const rect = svg.getBoundingClientRect();
|
|
||||||
tx += ((event.clientX - lastX) / rect.width) * widthPx;
|
|
||||||
ty += ((event.clientY - lastY) / rect.height) * heightPx;
|
|
||||||
lastX = event.clientX;
|
|
||||||
lastY = event.clientY;
|
|
||||||
clampPan();
|
|
||||||
applyTransform();
|
|
||||||
});
|
|
||||||
const endPan = (event: PointerEvent): void => {
|
|
||||||
if (!panning) return;
|
|
||||||
panning = false;
|
|
||||||
svg.classList.remove("is-panning");
|
|
||||||
try {
|
|
||||||
svg.releasePointerCapture(event.pointerId);
|
|
||||||
} catch {
|
|
||||||
/* 이미 해제됨 */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
|
||||||
clampPan();
|
|
||||||
applyTransform();
|
|
||||||
}
|
|
||||||
svg.addEventListener("pointerup", endPan);
|
|
||||||
svg.addEventListener("pointercancel", endPan);
|
|
||||||
// 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다.
|
|
||||||
svg.addEventListener("click", (event) => {
|
|
||||||
if (moved) event.stopPropagation();
|
|
||||||
});
|
|
||||||
const currentScale = (): number => scale;
|
|
||||||
const reset = (): void => {
|
|
||||||
scale = 1;
|
|
||||||
tx = 0;
|
|
||||||
ty = 0;
|
|
||||||
applyTransform();
|
|
||||||
};
|
|
||||||
// 더블클릭 원복.
|
|
||||||
svg.addEventListener("dblclick", (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
reset();
|
|
||||||
});
|
|
||||||
return { zoom, reset, scale: currentScale };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user