feat(B06): 횡단 카드 확대 대상을 플롯 도형으로 한정 + 표시 반폭 기준값 정정

2026-08-08 사용자 지시 2건.

1) 확대·축소가 축까지 키우던 문제
   attachZoomPan이 SVG viewBox를 조작해 축선·격자·눈금 숫자·축 이름까지 함께
   확대됐다. 확대할수록 도면이 아니라 글자가 커지는 셈이라 도면을 못 읽는다.
   viewBox 조작을 걷어내고 도형 레이어(plotLayer)의 transform
   (translate + scale)만 바꾸도록 교체했다. 배율 1~8배는 그대로 두고, 확대분이
   플롯 영역을 항상 덮도록 이동량을 clamp한다(원배율에서는 이동량 0).
   팬은 도형을 직접 미는 방식이라 부호가 반대가 됐다 — 커서를 따라간다.

   플롯 clip 그룹은 design 유무와 무관하게 항상 만들고 그 안쪽에 plotLayer를
   둔다. clip을 transform 붙은 요소에 직접 걸면 자르는 창까지 같이 확대돼
   도형이 플롯 밖으로 새어 나간다.

   움직이는 것: 지반선·면적 밴드·포장·설계선·암 경계·중심 십자선.
   고정: 축선·격자·눈금 숫자·축 이름.

2) 사이드 패널 "횡단 반폭" 기준값
   진입 시 확정 이력(data.options.cross_half_width_m)이 없으면 보유 샘플의
   최대 offset을 계산해 입력칸을 덮었다. 그래서 옛 기본값(15m)으로 생성된
   노선이 15.0으로 떠 기준값 구실을 못 했다. 계산 폴백을 제거해, 확정 이력이
   있을 때만 그 값을 쓰고 없으면 백엔드 기본값
   (config_system.SECTION_CROSS_HALF_WIDTH_M = 20m)을 그대로 둔다.
   보유 샘플 폭을 넘는 값은 [전체 측점 반영]이 재생성을 태운다(기존 분기 유지).

