diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index 0c5b1845..5694c037 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -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; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 69f509d6..133f9a4a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -701,15 +701,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시). 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();