diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index a2116e3e..c6bcb169 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -48,13 +48,26 @@ function polygon( return shape; } -/** 배수관 세트 오버레이 — computeCulvertLayout 결과를 그린다. */ +/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. */ +export type RevetKey = "inlet" | "outlet"; + +/** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ +export type RevetHighlightSetter = (key: RevetKey | null) => void; + +/** + * 배수관 세트 오버레이 — computeCulvertLayout 결과를 그린다. + * + * `onSelectRevet`이 오면 기슭막이 폴리곤이 **선택 대상**이 된다(2026-08-21 사용자 ①). + * 클릭은 여기서 멈춘다 — 밑에 깔린 절·성토 밴드와 카드 선택으로 번지면 강조가 다른 + * 것으로 바뀌거나 카드가 다시 그려진다(**구조물 선택이 면적 선택보다 우선**). + */ export function appendCulvertOverlay( layer: SVGElement, layout: CulvertLayout, x: (offset: number) => number, toDisplayY: (elevation: number) => number, -): void { + onSelectRevet?: (key: RevetKey) => void, +): RevetHighlightSetter { const { culvert, pipe, pipeCorners } = layout; const diameter = culvert.diameter_m; const roleLabel = (role: "inlet" | "outlet") => (role === "inlet" ? "유입" : "유출"); @@ -92,19 +105,27 @@ export function appendCulvertOverlay( ...layout.walls.filter((w) => w.role === "outlet"), ...layout.walls.filter((w) => w.role === "inlet"), ]; + const revetShapes = new Map(); for (const wall of wallsInOrder) { // 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②). - layer.append( - polygon( - wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), - "b06-chart__culvert-revet", - `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${wall.height.toFixed(1)}m` + - `(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` + - `, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` + - ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)` + - (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""), - ), + const revetShape = polygon( + wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), + "b06-chart__culvert-revet", + `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${wall.height.toFixed(1)}m` + + `(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` + + `, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` + + ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)` + + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""), ); + if (onSelectRevet) { + revetShape.classList.add("is-selectable"); + revetShape.addEventListener("click", (event) => { + event.stopPropagation(); + onSelectRevet(wall.role); + }); + } + revetShapes.set(wall.role, revetShape); + layer.append(revetShape); // 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선): // 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다. const joint = document.createElementNS(SVG_NS, "line"); @@ -263,4 +284,9 @@ export function appendCulvertOverlay( ); } drawPitching(); + + // 강조는 클래스만 갈아 끼운다 — 카드를 다시 그리면 휠 줌·팬이 초기화된다. + return (key) => { + for (const [role, shape] of revetShapes) shape.classList.toggle("is-active", role === key); + }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 2c821139..cfc2ddc1 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -64,6 +64,8 @@ import type { FillSlopeSegment, FillWallPlacement } from "./B06_Section_UI_Cross export function computeCulvertLayout( section: CrossSection, groundSamples: SectionSample[], + /** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */ + revetShift?: { inlet?: number; outlet?: number }, ): CulvertLayout | null { const culvert = section.culvert; if (!culvert) return null; @@ -132,8 +134,10 @@ export function computeCulvertLayout( slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit), ); if (inletPlacement) { - inlet.offset = inletPlacement.offset; - inlet.elevation = Math.min(groundAt(inletPlacement.offset), invertCap(inletInfo.edge)); + // 사용자가 화살표로 민 만큼 자동 자리에서 더 옮긴다(2026-08-21 사용자 ①). + const shifted = inletPlacement.offset + inletInfo.outward * (revetShift?.inlet ?? 0); + inlet.offset = shifted; + inlet.elevation = Math.min(groundAt(shifted), invertCap(inletInfo.edge)); } } @@ -425,10 +429,8 @@ export function computeCulvertLayout( outletAnchorOffset, ); if (outletPlacement) { - outletWallAnchor = { - offset: outletPlacement.offset, - elevation: invertAt(outletPlacement.offset), - }; + const shifted = outletPlacement.offset + outletInfo.outward * (revetShift?.outlet ?? 0); + outletWallAnchor = { offset: shifted, elevation: invertAt(shifted) }; } } let outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null); @@ -533,9 +535,12 @@ export function computeCulvertLayout( const gap = target - cutLength(pipeCorners); if (Math.abs(gap) < 0.005) break; // ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위). + // 단 사용자가 화살표로 자리를 지정했으면 그 자리를 지키고 폭으로만 흡수한다. + const pinned = Math.abs(revetShift?.outlet ?? 0) > 1e-9; const moved = outletWallAnchor.offset + axis.offset * gap; const movedSlope = outletSlopeAt(moved, outletThickness); if ( + !pinned && movedSlope.ratio >= FILL_SLOPE_RATIO_MIN && movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 @@ -551,7 +556,8 @@ export function computeCulvertLayout( // (이음선 t/2 + 전면 t). 관 축에 투영한 몫만 길이에 반영된다. const grip = Math.max(Math.abs(axis.offset), 0.2) * 1.5; const wanted = outletThickness + gap / grip; - let capped = Math.min(Math.max(wanted, REVET_THICKNESS_M * 0.6), REVET_THICKNESS_M * 2.5); + // 폭은 0.45의 0.6~2배까지만 — 남는 몫은 관이 벽을 조금 벗어나는 것으로 둔다. + let capped = Math.min(Math.max(wanted, REVET_THICKNESS_M * 0.6), REVET_THICKNESS_M * 2); // 폭을 넓히면 이음선도 바깥으로 밀려 사면이 길어진다 — **5m 한계가 우선**이라 // 넘는 몫은 포기한다(별표2). 그 경우 관은 표기 길이보다 짧게 벽을 조금 벗어난다 // (2026-08-20 확정 규칙 그대로). @@ -629,7 +635,6 @@ export function computeCulvertLayout( // ── 성토 사면 구간 확정. 벽 자리가 다 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동) // 이 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. - // 사면은 **단일 각도**(2026-08-21 사용자)라 노견 → 벽 이음선 상단점 직선 하나다. const slopeOf = (wall: WallLayout): FillSlopeSegment => fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge); for (const wall of walls) { diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index cb5bedd7..397294db 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -24,6 +24,8 @@ import { type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCulvertOverlay, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; +import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { CROSS_HEIGHT, CROSS_PAD, @@ -144,143 +146,6 @@ export function crossCardNaturalHeight( * 선 굵기는 `vector-effect: non-scaling-stroke`(CSS)라 배율과 무관하게 유지된다. * 최대 8배까지, 축소는 원배율까지만 허용한다. */ -/** - * 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다. - * - * **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에 - * 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭. - */ -interface ZoomPanHandle { - /** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */ - zoom: (factor: number) => void; - reset: () => void; -} - -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 => { - 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 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(); - }); - 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 { - /* 이미 해제됨 */ - } - }; - svg.addEventListener("pointerup", endPan); - svg.addEventListener("pointercancel", endPan); - // 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다. - svg.addEventListener("click", (event) => { - if (moved) event.stopPropagation(); - }); - const reset = (): void => { - scale = 1; - tx = 0; - ty = 0; - applyTransform(); - }; - // 더블클릭 원복. - svg.addEventListener("dblclick", (event) => { - event.stopPropagation(); - reset(); - }); - return { zoom, reset }; -} - -/** 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. */ -function buildZoomControls(handle: ZoomPanHandle): HTMLElement { - const bar = document.createElement("div"); - bar.className = "b06-cross-card__zoom"; - const add = (label: string, title: string, action: () => void): void => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b06-cross-card__zoom-btn"; - button.textContent = label; - button.title = title; - button.addEventListener("click", (event) => { - // 카드 선택으로 번지면 그래프가 다시 그려져 방금 맞춘 배율이 날아간다. - event.stopPropagation(); - action(); - }); - bar.append(button); - }; - add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85)); - add("−", L("B06_Profile_View_ZoomOut"), () => handle.zoom(0.85)); - add("⤢", L("B06_Profile_View_ZoomFit"), () => handle.reset()); - return bar; -} - /** * 측점 **개별 표시 반폭** 세션 제어기(2026-08-06 사용자 지시). * Page가 세션 보관·저장값 복원·카드 갱신·확정 저장(cross_patches)을 연결해 구현한다. @@ -292,6 +157,19 @@ export interface StationWidthControl { reset: (chainageM: number) => void; } +/** + * 기슭막이 X 자리 제어(2026-08-21 사용자 ①) — 벽을 눌러 고르고 ◀/▶로 0.1m씩 민다. + * 고른 벽은 여기 담아 두어 카드가 다시 그려져도 되살아난다. `select`는 값만 담고 + * 다시 그리지 않는다 — 강조는 클래스만 갈아 끼워 줌·팬을 지킨다. + */ +export interface RevetOffsetControl { + shiftFor: (section: CrossSection, role: RevetKey) => number; + selectedFor: (section: CrossSection) => RevetKey | null; + select: (chainageM: number, key: RevetKey | null) => void; + adjust: (chainageM: number, role: RevetKey, deltaM: number) => void; + reset: (chainageM: number, role: RevetKey) => void; +} + export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -310,6 +188,8 @@ export function createCrossSectionCard( onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void, /** 개별 표시 반폭 제어 — 있으면 카드 하단에 ◀/▶/↺ 버튼 그룹을 우측 맞춤으로 단다. */ stationWidth?: StationWidthControl, + /** 기슭막이 X 자리 제어 — 있으면 벽이 선택 가능해지고 선택 시 ◀/▶/↺가 뜬다. */ + revetOffset?: RevetOffsetControl, ): CrossCardElement { // 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06). const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; @@ -328,6 +208,22 @@ export function createCrossSectionCard( let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null; let setBandActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined; + let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; + let setRevetActive: RevetHighlightSetter = () => undefined; + let showRevetControl: (visible: boolean) => void = () => undefined; + /** 기슭막이를 고르면 면적 강조는 끈다 — **구조물 선택이 우선**(2026-08-21 사용자 ①). */ + const toggleRevet = (key: RevetKey): void => { + activeRevet = activeRevet === key ? null : key; + setRevetActive(activeRevet); + showRevetControl(activeRevet !== null); + revetOffset?.select(section.chainage_m, activeRevet); + if (activeRevet !== null && activeArea !== null) { + activeArea = null; + setBandActive(null); + setChipActive(null); + onAreaSelect?.(section.station_id, null); + } + }; card.applySelection = (nextSelected, areaKey) => { isSelected = nextSelected; card.classList.toggle("b06-cross-card--selected", nextSelected); @@ -342,6 +238,12 @@ export function createCrossSectionCard( return; } activeArea = activeArea === key ? null : key; + if (activeArea !== null && activeRevet !== null) { + activeRevet = null; + setRevetActive(null); + showRevetControl(false); + revetOffset?.select(section.chainage_m, null); + } setBandActive(activeArea); setChipActive(activeArea); onAreaSelect?.(section.station_id, activeArea); @@ -527,7 +429,16 @@ export function createCrossSectionCard( toggleArea, ); // 배수관 세트 기하를 먼저 계산한다 — 설계선이 기슭막이 밖 성토 경사를 끊는 데 쓴다. - const culvertLayout = computeCulvertLayout(section, sourceSamples); + const culvertLayout = computeCulvertLayout( + section, + sourceSamples, + revetOffset + ? { + inlet: revetOffset.shiftFor(section, "inlet"), + outlet: revetOffset.shiftFor(section, "outlet"), + } + : undefined, + ); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. appendPavementOverlay(plotLayer, section.design, x, toDisplayY); appendCrossDesignOverlay( @@ -549,7 +460,16 @@ export function createCrossSectionCard( ); } // 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19). - if (culvertLayout) appendCulvertOverlay(plotLayer, culvertLayout, x, toDisplayY); + if (culvertLayout) { + setRevetActive = appendCulvertOverlay( + plotLayer, + culvertLayout, + x, + toDisplayY, + revetOffset ? toggleRevet : undefined, + ); + if (activeRevet) setRevetActive(activeRevet); + } } const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { @@ -666,6 +586,44 @@ export function createCrossSectionCard( ); footer.append(widthControl); } + // 기슭막이 X 자리 ◀/▶/↺ — 벽을 골랐을 때만 보인다(2026-08-21 사용자 ①). 0.1m씩 민다. + // ◀/▶은 **화면 좌우**가 아니라 벽 기준 안쪽/바깥쪽이면 좌우 벽에서 뜻이 뒤집혀 헷갈린다. + // 화면 좌우 그대로 두고, 벽의 outward 부호는 제어 쪽에서 맞춘다. + if (revetOffset) { + const revetControl = document.createElement("div"); + revetControl.className = "b06-cross-card__revetctl"; + const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b06-design__rockb-btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; + }; + // 벽이 서는 쪽(outward): 상단측(유입)이 좌측이면 유입 벽은 좌(+), 유출 벽은 우(−). + const outwardOf = (role: RevetKey): number => + ((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1; + // 화면 좌(◀)로 민다 = offset이 커진다. 이동량은 벽 기준(outward)으로 환산해 넘긴다. + const nudge = (screenDeltaM: number) => () => { + if (activeRevet) { + revetOffset.adjust(section.chainage_m, activeRevet, screenDeltaM * outwardOf(activeRevet)); + } + }; + revetControl.append( + makeButton("◀", L("B06_Cross_Revet_Left"), nudge(0.1)), + makeButton("▶", L("B06_Cross_Revet_Right"), nudge(-0.1)), + makeButton("↺", L("B06_Cross_Revet_Reset"), () => { + if (activeRevet) revetOffset.reset(section.chainage_m, activeRevet); + }), + ); + showRevetControl = (visible) => revetControl.classList.toggle("is-hidden", !visible); + showRevetControl(activeRevet !== null); + footer.append(revetControl); + } card.append(footer); return card; } diff --git a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts new file mode 100644 index 00000000..9bd1044a --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B06_Section_UI_Cross_View_Zoom.ts + * 횡단 카드 그래프의 **확대·축소·팬** 조작구. 카드 렌더러(`_UI_Cross_View.ts`)에서 + * 700줄 제한으로 분리했다. 동작 규칙(휠은 줌이 아님)은 아래 주석 그대로다. + * ========================================================================== */ + +import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common"; + +/** + * 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다. + * + * **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에 + * 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭. + */ +export interface ZoomPanHandle { + /** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */ + zoom: (factor: number) => void; + reset: () => void; +} + +export 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 => { + 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 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(); + }); + 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 { + /* 이미 해제됨 */ + } + }; + svg.addEventListener("pointerup", endPan); + svg.addEventListener("pointercancel", endPan); + // 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다. + svg.addEventListener("click", (event) => { + if (moved) event.stopPropagation(); + }); + const reset = (): void => { + scale = 1; + tx = 0; + ty = 0; + applyTransform(); + }; + // 더블클릭 원복. + svg.addEventListener("dblclick", (event) => { + event.stopPropagation(); + reset(); + }); + return { zoom, reset }; +} + +/** 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. */ +export function buildZoomControls(handle: ZoomPanHandle): HTMLElement { + const bar = document.createElement("div"); + bar.className = "b06-cross-card__zoom"; + const add = (label: string, title: string, action: () => void): void => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b06-cross-card__zoom-btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + // 카드 선택으로 번지면 그래프가 다시 그려져 방금 맞춘 배율이 날아간다. + event.stopPropagation(); + action(); + }); + bar.append(button); + }; + add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85)); + add("−", L("B06_Profile_View_ZoomOut"), () => handle.zoom(0.85)); + add("⤢", L("B06_Profile_View_ZoomFit"), () => handle.reset()); + return bar; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index e3557f2c..cbdc13d6 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -32,7 +32,7 @@ import { type SectionDetailResponse, type StandardCrossSection, } from "./B06_Section_Api_Fetch"; -import { type StationWidthControl } from "./B06_Section_UI_Cross_View"; +import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { type CrossDesignChange, @@ -295,13 +295,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 전체 반영 = 개별 반폭도 전역값으로 초기화(2026-08-06). 저장된 개별값 // (design.display_half_width_m)이 남아 있으면 전역 반폭이 무시되므로, 세션에 // 전역값을 명시해 저장값보다 우선하게 한다(확정 시 저장값도 전역으로 덮인다). - stationWidths.clear(); - if (requested !== undefined) { - for (const section of sectionDetail.cross_sections) { - stationWidths.set(widthKey(section.chainage_m), clampStationWidth(requested)); - } - } - persistStationWidths(); + stationControls.applyGlobalWidth( + requested, + sectionDetail.cross_sections.map((section) => section.chainage_m), + ); persistDisplayHalfWidth(); renderSectionDetail(); updateActionState(); @@ -394,76 +391,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }, }; - /* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ────────────────────── - * 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때 - * cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다. - * 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */ - const stationWidths = new Map(); - const widthKey = (chainageM: number): string => chainageM.toFixed(2); - const widthSessionKey = (): string | null => - projectId && currentRouteId !== null ? `b06:crossw:${projectId}:${currentRouteId}` : null; - - function loadStationWidths(): void { - stationWidths.clear(); - const key = widthSessionKey(); - if (!key) return; - try { - const raw = window.sessionStorage.getItem(key); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([chainage, width]) => { - if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width); - }); - } catch { - /* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */ - } - } - - function persistStationWidths(): void { - const key = widthSessionKey(); - if (!key) return; - try { - window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths))); - } catch { - /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ - } - } - - /** 개별 반폭 하한 2m·상한은 보유 샘플 폭 — 표시용이라 샘플 밖은 의미가 없다. */ - const clampStationWidth = (value: number): number => - Math.min(Math.max(value, 2), Math.max(sampledHalfWidth(), 2)); - - const stationWidthControl: StationWidthControl = { - widthFor: (section) => { - const session = stationWidths.get(widthKey(section.chainage_m)); - if (session !== undefined) return session; - const stored = section.design?.display_half_width_m; - return typeof stored === "number" && stored > 0 ? stored : undefined; - }, - adjust: (chainageM, deltaM) => { - const key = widthKey(chainageM); - const section = sectionDetail?.cross_sections.find( - (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, - ); - const stored = section?.design?.display_half_width_m; - const current = - stationWidths.get(key) ?? - (typeof stored === "number" && stored > 0 ? stored : undefined) ?? - crossHalfWidth() ?? - sampledHalfWidth(); - stationWidths.set(key, clampStationWidth(Math.round(current + deltaM))); - persistStationWidths(); - sectionView.refreshCard(chainageM); - }, - reset: (chainageM) => { - // 초기화 = 전역 반폭 복귀. 저장값(design)도 무시해야 하므로 세션에 전역값을 명시한다. - const globalWidth = crossHalfWidth(); - if (globalWidth === undefined) stationWidths.delete(widthKey(chainageM)); - else stationWidths.set(widthKey(chainageM), clampStationWidth(globalWidth)); - persistStationWidths(); - sectionView.refreshCard(chainageM); - }, - }; + const stationControls = createStationControls({ + sessionKey: (kind) => + projectId && currentRouteId !== null ? `b06:${kind}:${projectId}:${currentRouteId}` : null, + refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + detail: () => sectionDetail, + crossHalfWidth, + sampledHalfWidth, + }); + const stationWidthControl = stationControls.stationWidth; + const revetOffsetControl = stationControls.revetOffset; + const stationWidths = stationControls.widths; const sectionView = createSectionView( (chainageM, change) => { @@ -471,6 +409,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }, rockBoundaryControl, stationWidthControl, + revetOffsetControl, ); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. @@ -693,7 +632,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; loadRockOffsets(); - loadStationWidths(); + stationControls.load(); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts new file mode 100644 index 00000000..6630a595 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -0,0 +1,179 @@ +/* ============================================================================= + * B06_Section_UI_Page_Station_Controls.ts + * 측점 단위 표시 제어 두 가지 — **개별 표시 반폭**(2026-08-06)과 **기슭막이 X 자리** + * (2026-08-21). 둘 다 세션에만 담고 카드 재렌더로 반영한다. 페이지 본체 + * (`_UI_Page.ts`)에서 700줄 제한으로 분리했다. + * ========================================================================== */ + +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetOffsetControl, StationWidthControl } from "./B06_Section_UI_Cross_View"; + +/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */ +export interface StationControlDeps { + sessionKey: (kind: "crossw" | "revetx") => string | null; + refreshCard: (chainageM: number) => void; + detail: () => SectionDetailResponse | null; + crossHalfWidth: () => number | undefined; + sampledHalfWidth: () => number; +} + +/** 반폭·기슭막이 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ +export interface StationControls { + stationWidth: StationWidthControl; + revetOffset: RevetOffsetControl; + widths: Map; + load: () => void; + /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ + applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void; +} + +export function createStationControls(deps: StationControlDeps): StationControls { + /* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ────────────────────── + * 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때 + * cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다. + * 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */ + const stationWidths = new Map(); + const widthKey = (chainageM: number): string => chainageM.toFixed(2); + const widthSessionKey = (): string | null => deps.sessionKey("crossw"); + + function loadStationWidths(): void { + stationWidths.clear(); + const key = widthSessionKey(); + if (!key) return; + try { + const raw = window.sessionStorage.getItem(key); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, width]) => { + if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width); + }); + } catch { + /* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */ + } + } + + function persistStationWidths(): void { + const key = widthSessionKey(); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + } + + /** 개별 반폭 하한 2m·상한은 보유 샘플 폭 — 표시용이라 샘플 밖은 의미가 없다. */ + const clampStationWidth = (value: number): number => + Math.min(Math.max(value, 2), Math.max(deps.sampledHalfWidth(), 2)); + + const stationWidthControl: StationWidthControl = { + widthFor: (section) => { + const session = stationWidths.get(widthKey(section.chainage_m)); + if (session !== undefined) return session; + const stored = section.design?.display_half_width_m; + return typeof stored === "number" && stored > 0 ? stored : undefined; + }, + adjust: (chainageM, deltaM) => { + const key = widthKey(chainageM); + const section = deps + .detail() + ?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01); + const stored = section?.design?.display_half_width_m; + const current = + stationWidths.get(key) ?? + (typeof stored === "number" && stored > 0 ? stored : undefined) ?? + deps.crossHalfWidth() ?? + deps.sampledHalfWidth(); + stationWidths.set(key, clampStationWidth(Math.round(current + deltaM))); + persistStationWidths(); + deps.refreshCard(chainageM); + }, + reset: (chainageM) => { + // 초기화 = 전역 반폭 복귀. 저장값(design)도 무시해야 하므로 세션에 전역값을 명시한다. + const globalWidth = deps.crossHalfWidth(); + if (globalWidth === undefined) stationWidths.delete(widthKey(chainageM)); + else stationWidths.set(widthKey(chainageM), clampStationWidth(globalWidth)); + persistStationWidths(); + deps.refreshCard(chainageM); + }, + }; + + /* ── 기슭막이 X 자리(2026-08-21 사용자 ①) ──────────────────────────── + * 벽을 눌러 고르고 카드 하단 ◀/▶로 0.1m씩 민다. 값은 세션에만 담는다 — 자동 자리가 + * 지형·계획고를 따라 다시 풀리므로, 손으로 민 값은 그 세션의 표시 조정으로 본다. + * 키는 `누가거리:역할`. `select`는 다시 그리지 않고 값만 담는다(줌·팬 보존). */ + const revetShifts = new Map(); + const revetSelected = new Map(); + const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; + const revetSessionKey = (): string | null => deps.sessionKey("revetx"); + + function loadRevetShifts(): void { + revetShifts.clear(); + revetSelected.clear(); + const key = revetSessionKey(); + if (!key) return; + try { + const raw = window.sessionStorage.getItem(key); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([entry, shift]) => { + if (Number.isFinite(shift)) revetShifts.set(entry, shift); + }); + } catch { + /* 손상된 세션 값은 무시 — 자동 자리로 재시작. */ + } + } + + function persistRevetShifts(): void { + const key = revetSessionKey(); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(revetShifts))); + } catch { + /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ + } + } + + /** 손으로 미는 범위 한계(m) — 벽이 노면 밑이나 사면 밖으로 달아나지 않게 묶는다. */ + const clampRevetShift = (value: number): number => + Math.min(Math.max(Math.round(value * 10) / 10, -3), 3); + + const revetOffsetControl: RevetOffsetControl = { + shiftFor: (section, role) => revetShifts.get(revetKey(section.chainage_m, role)) ?? 0, + selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null, + select: (chainageM, key) => { + if (key) revetSelected.set(chainageM.toFixed(2), key); + else revetSelected.delete(chainageM.toFixed(2)); + }, + adjust: (chainageM, role, deltaM) => { + const key = revetKey(chainageM, role); + revetShifts.set(key, clampRevetShift((revetShifts.get(key) ?? 0) + deltaM)); + persistRevetShifts(); + deps.refreshCard(chainageM); + }, + reset: (chainageM, role) => { + revetShifts.delete(revetKey(chainageM, role)); + persistRevetShifts(); + deps.refreshCard(chainageM); + }, + }; + return { + stationWidth: stationWidthControl, + revetOffset: revetOffsetControl, + widths: stationWidths, + load: () => { + loadStationWidths(); + loadRevetShifts(); + }, + applyGlobalWidth: (requested, chainages) => { + stationWidths.clear(); + if (requested !== undefined) { + for (const chainageM of chainages) { + stationWidths.set(widthKey(chainageM), clampStationWidth(requested)); + } + } + persistStationWidths(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 708f1518..5a2b1fc9 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -27,14 +27,12 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import { createCrossSectionCard, + type RevetOffsetControl, crossCardNaturalHeight, type CrossCardElement, type StationWidthControl, } from "./B06_Section_UI_Cross_View"; -import { - createLongitudinalProfile, - longitudinalMinimumWidth, -} from "./B06_Section_UI_Longitudinal"; +import { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; import { applyLegendToggle, computeMassHaulSeries, @@ -173,6 +171,8 @@ export function createSectionView( rockBoundary?: RockBoundaryControl, /** 측점 개별 표시 반폭 제어(2026-08-06) — 카드 하단 ◀/▶/↺과 행 높이 계산이 쓴다. */ stationWidth?: StationWidthControl, + /** 기슭막이 X 자리 제어(2026-08-21) — 벽을 골라 0.1m씩 민다. */ + revetOffset?: RevetOffsetControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -443,6 +443,7 @@ export function createSectionView( section.station_id === selectedStationId ? activeAreaKey : null, selectArea, stationWidth, + revetOffset, ); /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index c45c3193..b900345a 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -597,3 +597,15 @@ color: var(--color-text); background: var(--color-surface); } + +/* 기슭막이 선택 — 구조물 선택이 절·성토 면적 강조보다 우선한다(2026-08-21 사용자 ①). + 색은 토큰만 쓰고 하드코딩하지 않는다. */ +.b06-chart__culvert-revet.is-selectable { + cursor: pointer; +} + +.b06-chart__culvert-revet.is-active { + stroke: var(--color-accent, currentColor); + stroke-width: 2; + filter: brightness(1.15); +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index cfe33a1d..d879a71a 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -235,6 +235,15 @@ export const ui_locales_b2 = { B06_Cross_Width_Dec: ["표시 반폭 1m 줄이기", "Narrow view width by 1m"], B06_Cross_Width_Inc: ["표시 반폭 1m 늘리기", "Widen view width by 1m"], B06_Cross_Width_Reset: ["전역 반폭으로 초기화", "Reset to global width"], + B06_Cross_Revet_Select: [ + "기슭막이 선택 — 화살표로 자리 조절", + "Select revetment — nudge with arrows", + ], + B06_Cross_Revet_Left: ["기슭막이 0.1m 왼쪽으로", "Move revetment 0.1m left"], + B06_Cross_Revet_Right: ["기슭막이 0.1m 오른쪽으로", "Move revetment 0.1m right"], + B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"], + B06_Cross_Revet_Inlet: ["유입 기슭막이", "Inlet revetment"], + B06_Cross_Revet_Outlet: ["유출 기슭막이", "Outlet revetment"], B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"], B06_Profile_View_Cross: ["횡단면도", "Cross sections"], B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"],