검증: npm run typecheck 통과, prettier 무변경. docs/raw/PLAN.md에 체크리스트 기록.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 08:26:00 +09:00
co-authored by Claude Fable 5
parent 2121a5eef6
commit 2abca64deb
2 changed files with 96 additions and 73 deletions
@@ -135,9 +135,13 @@ export function crossCardNaturalHeight(
}
/**
* 카드 SVG에 마우스 휠 줌(커서 중심) + 드래그 팬 + 더블클릭 원복을 붙인다(E-5).
* viewBox만 조작하고 선은 `vector-effect: non-scaling-stroke`(CSS)로 굵기를 유지해
* 확대해도 선·글자가 선명하다. 최대 8배까지, 축소는 원본까지만 허용한다.
* 카드 SVG에 확대·축소 + 드래그 팬 + 더블클릭 원복을 붙인다(E-5).
*
* 확대·축소 대상은 **플롯 안 도형(지반선·설계선·면적 밴드·중심 십자선)뿐**이다. 축·눈금·축
* 이름은 제자리에 남고, 데이터 레이어(`plotLayer`)의 transform만 바뀐다(2026-08-08 사용자
* 지시). 예전에는 viewBox를 조작해 축과 글자까지 같이 커졌다.
* 선 굵기는 `vector-effect: non-scaling-stroke`(CSS)라 배율과 무관하게 유지된다.
* 최대 8배까지, 축소는 원배율까지만 허용한다.
*/
/**
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
@@ -146,32 +150,46 @@ export function crossCardNaturalHeight(
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
*/
interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 보이는 화면의 중앙을 붙잡는다. */
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
}
function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): ZoomPanHandle {
const base = { x: 0, y: 0, w: widthPx, h: heightPx };
const vb = { ...base };
const applyVB = (): void => svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
function attachZoomPan(
svg: SVGSVGElement,
plotLayer: SVGGElement,
widthPx: number,
heightPx: number,
): 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 = 1;
let tx = 0;
let ty = 0;
const applyTransform = (): void =>
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
const clampPan = (): void => {
vb.x = Math.min(Math.max(vb.x, base.x), base.x + base.w - vb.w);
vb.y = Math.min(Math.max(vb.y, base.y), base.y + base.h - vb.h);
tx = Math.min(Math.max(tx, (plot.x + plot.width) * (1 - scale)), plot.x * (1 - scale));
ty = Math.min(Math.max(ty, (plot.y + plot.height) * (1 - scale)), plot.y * (1 - scale));
};
// 보이는 화면의 **중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
// 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
const zoom = (factor: number): void => {
const mx = vb.x + vb.w / 2;
const my = vb.y + vb.h / 2;
const nw = Math.min(base.w, Math.max(base.w / 8, vb.w / factor));
const nh = Math.min(base.h, Math.max(base.h / 8, vb.h / factor));
vb.x = mx - (mx - vb.x) * (nw / vb.w);
vb.y = my - (my - vb.y) * (nh / vb.h);
vb.w = nw;
vb.h = nh;
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();
applyVB();
applyTransform();
};
let panning = false;
@@ -200,13 +218,14 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): Z
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();
vb.x -= ((event.clientX - lastX) / rect.width) * vb.w;
vb.y -= ((event.clientY - lastY) / rect.height) * vb.h;
tx += ((event.clientX - lastX) / rect.width) * widthPx;
ty += ((event.clientY - lastY) / rect.height) * heightPx;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyVB();
applyTransform();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
@@ -225,11 +244,10 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): Z
if (moved) event.stopPropagation();
});
const reset = (): void => {
vb.x = base.x;
vb.y = base.y;
vb.w = base.w;
vb.h = base.h;
applyVB();
scale = 1;
tx = 0;
ty = 0;
applyTransform();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
@@ -457,6 +475,26 @@ export function createCrossSectionCard(
);
}
// 확대·축소·팬이 닿는 범위 = 이 레이어 안(2026-08-08 사용자 지시. 축·눈금·축 이름은 제자리).
// clip 그룹은 고정하고 그 **안쪽** plotLayer만 transform으로 움직인다 — clip을 transform이
// 붙은 요소에 직접 걸면 자르는 창까지 같이 확대돼 플롯 밖으로 도형이 새어 나간다.
// 표시 반폭 밖의 설계선·면적 밴드·포장·암 경계를 잘라 내는 몫도 이 clip이 겸한다
// (2026-08-06 사용자 지적 — 지면 샘플만 잘라서는 나머지가 플롯을 뚫고 나갔다).
const clipId = `b06-cross-clip-${section.station_id}`;
const clip = svgElement("clipPath", { id: clipId });
clip.append(
svgElement("rect", {
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 clipGroup = svgElement("g", { "clip-path": `url(#${clipId})` });
const plotLayer = svgElement("g", { class: "b06-chart__plot-layer" });
clipGroup.append(plotLayer);
svg.append(clip, clipGroup);
const segments: string[] = [];
let current: string[] = [];
for (const sample of sourceSamples) {
@@ -470,32 +508,17 @@ export function createCrossSectionCard(
}
if (current.length > 1) segments.push(current.join(" "));
segments.forEach((points) =>
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
);
if (section.design) {
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
// 표시 반폭으로 지면 샘플만 잘라도 설계선·면적 밴드·포장·암경계는 원래 좌표를 그대로
// 쓰므로 플롯 밖으로 뚫고 나간다(2026-08-06 사용자 지적). 플롯 영역 clipPath로 잘라
// 지정한 반폭 범위만 보이게 한다.
const clipId = `b06-cross-clip-${section.station_id}`;
const clip = svgElement("clipPath", { id: clipId });
clip.append(
svgElement("rect", {
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 overlayGroup = svgElement("g", { "clip-path": `url(#${clipId})` });
svg.append(clip, overlayGroup);
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
// 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지
// 않고 클래스만 켜면 되므로 줌·팬이 살아남는다.
setBandActive = appendCrossAreaBands(
overlayGroup,
plotLayer,
section.design,
sourceSamples,
x,
@@ -503,12 +526,12 @@ export function createCrossSectionCard(
toggleArea,
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(overlayGroup, section.design, x, toDisplayY);
appendCrossDesignOverlay(overlayGroup, section.design, x, toDisplayY, sourceSamples);
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, sourceSamples);
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
overlayGroup,
plotLayer,
sourceSamples,
rockBoundary.offsetFor(section),
x,
@@ -532,6 +555,23 @@ export function createCrossSectionCard(
? y(elevationMid + (centerElevation - elevationMid) * exaggeration)
: heightPx / 2;
const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`;
// 중심 십자선은 계획 노선 자리를 가리키는 **데이터**라 도형 레이어에 넣어 함께 움직인다.
plotLayer.append(
svgElement("line", {
x1: centerX,
y1: centerY - 18,
x2: centerX,
y2: centerY + 18,
class: centerMarkerClass,
}),
svgElement("line", {
x1: centerX - 18,
y1: centerY,
x2: centerX + 18,
y2: centerY,
class: centerMarkerClass,
}),
);
svg.append(
svgElement("line", {
x1: CROSS_PAD.left,
@@ -547,20 +587,6 @@ export function createCrossSectionCard(
y2: heightPx - CROSS_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: centerX,
y1: centerY - 18,
x2: centerX,
y2: centerY + 18,
class: centerMarkerClass,
}),
svgElement("line", {
x1: centerX - 18,
y1: centerY,
x2: centerX + 18,
y2: centerY,
class: centerMarkerClass,
}),
svgText(L("B06_Profile_View_CrossXAxis"), {
x: widthPx / 2,
y: heightPx - 8,
@@ -575,10 +601,10 @@ export function createCrossSectionCard(
class: "b06-chart__axis-label",
}),
);
// 마우스 휠 줌·드래그 팬·더블클릭 원복(E-5). viewBox 조작 + non-scaling-stroke로 선명도 유지.
// 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke.
const chartWrap = document.createElement("div");
chartWrap.className = "b06-cross-card__chart-wrap";
const zoomPan = attachZoomPan(svg, widthPx, heightPx);
const zoomPan = attachZoomPan(svg, plotLayer, widthPx, heightPx);
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
const readout = buildAreaReadout(section.design, toggleArea);
setChipActive = readout.setActive;
@@ -701,15 +701,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시).
if (storedOptions?.standard_cross_section)
standardPanel?.applyStored(storedOptions.standard_cross_section);
const storedHalfWidth =
storedOptions?.cross_half_width_m && storedOptions.cross_half_width_m > 0
? storedOptions.cross_half_width_m
: Math.max(
0,
...sectionDetail.cross_sections.flatMap((section) =>
section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
),
);
// 입력칸의 기준값은 **백엔드 기본 반폭**(config SECTION_CROSS_HALF_WIDTH_M = 20m, 위에서
// 이미 넣었다)이고, 사용자가 값을 지정해 확정한 이력(data.options)이 있을 때만 그 값으로
// 바꾼다(2026-08-08 사용자 지시). 예전에는 options가 없으면 보유 샘플의 최대 offset을 계산해
// 덮었는데, 옛 기본값(15m)으로 만들어진 노선이 15.0으로 보여 기준값 구실을 못 했다.
// 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다(applyPanelToAll).
const storedHalfWidth = storedOptions?.cross_half_width_m ?? 0;
if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1);
// 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값).
const sessionDisplayKey = displaySessionKey();