feat(B06): 기슭막이 선택·X 자리 조절 화살표
- 기슭막이 폴리곤을 눌러 고르면 강조되고 카드 하단에 ◀/▶/↺가 뜬다. 0.1m씩 화면 좌우로 민다(좌우 벽에서 뜻이 뒤집히지 않게 outward 부호는 내부 환산). - 구조물 선택이 절·성토 면적 강조보다 우선한다. 벽 클릭은 벽에서 멈춰 밴드·카드 선택으로 번지지 않고, 한쪽을 고르면 다른 쪽 강조는 꺼진다. - computeCulvertLayout에 revetShift 인자 추가. 손으로 민 자리는 고정되어 관 길이 정수 맞춤이 벽을 다시 옮기지 않고 폭으로만 흡수한다(폭 상한 0.45×2). - 값은 세션 보관(b06:revetx), 이동 범위 ±3m. - 700줄 정리: Page 751→690(_UI_Page_Station_Controls 분리), Cross_View 765→629(_UI_Cross_View_Zoom 분리). 검증: 이동 5케이스 접점 오차 0.00e+00, 계획고 스윕 24케이스 회귀 문제 0, tsc 통과, pytest 148 pass. 화면 조작 확인은 다음 단계. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<RevetKey, SVGPolygonElement>();
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 전체 반영 = 개별 반폭도 전역값으로 초기화(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<void> {
|
||||
},
|
||||
};
|
||||
|
||||
/* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ──────────────────────
|
||||
* 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때
|
||||
* cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다.
|
||||
* 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */
|
||||
const stationWidths = new Map<string, number>();
|
||||
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<string, number>;
|
||||
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<void> {
|
||||
},
|
||||
rockBoundaryControl,
|
||||
stationWidthControl,
|
||||
revetOffsetControl,
|
||||
);
|
||||
|
||||
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
|
||||
@@ -693,7 +632,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
|
||||
currentRouteId = context.route_id;
|
||||
loadRockOffsets();
|
||||
loadStationWidths();
|
||||
stationControls.load();
|
||||
try {
|
||||
const existing = await getSections(projectId, context.route_id);
|
||||
if (!existing.longitudinal) {
|
||||
|
||||
@@ -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<string, number>;
|
||||
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<string, number>();
|
||||
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<string, number>;
|
||||
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<string, number>();
|
||||
const revetSelected = new Map<string, RevetKey>();
|
||||
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<string, number>;
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user