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;
|
||||
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
|
||||
const STATION_HIT_PX = 11;
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
* (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로
|
||||
* 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */
|
||||
const CONTOUR_LEVEL_BUDGET = 1200;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -118,6 +122,9 @@ export async function openRouteEditModal(
|
||||
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
||||
let curveLock: CurveLock[] = [];
|
||||
let curveArc: Array<number | null> = [];
|
||||
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도
|
||||
* 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */
|
||||
let apexLock: boolean[] = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
@@ -258,7 +265,7 @@ export async function openRouteEditModal(
|
||||
|
||||
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
||||
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 {
|
||||
if (closed) return;
|
||||
@@ -291,7 +298,10 @@ export async function openRouteEditModal(
|
||||
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
||||
handleAtScreen(
|
||||
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 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,
|
||||
px,
|
||||
py,
|
||||
@@ -354,6 +364,7 @@ export async function openRouteEditModal(
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
apexLock,
|
||||
curveCount: curveInfo.length,
|
||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||
minRadiusM,
|
||||
@@ -372,6 +383,7 @@ export async function openRouteEditModal(
|
||||
curveRadius,
|
||||
curveLock,
|
||||
curveArc,
|
||||
apexLock,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
}),
|
||||
@@ -398,7 +410,7 @@ export async function openRouteEditModal(
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
function snapshotNow(): RouteEditSnapshot {
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, picked };
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
@@ -410,6 +422,7 @@ export async function openRouteEditModal(
|
||||
curveRadius = snapshot.curveRadius;
|
||||
curveLock = snapshot.curveLock;
|
||||
curveArc = snapshot.curveArc;
|
||||
apexLock = snapshot.apexLock;
|
||||
picked = snapshot.picked;
|
||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||
},
|
||||
@@ -436,11 +449,13 @@ export async function openRouteEditModal(
|
||||
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
||||
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
||||
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
|
||||
dragNode = nodeAt(px, py);
|
||||
dragHandle = dragNode >= 0 ? null : handleAt(px, py);
|
||||
const hitNode = nodeAt(px, py);
|
||||
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
|
||||
dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1;
|
||||
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
|
||||
dragMoved = false;
|
||||
if (dragNode >= 0) {
|
||||
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
||||
if (hitNode >= 0) {
|
||||
picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
||||
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
|
||||
syncCurveBar();
|
||||
draw();
|
||||
@@ -538,7 +553,16 @@ export async function openRouteEditModal(
|
||||
draw();
|
||||
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 => {
|
||||
@@ -570,6 +594,7 @@ export async function openRouteEditModal(
|
||||
curveRadius.splice(segment + 1, 0, null);
|
||||
curveLock.splice(segment + 1, 0, null);
|
||||
curveArc.splice(segment + 1, 0, null);
|
||||
apexLock.splice(segment + 1, 0, false);
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
@@ -588,6 +613,7 @@ export async function openRouteEditModal(
|
||||
curveRadius.splice(index, 1);
|
||||
curveLock.splice(index, 1);
|
||||
curveArc.splice(index, 1);
|
||||
apexLock.splice(index, 1);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
});
|
||||
@@ -638,6 +664,7 @@ export async function openRouteEditModal(
|
||||
curveRadius = flat.curveRadius;
|
||||
curveLock = flat.curveLock;
|
||||
curveArc = flat.curveArc;
|
||||
apexLock = flat.apexLock;
|
||||
picked = -1;
|
||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
|
||||
@@ -16,8 +16,13 @@
|
||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
|
||||
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";
|
||||
|
||||
export interface CrossPreviewParams {
|
||||
@@ -33,7 +38,7 @@ export interface CrossPreviewParams {
|
||||
}
|
||||
|
||||
export interface CrossPreviewWindow {
|
||||
/** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */
|
||||
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
|
||||
refresh: () => Promise<void>;
|
||||
@@ -55,16 +60,15 @@ function createPane(title: string, empty: string): CrossPane {
|
||||
<strong class="b05-routeedit__cross-title">${title}</strong>
|
||||
<span class="b05-routeedit__cross-station"></span>
|
||||
</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>`;
|
||||
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
|
||||
return {
|
||||
root,
|
||||
show(preview, intervalM) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
box.replaceChildren();
|
||||
if (!preview) {
|
||||
station.textContent = "";
|
||||
foot.textContent = empty;
|
||||
@@ -73,11 +77,30 @@ function createPane(title: string, empty: string): CrossPane {
|
||||
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
||||
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
||||
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);
|
||||
},
|
||||
wait(text) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
box.replaceChildren();
|
||||
foot.textContent = text;
|
||||
},
|
||||
};
|
||||
@@ -117,11 +140,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
|
||||
return {
|
||||
async open(chainageM) {
|
||||
// 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다.
|
||||
const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6;
|
||||
if (!sameStation) previous.show(null, params.request().station_interval_m);
|
||||
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
|
||||
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
|
||||
previous.show(null, params.request().station_interval_m);
|
||||
watching = chainageM;
|
||||
await load(chainageM, sameStation);
|
||||
await load(chainageM, false);
|
||||
},
|
||||
async refresh() {
|
||||
if (watching === null) return;
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
|
||||
* 횡단 한 장을 캔버스에 그린다 — **원지반선·기본 계획 횡단선**과 아래 한 줄 요약.
|
||||
* 횡단 한 장을 **SVG 선**으로 짓는다 — 원지반선·기본 계획 횡단선과 아래 한 줄 요약.
|
||||
*
|
||||
* `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정).
|
||||
* 값은 서버가 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 { 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";
|
||||
|
||||
/** 그림 가장자리 여백(px). */
|
||||
const PAD = 24;
|
||||
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
|
||||
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 {
|
||||
@@ -19,10 +41,7 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
|
||||
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
||||
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
||||
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
||||
const lengths = fillSlopeLengths({
|
||||
samples: preview.samples,
|
||||
design,
|
||||
} as unknown as CrossSection);
|
||||
const lengths = fillSlopeLengths(asSection(preview));
|
||||
const sides = (["left", "right"] as const)
|
||||
.filter((side) => lengths[side] !== null)
|
||||
.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)}㎡`;
|
||||
}
|
||||
|
||||
/** 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 규약과 같은 방향).
|
||||
*
|
||||
* **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
||||
* 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
||||
*/
|
||||
export function drawCross(
|
||||
context: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
export function buildCrossSvg(
|
||||
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
|
||||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
|
||||
const design = (preview.design?.design_line ?? []).map(
|
||||
(point) => [point.offset_m, point.elevation_m] as [number, number],
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
|
||||
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||||
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
|
||||
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];
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (all.length < 2) return;
|
||||
if (all.length < 2) {
|
||||
svg.append(layer);
|
||||
return { svg, layer, width, height, content: { x: 0, y: 0, width, height } };
|
||||
}
|
||||
|
||||
const offsets = all.map((point) => point[0]);
|
||||
const heights = all.map((point) => point[1]);
|
||||
@@ -64,57 +137,80 @@ export function drawCross(
|
||||
const maxZ = Math.max(...heights);
|
||||
const spanX = maxOffset - minOffset || 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 centerZ = (minZ + maxZ) / 2;
|
||||
const toScreen = (point: [number, number]): [number, number] => [
|
||||
canvas.width / 2 + (centerOffset - point[0]) * scale,
|
||||
canvas.height / 2 + (centerZ - point[1]) * scale,
|
||||
const toScreen = (point: Point): Point => [
|
||||
width / 2 + (centerOffset - point[0]) * 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;
|
||||
context.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
const [x, y] = toScreen(point);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = width;
|
||||
context.stroke();
|
||||
layer.append(
|
||||
svgElement("polyline", {
|
||||
class: className,
|
||||
points: points.map((point) => toScreen(point).join(",")).join(" "),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
||||
const [centerX] = toScreen([0, centerZ]);
|
||||
context.save();
|
||||
context.setLineDash([4, 4]);
|
||||
context.strokeStyle = "rgba(148,163,184,0.7)";
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(centerX, PAD / 2);
|
||||
context.lineTo(centerX, canvas.height - PAD / 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,
|
||||
layer.append(
|
||||
svgElement("line", {
|
||||
class: "b05-routeedit__cross-axis",
|
||||
x1: centerX,
|
||||
y1: CROSS_PLOT_PAD.top / 2,
|
||||
x2: centerX,
|
||||
y2: height - CROSS_PLOT_PAD.bottom / 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>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM: 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) => {
|
||||
const { picked, curveOn } = params.state();
|
||||
if (picked < 0) return;
|
||||
@@ -111,6 +120,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
apexLock,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
} = params.state();
|
||||
@@ -146,6 +156,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
lock: curveLock[picked] ?? null,
|
||||
apexLocked: apexLock[picked] === true,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface CurveSummaryInput {
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
|
||||
curveCount: number;
|
||||
/** 법정 기준을 못 맞춘 자리 수. */
|
||||
@@ -129,7 +131,8 @@ export function curveSummary(input: CurveSummaryInput): string {
|
||||
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
|
||||
).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 = [
|
||||
off ? `곡선 지움 ${off}곳` : "",
|
||||
forced ? `R 지정 ${forced}곳` : "",
|
||||
@@ -157,6 +160,7 @@ export interface FlattenedPlan {
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
apexLock: boolean[];
|
||||
}
|
||||
|
||||
interface ServerNode {
|
||||
@@ -198,6 +202,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
|
||||
curveRadius: [],
|
||||
curveLock: [],
|
||||
curveArc: [],
|
||||
apexLock: [],
|
||||
};
|
||||
nodes.forEach((node, index) => {
|
||||
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.curveLock.push(null);
|
||||
out.curveArc.push(null);
|
||||
out.apexLock.push(false);
|
||||
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface RouteEditSnapshot {
|
||||
curveLock: Array<"radius" | "arc" | null>;
|
||||
/** 길이를 붙들었을 때의 그 길이(m). */
|
||||
curveArc: Array<number | null>;
|
||||
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
picked: number;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
||||
curveRadius: [...snapshot.curveRadius],
|
||||
curveLock: [...snapshot.curveLock],
|
||||
curveArc: [...snapshot.curveArc],
|
||||
apexLock: [...snapshot.apexLock],
|
||||
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.curveLock[index] !== b.curveLock[index]) return false;
|
||||
if (a.curveArc[index] !== b.curveArc[index]) return false;
|
||||
if (a.apexLock[index] !== b.apexLock[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface CurveLabelState {
|
||||
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||||
arcLengthShown: number | null;
|
||||
lock: CurveLock;
|
||||
/** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */
|
||||
apexLocked: boolean;
|
||||
innerAngleDeg: number | null;
|
||||
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM?: number;
|
||||
@@ -84,6 +86,8 @@ export interface CurveLabelHandlers {
|
||||
onCurveOn: (on: boolean) => void;
|
||||
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
|
||||
onLock: (lock: CurveLock) => void;
|
||||
/** 교각점 자리를 못 박거나 푼다. */
|
||||
onApexLock: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
export interface CurveLabel {
|
||||
@@ -131,6 +135,10 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
<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);
|
||||
|
||||
@@ -141,6 +149,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
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")!;
|
||||
|
||||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
@@ -150,6 +159,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
|
||||
let curveOn = true;
|
||||
let lock: CurveLock = null;
|
||||
let apexLocked = false;
|
||||
let seat = -1;
|
||||
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
|
||||
let manual: [number, number] | null = null;
|
||||
@@ -179,6 +189,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
.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;
|
||||
@@ -260,6 +272,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
}
|
||||
curveOn = state.curveOn;
|
||||
lock = state.lock;
|
||||
apexLocked = state.apexLocked;
|
||||
limit = state.bounds;
|
||||
limitRadius = state.limitRadiusM ?? 0;
|
||||
limitArc = state.limitArcM ?? 0;
|
||||
@@ -275,6 +288,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
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 =
|
||||
|
||||
@@ -160,20 +160,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
|
||||
context.lineWidth = 2.6;
|
||||
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();
|
||||
|
||||
@@ -254,9 +242,44 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
|
||||
drawStationMarks(context, scene, line);
|
||||
drawMeasureMarks(context, scene, line);
|
||||
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
|
||||
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
|
||||
drawContourLabels(context, scene, band, style);
|
||||
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 ⑤). */
|
||||
function drawMeasureMarks(
|
||||
context: CanvasRenderingContext2D,
|
||||
|
||||
@@ -190,15 +190,58 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-canvas {
|
||||
/* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③).
|
||||
휠 = 확대·축소, 가운데 버튼 끌기 = 이동, 더블클릭 = 원복(공용 조각이 맡음). */
|
||||
.b05-routeedit__cross-box {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-height: 150px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
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 {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -381,3 +424,11 @@
|
||||
color: var(--color-text-secondary);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 절·성토 사면이 원지반과 **실제로 만나는** 좌·우 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 사용자 지시).
|
||||
*
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
/* =============================================================================
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
|
||||
*
|
||||
* **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에
|
||||
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
|
||||
*/
|
||||
export interface ZoomPanHandle {
|
||||
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
|
||||
zoom: (factor: number) => void;
|
||||
reset: () => void;
|
||||
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
|
||||
scale: () => number;
|
||||
}
|
||||
export type { ContentBounds, ZoomPanHandle, ZoomPanState };
|
||||
|
||||
/**
|
||||
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
|
||||
@@ -34,28 +33,10 @@ export interface CrossWidthActions {
|
||||
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 사용자 ③). */
|
||||
export const cardZoomStates = new Map<string, ZoomPanState>();
|
||||
|
||||
/** 카드 SVG 에 줌·팬을 붙인다 — 여백만 씌워 공용 조각에 넘긴다. 부르는 꼴은 그대로다. */
|
||||
export function attachZoomPan(
|
||||
svg: SVGSVGElement,
|
||||
plotLayer: SVGGElement,
|
||||
@@ -68,132 +49,18 @@ export function attachZoomPan(
|
||||
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
content?: ContentBounds,
|
||||
): ZoomPanHandle {
|
||||
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
|
||||
const plot = {
|
||||
x: CROSS_PAD.left,
|
||||
y: CROSS_PAD.top,
|
||||
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
|
||||
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
|
||||
};
|
||||
const maxScale = 8;
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
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();
|
||||
return attachSvgZoomPan({
|
||||
svg,
|
||||
layer: plotLayer,
|
||||
widthPx,
|
||||
heightPx,
|
||||
pad: CROSS_PAD,
|
||||
initial,
|
||||
onChange,
|
||||
content,
|
||||
// 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
|
||||
wheelZoom: false,
|
||||
});
|
||||
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