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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user