From ea29cc2e59f618271732fb11c75afae6ca55e5df Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 18:24:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=ED=99=95=EB=8C=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=8C=AC=EC=9C=BC=EB=A1=9C=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9C=A0=20=EB=B2=94=EC=9C=84=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EC=97=B4=EB=9E=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도형(지반선·면적 밴드·설계선·암 경계선)을 표시 반폭으로 자르지 않고 **캐시 보유 전 구간**으로 그린다. 표시 반폭 밖은 기존 clip이 가리므로 원배율 화면은 그대로다. - 팬 한계(clampPan)를 그려진 범위까지 확장 — 배율 1에서는 종전대로 이동 0으로 묶고, 확대 상태에서만 캐시 끝까지 가운데 버튼 드래그로 끌어다 볼 수 있다. - 축 눈금은 팬 상태를 역산해 다시 그리므로(_UI_Cross_Axes) X·Y 값이 따라 움직인다. 자체검증: node tmp/tests/test_cross_pan_bounds.mjs (배율 1 고정, 2배에서 좌우 한계가 캐시 끝과 일치, 범위 밖 이탈 없음, content 없을 때 옛 규칙 유지), test_cross_axes_zoom.mjs 재실행, tsc --noEmit 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_View.ts | 27 ++++++++++-- B06_Section/B06_Section_UI_Cross_View_Zoom.ts | 41 ++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 169d95a7..bcee0c87 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -327,9 +327,13 @@ export function createCrossSectionCard( clipGroup.append(plotLayer); svg.append(clip, clipGroup); + // 그리기는 **캐시 보유 전 구간**으로 한다 — 표시 반폭 밖은 clip으로 가려 두고, 확대 + // 상태의 가운데 버튼 팬으로 끌어다 본다(2026-08-23 사용자 지시). 축 눈금은 팬을 따라 + // 다시 그려지므로(_UI_Cross_Axes) 값이 어긋나지 않는다. + const drawSamples = section.samples; const segments: string[] = []; let current: string[] = []; - for (const sample of sourceSamples) { + for (const sample of drawSamples) { if (!validElevation(sample)) { if (current.length > 1) segments.push(current.join(" ")); current = []; @@ -352,7 +356,7 @@ export function createCrossSectionCard( setBandActive = appendCrossAreaBands( plotLayer, section.design, - sourceSamples, + drawSamples, x, toDisplayY, toggleArea, @@ -375,14 +379,14 @@ export function createCrossSectionCard( section.design, x, toDisplayY, - sourceSamples, + drawSamples, culvertLayout?.designTrim ?? undefined, ); // 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의. if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( plotLayer, - sourceSamples, + drawSamples, rockBoundary.offsetFor(section), x, toDisplayY, @@ -483,6 +487,20 @@ export function createCrossSectionCard( // 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke. const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; + // 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상·하단. + const drawnOffsets = drawSamples.map((sample) => sample.offset_m ?? 0); + const drawnDisplayY = drawSamples + .filter(validElevation) + .map((sample) => y(elevationMid + (sample.elevation_m - elevationMid) * exaggeration)); + const contentBounds = + drawnOffsets.length && drawnDisplayY.length + ? { + x: x(Math.max(...drawnOffsets)), + width: x(Math.min(...drawnOffsets)) - x(Math.max(...drawnOffsets)), + y: Math.min(...drawnDisplayY), + height: Math.max(...drawnDisplayY) - Math.min(...drawnDisplayY), + } + : undefined; const zoomPan = attachZoomPan( svg, plotLayer, @@ -493,6 +511,7 @@ export function createCrossSectionCard( cardZoomStates.set(section.station_id, state); renderAxes(state); }, + contentBounds, ); // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea); diff --git a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts index a16d2665..ec140ebf 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts @@ -38,6 +38,18 @@ export interface ZoomPanState { ty: number; } +/** + * 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 반폭 밖에 있어 잘려 있는 + * **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다 + * (2026-08-23 사용자 지시). + */ +export interface ContentBounds { + x: number; + y: number; + width: number; + height: number; +} + export function attachZoomPan( svg: SVGSVGElement, plotLayer: SVGGElement, @@ -47,6 +59,8 @@ export function attachZoomPan( initial?: ZoomPanState, /** 상태가 바뀔 때마다 부른다 — 카드 밖(뷰)이 담아 뒀다가 다음 렌더에 되돌린다. */ onChange?: (state: ZoomPanState) => void, + /** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */ + content?: ContentBounds, ): ZoomPanHandle { // 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다. const plot = { @@ -64,9 +78,32 @@ export function attachZoomPan( 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 => { - 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 bounds = + content && scale > 1 + 1e-6 + ? { + 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); }; // 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.