feat(B04/B05/공통): 7단계 명칭 통일, B05 패널 드래그 리사이즈, B04 계획선 레이어, 2D 지도 좌드래그 팬 제거

- 진행단계 오버레이 버튼과 B그룹 페이지 제목을 파일입력/전처리/종단설계/횡단설계/
  상세설계/수량산출/설계도서로 통일(ko/en). i18n 키는 유지하고 값만 교체.
  B05 페이지 제목의 하드코딩 문구를 locale 참조로 교체.
- ui_template_resizer 공통 스플리터 신설. B05 하단 종단 패널은 위쪽 경계를 끌어
  높이 조절(늘어난 만큼 그래프만 신축, 도면 테이블 높이는 고정), 안쪽 우측
  배수유역 패널은 왼쪽 경계를 끌어 폭 조절(상한 = 하단 패널 폭의 70%).
  크기는 sessionStorage에만 저장하고 CSS 변수로 적용해 접기 규칙과 충돌하지 않게 함.
- B04 2D 지도에 계획선 레이어 추가. 계획노선 좌표를 주는
  GET /api/projects/{id}/planned-route 신설(B03 업로드 CSV → 사업지 좌표계 m).
  선 색·굵기는 MapRender에 상수로 두고 B05 배수유역도와 공유.
- B04 지도 오버레이 재배치: 객체 표시 라벨은 우측 최하단, 배수유역 정보는 좌측 최상단.
- B04 지도·B05 배수유역도에서 좌버튼 드래그 팬 제거(가운데 버튼 전용).
  유역선 핸들·배관 마커 편집은 좌버튼 그대로 유지, 팬 중에만 grabbing 커서.
- B05 배수유역도에 위성사진 표시 토글 추가(등고선 앞, 기본 on).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 14:05:58 +09:00
co-authored by Claude Opus 5
parent ec873c6c0e
commit 9b6547a418
12 changed files with 411 additions and 37 deletions
@@ -277,6 +277,19 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise
}); });
} }
/** 계획노선(B03 업로드 CSV)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */
export interface PlannedRouteResponse {
status: string;
points: Array<{ x: number; y: number }>;
}
/** 2D 지도에 계획선을 겹쳐 그리기 위한 점 목록을 받는다. 없으면 빈 목록이 온다. */
export async function fetchPlannedRoute(projectId: string): Promise<PlannedRouteResponse> {
return requestJson<PlannedRouteResponse>(`/projects/${projectId}/planned-route`, {
method: "GET",
});
}
/* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ──────────────────── /* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ────────────────────
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고 * 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
* 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다. * 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다.
@@ -237,3 +237,43 @@ async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int)
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b"" empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
return Response(content=empty_tile, media_type="application/x-protobuf") return Response(content=empty_tile, media_type="application/x-protobuf")
# 계획노선(B03 업로드 CSV) 폴리라인 조회 — 2D 지도에 계획선을 겹쳐 그리는 데 쓴다.
@router.get("/{project_id}/planned-route", response_model=None)
async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""B03에 업로드된 계획노선을 사업지 좌표계(m) 점 목록으로 돌려준다.
B05의 확정 경로가 아니라 **원청이 준 계획선**이다. 아직 올리지 않았거나 좌표가
모자라면 빈 목록을 돌려준다 — 화면은 계획선만 빼고 그대로 그린다.
"""
from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
)
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route_csv(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
return {"status": "success", "points": []}
# 배경 지도 메타와 같은 좌표계로 맞춘다. 노선 파일이 제 좌표계를 적어 두었고
# 그것이 프로젝트 좌표계와 다르면 여기서 한 번 옮긴다.
target_epsg = project_epsg_from_prj(project_root)
points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices]
source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg
if source_epsg != target_epsg:
from pyproj import Transformer
transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True)
points = [transformer.transform(x, y) for x, y in points]
return {"status": "success", "points": [{"x": x, "y": y} for x, y in points]}
except Exception as exc:
logger.warning("계획노선 조회 실패: %s", exc)
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
@@ -24,6 +24,14 @@ export type MarkerKind = "dot" | "x";
/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */ /** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */
const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)"; const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)";
/**
* 계획선(노선) 표기 색 — 주황. B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다.
* 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다.
*/
export const ROUTE_LINE_COLOR = "#f97316";
/** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */
export const ROUTE_LINE_WIDTH = 2.4;
/** /**
* 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열.
* weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계).
@@ -3,6 +3,7 @@ import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { import {
fetchGisGeoJson, fetchGisGeoJson,
fetchPlannedRoute,
fetchVWorldMeta, fetchVWorldMeta,
getVWorldMapUrl, getVWorldMapUrl,
type VWorldMeta, type VWorldMeta,
@@ -16,6 +17,9 @@ import {
drawPreparedLabels, drawPreparedLabels,
drawPreparedLayer, drawPreparedLayer,
prepareLayer, prepareLayer,
prepareMetricPolyline,
ROUTE_LINE_COLOR,
ROUTE_LINE_WIDTH,
type GeoJsonCollection, type GeoJsonCollection,
type MapRect, type MapRect,
type Normalizer, type Normalizer,
@@ -143,11 +147,14 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
empty.textContent = L("B04_Surface_Map_Empty"); empty.textContent = L("B04_Surface_Map_Empty");
const status = document.createElement("span"); const status = document.createElement("span");
status.className = "b04-map__status"; status.className = "b04-map__status";
// 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. // 지도 위 좌상단 묶음 — 배수유역 정보 자리다(2026-08-01 사용자 지시).
// 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다(2026-08-01 사용자 지시). // 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다.
const statusStack = document.createElement("div"); const statusStack = document.createElement("div");
statusStack.className = "b04-map__status-stack"; statusStack.className = "b04-map__status-stack";
statusStack.append(status); // 객체 표시(지형지물 개수) 라벨은 우측 최하단으로 내린다 — 좌상단을 배수유역에 내주기 위함.
const statusCorner = document.createElement("div");
statusCorner.className = "b04-map__status-corner";
statusCorner.append(status);
const scaleBar = document.createElement("div"); const scaleBar = document.createElement("div");
scaleBar.className = "b04-map__scale"; scaleBar.className = "b04-map__scale";
const scaleText = document.createElement("span"); const scaleText = document.createElement("span");
@@ -160,6 +167,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
canvas, canvas,
empty, empty,
statusStack, statusStack,
statusCorner,
scaleBar, scaleBar,
progress.root, progress.root,
); );
@@ -184,6 +192,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
); );
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer])); const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer]));
let showContourLabels = CONTOUR_LABEL_DEFAULT_ON; let showContourLabels = CONTOUR_LABEL_DEFAULT_ON;
// 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
let routeLayer: PreparedLayer | null = null;
let showRoute = true;
let scale = 1; let scale = 1;
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
@@ -244,6 +255,22 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
도엽_성절토: L("B04_Surface_Map_SheetCutFill"), 도엽_성절토: L("B04_Surface_Map_SheetCutFill"),
도엽_옹벽석축: L("B04_Surface_Map_SheetWall"), 도엽_옹벽석축: L("B04_Surface_Map_SheetWall"),
}; };
// 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와
// 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다.
const routeButton = document.createElement("button");
routeButton.type = "button";
routeButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
routeButton.textContent = L("B04_Surface_Map_PlannedRoute");
routeButton.style.setProperty("--b04-layer-color", ROUTE_LINE_COLOR);
routeButton.setAttribute("aria-pressed", "true");
routeButton.addEventListener("click", () => {
showRoute = !showRoute;
routeButton.classList.toggle("is-active", showRoute);
routeButton.setAttribute("aria-pressed", String(showRoute));
scheduleDraw();
});
gisButtons.append(routeButton);
GIS_LAYERS.forEach((layer) => { GIS_LAYERS.forEach((layer) => {
gisButtons.append( gisButtons.append(
makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]), makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]),
@@ -380,6 +407,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
} }
// 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다. // 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다.
if (normalizer) watershed.draw(context, normalizer, view); if (normalizer) watershed.draw(context, normalizer, view);
// 계획선은 맨 위에 둔다 — 다른 레이어에 덮이면 노선이 어디로 지나는지 읽을 수 없다.
if (showRoute && routeLayer) {
context.lineWidth = ROUTE_LINE_WIDTH;
context.strokeStyle = ROUTE_LINE_COLOR;
drawPreparedLayer(context, routeLayer, view, "dot");
}
updateImageTransform(); updateImageTransform();
drawScaleBar(mapRect); drawScaleBar(mapRect);
} }
@@ -400,11 +433,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
backgroundImages.forEach((image) => image.removeAttribute("src")); backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null; meta = null;
preparedLayers.clear(); preparedLayers.clear();
routeLayer = null;
resetView(); resetView();
status.textContent = L("B04_Surface_Map_Loading"); status.textContent = L("B04_Surface_Map_Loading");
showProgress(0, L("B04_Surface_Map_Loading")); showProgress(0, L("B04_Surface_Map_Loading"));
try { try {
const nextMeta = await fetchVWorldMeta(projectId, "satellite"); // 계획선은 지도 메타와 같은 좌표계라 함께 받아 둔다. 실패해도 지도는 그대로 그린다.
const [nextMeta, planned] = await Promise.all([
fetchVWorldMeta(projectId, "satellite"),
fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })),
]);
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다. // 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
let done = 0; let done = 0;
const loadedLayers = await Promise.all( const loadedLayers = await Promise.all(
@@ -431,6 +469,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
meta = nextMeta; meta = nextMeta;
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
normalizer = createNormalizer(nextMeta); normalizer = createNormalizer(nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
// 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다.
routeButton.disabled = routeLayer === null;
routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : "";
let featureCount = 0; let featureCount = 0;
loadedLayers.forEach(([layer, data]) => { loadedLayers.forEach(([layer, data]) => {
if (!data) return; if (!data) return;
@@ -479,7 +522,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를 // 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다. // 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
if (event.button === 1) event.preventDefault(); if (event.button === 1) event.preventDefault();
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다
// (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
if (event.pointerType === "mouse" && event.button !== 1) return;
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.style.cursor = "grabbing";
viewport.setPointerCapture(event.pointerId); viewport.setPointerCapture(event.pointerId);
}); });
viewport.addEventListener("pointermove", (event) => { viewport.addEventListener("pointermove", (event) => {
@@ -490,6 +538,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
}); });
const stopDragging = (): void => { const stopDragging = (): void => {
dragStart = null; dragStart = null;
viewport.style.removeProperty("cursor");
}; };
viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging); viewport.addEventListener("pointercancel", stopDragging);
+20 -6
View File
@@ -574,16 +574,15 @@
height: 560px; height: 560px;
overflow: hidden; overflow: hidden;
touch-action: none; touch-action: none;
cursor: grab; /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만
JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */
cursor: default;
user-select: none;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-cards); border-radius: var(--radius-cards);
background: var(--color-canvas); background: var(--color-canvas);
} }
.b04-map__viewport:active {
cursor: grabbing;
}
.b04-map__image, .b04-map__image,
.b04-map__canvas { .b04-map__canvas {
position: absolute; position: absolute;
@@ -612,7 +611,7 @@
font-size: var(--text-caption); font-size: var(--text-caption);
} }
/* 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. /* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시).
지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */
.b04-map__status-stack { .b04-map__status-stack {
position: absolute; position: absolute;
@@ -627,6 +626,21 @@
pointer-events: none; pointer-events: none;
} }
/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시).
축척은 좌하단이라 서로 겹치지 않는다. */
.b04-map__status-corner {
position: absolute;
z-index: 2;
right: var(--spacing-12);
bottom: var(--spacing-12);
display: flex;
max-width: min(50%, 480px);
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-4);
pointer-events: none;
}
/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 /* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면
나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다.
배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */
@@ -1,4 +1,5 @@
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { import {
fetchVWorldMeta, fetchVWorldMeta,
@@ -15,6 +16,8 @@ import {
drawUpstreamLines, drawUpstreamLines,
prepareLayer, prepareLayer,
prepareMetricPolyline, prepareMetricPolyline,
ROUTE_LINE_COLOR,
ROUTE_LINE_WIDTH,
type GeoJsonCollection, type GeoJsonCollection,
type MapRect, type MapRect,
type Normalizer, type Normalizer,
@@ -57,9 +60,18 @@ const LAYER_LABELS: Record<DrainageLayer, string> = {
/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ /** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */
const ARROW_TOGGLE_COLOR = "#7c3aed"; const ARROW_TOGGLE_COLOR = "#7c3aed";
const UPSTREAM_TOGGLE_COLOR = "#1d4ed8"; const UPSTREAM_TOGGLE_COLOR = "#1d4ed8";
/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */
const SATELLITE_TOGGLE_COLOR = "#64748b";
const ROUTE_COLOR = "#f97316"; /** 계획선 색은 B04 2D 지도와 같은 값을 쓴다(정의처: MapRender). */
const ROUTE_COLOR = ROUTE_LINE_COLOR;
const COLLAPSED_KEY = "b05-route-drainage-collapsed"; const COLLAPSED_KEY = "b05-route-drainage-collapsed";
/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
const WIDTH_KEY = "b05-route-drainage-width";
/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */
const MIN_PANEL_WIDTH = 320;
/** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */
const MAX_PANEL_WIDTH_RATIO = 0.7;
/** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ /** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */
const BASIN_COLORS = [ const BASIN_COLORS = [
@@ -163,7 +175,18 @@ export function createDrainagePanel(): DrainagePanel {
const basinList = document.createElement("div"); const basinList = document.createElement("div");
basinList.className = "b05-drainage__basins"; basinList.className = "b05-drainage__basins";
basinList.hidden = true; basinList.hidden = true;
root.append(panelHandle.root, header, viewport, basinList); // 왼쪽 경계를 끌어 폭을 조절한다. 상한은 하단 패널 폭의 70%,
// 세로는 하단 패널에 그대로 딸려 간다(따로 조절하지 않는다 — 사용자 지시).
const widthResizer = createPanelResizer({
axis: "horizontal",
target: root,
cssVar: "--b05-drainage-width",
direction: -1,
min: MIN_PANEL_WIDTH,
max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO,
storageKey: WIDTH_KEY,
});
root.append(panelHandle.root, widthResizer.root, header, viewport, basinList);
let projectId: string | null = null; let projectId: string | null = null;
let meta: VWorldMeta | null = null; let meta: VWorldMeta | null = null;
@@ -229,6 +252,17 @@ export function createDrainagePanel(): DrainagePanel {
return button; return button;
} }
// 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이
// 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다.
addLayerToggle(
"위성사진",
SATELLITE_TOGGLE_COLOR,
true,
(next) => {
backgroundImage.hidden = !next;
},
"배경 위성사진을 보이거나 숨깁니다.",
);
DRAINAGE_LAYERS.forEach((layer) => { DRAINAGE_LAYERS.forEach((layer) => {
addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => { addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => {
if (next) activeLayers.add(layer); if (next) activeLayers.add(layer);
@@ -314,7 +348,7 @@ export function createDrainagePanel(): DrainagePanel {
drawUpstreamLines(context, upstreamLines, normalizer, view); drawUpstreamLines(context, upstreamLines, normalizer, view);
} }
if (routeLayer) { if (routeLayer) {
context.lineWidth = 2.4; context.lineWidth = ROUTE_LINE_WIDTH;
context.strokeStyle = ROUTE_COLOR; context.strokeStyle = ROUTE_COLOR;
drawPreparedLayer(context, routeLayer, view, "dot"); drawPreparedLayer(context, routeLayer, view, "dot");
} }
@@ -606,7 +640,12 @@ export function createDrainagePanel(): DrainagePanel {
viewport.setPointerCapture(event.pointerId); viewport.setPointerCapture(event.pointerId);
return; return;
} }
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다
// (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
if (event.pointerType === "mouse" && event.button !== 1) return;
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.style.cursor = "grabbing";
viewport.setPointerCapture(event.pointerId); viewport.setPointerCapture(event.pointerId);
}); });
viewport.addEventListener("pointermove", (event) => { viewport.addEventListener("pointermove", (event) => {
@@ -634,6 +673,7 @@ export function createDrainagePanel(): DrainagePanel {
boundaryEditor.handleUp(); boundaryEditor.handleUp();
pipeEditor.handleUp(); pipeEditor.handleUp();
dragStart = null; dragStart = null;
viewport.style.removeProperty("cursor");
}; };
viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging); viewport.addEventListener("pointercancel", stopDragging);
@@ -676,6 +716,7 @@ export function createDrainagePanel(): DrainagePanel {
frameHandle = 0; frameHandle = 0;
} }
resizeObserver.disconnect(); resizeObserver.disconnect();
widthResizer.dispose();
}, },
}; };
} }
+6 -1
View File
@@ -1,4 +1,5 @@
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { createProgressCircle } from "@ui/ui_template_progress"; import { createProgressCircle } from "@ui/ui_template_progress";
@@ -50,6 +51,10 @@ import "./B05_wf2_Route_UI_Style.css";
type GradeClass = RoutePanelValues["gradeClass"]; type GradeClass = RoutePanelValues["gradeClass"];
type RoadWidths = Record<GradeClass, number>; type RoadWidths = Record<GradeClass, number>;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 };
async function fetchRoadWidths(projectId: string): Promise<RoadWidths> { async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
@@ -628,7 +633,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
mainContent.className = "b05-route__main"; mainContent.className = "b05-route__main";
mainContent.append(viewer.root, profilePanel.root); mainContent.append(viewer.root, profilePanel.root);
const layout = createWorkflowLayout({ const layout = createWorkflowLayout({
title: "종단 설계", title: L("B05_Route_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 2, activeStep: 2,
leftPanel: panel.root, leftPanel: panel.root,
@@ -21,6 +21,7 @@ import {
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal"; } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel";
import { createProgressCircle } from "@ui/ui_template_progress"; import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
@@ -57,9 +58,18 @@ import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed"; const COLLAPSED_KEY = "b05-route-profile-collapsed";
/** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
const HEIGHT_KEY = "b05-route-profile-height";
/** 처음 잡힌 도면 테이블 높이(px). 패널 높이와 같은 수명(세션)으로 함께 남긴다 —
* 페이지를 다시 들어와도 테이블 높이가 달라지지 않아야 한다. */
const TABLE_HEIGHT_KEY = "b05-route-profile-table-height";
/** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ /** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */
const CHART_HEIGHT_RATIO = 0.4; const CHART_HEIGHT_RATIO = 0.4;
const MIN_CHART_HEIGHT = 100; const MIN_CHART_HEIGHT = 100;
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
const MIN_PANEL_HEIGHT = 180;
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */ /** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
const TABLE_ROW_COUNT = 12; const TABLE_ROW_COUNT = 12;
@@ -284,7 +294,18 @@ export function createRouteProfilePanel(
content.className = "b05-route-profile__content"; content.className = "b05-route-profile__content";
const drainagePanel = createDrainagePanel(); const drainagePanel = createDrainagePanel();
content.append(bodyWrap, drainagePanel.root); content.append(bodyWrap, drainagePanel.root);
root.append(panelHandle.root, balanceBar, content); // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
const heightResizer = createPanelResizer({
axis: "vertical",
target: root,
cssVar: "--b05-profile-height",
direction: -1,
min: MIN_PANEL_HEIGHT,
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
storageKey: HEIGHT_KEY,
});
root.append(panelHandle.root, heightResizer.root, balanceBar, content);
drainagePanel.load(projectId); drainagePanel.load(projectId);
let detail: SectionDetailResponse | null = null; let detail: SectionDetailResponse | null = null;
@@ -301,6 +322,8 @@ export function createRouteProfilePanel(
let redrawPending = false; let redrawPending = false;
let lastWidth = 0; let lastWidth = 0;
let lastHeight = 0; let lastHeight = 0;
/** 처음 그릴 때 잡힌 도면 테이블 높이(px). 패널을 끌어도 이 값을 지킨다. */
let fixedTableHeight = Number(sessionStorage.getItem(TABLE_HEIGHT_KEY)) || 0;
function renderBalance(): void { function renderBalance(): void {
balanceBar.replaceChildren(); balanceBar.replaceChildren();
@@ -430,8 +453,14 @@ export function createRouteProfilePanel(
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다). // 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다. // 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const available = Math.max(120, body.clientHeight); const available = Math.max(120, body.clientHeight);
// 첫 화면에서만 4:6으로 나누고, 그때 잡힌 테이블 높이를 이후로도 그대로 쓴다.
// 패널을 끌어 늘리거나 줄이면 그 차이는 전부 그래프가 흡수한다(사용자 지시).
if (alignment && fixedTableHeight <= 0) {
fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO));
sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight));
}
const chartHeight = alignment const chartHeight = alignment
? Math.max(MIN_CHART_HEIGHT, Math.round(available * CHART_HEIGHT_RATIO)) ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
: available; : available;
const tableHeight = available - chartHeight; const tableHeight = available - chartHeight;
@@ -655,6 +684,7 @@ export function createRouteProfilePanel(
dispose() { dispose() {
window.clearTimeout(resizeTimer); window.clearTimeout(resizeTimer);
resizeObserver.disconnect(); resizeObserver.disconnect();
heightResizer.dispose();
}, },
}; };
} }
+22 -8
View File
@@ -47,7 +47,8 @@
left: 0; left: 0;
display: flex; display: flex;
height: 60vh; height: 60vh;
height: 60dvh; /* 기본은 화면의 60%. 위쪽 경계를 끌면 --b05-profile-height(px)가 대신 들어온다. */
height: var(--b05-profile-height, 60dvh);
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: visible; overflow: visible;
@@ -56,6 +57,18 @@
transition: height var(--transition-fast); transition: height var(--transition-fast);
} }
/* 끄는 동안에는 transition을 끈다 — 켜 두면 손보다 패널이 늦게 따라온다. */
.b05-route-profile.is-resizing,
.b05-drainage.is-resizing {
transition: none;
}
/* 접힌 패널에는 끌 것이 없다 — 손잡이를 숨겨 잘못 잡히지 않게 한다. */
.b05-route-profile.is-collapsed > .ui-resizer,
.b05-drainage.is-collapsed > .ui-resizer {
display: none;
}
.b05-route-profile.is-collapsed { .b05-route-profile.is-collapsed {
height: 0; height: 0;
min-height: 0; min-height: 0;
@@ -835,9 +848,11 @@
.b05-drainage { .b05-drainage {
position: relative; position: relative;
display: flex; display: flex;
width: 38%; /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다.
상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */
width: var(--b05-drainage-width, 38%);
min-width: 320px; min-width: 320px;
max-width: 640px; max-width: 70%;
flex: 0 0 auto; flex: 0 0 auto;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -915,14 +930,13 @@
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
background: var(--color-surface); background: var(--color-surface);
cursor: grab; /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가
grabbing으로 바꾼다(2026-08-01 사용자 지시). */
cursor: default;
user-select: none;
touch-action: none; touch-action: none;
} }
.b05-drainage__viewport:active {
cursor: grabbing;
}
.b05-drainage__image, .b05-drainage__image,
.b05-drainage__canvas { .b05-drainage__canvas {
position: absolute; position: absolute;
+18 -13
View File
@@ -99,12 +99,12 @@ export const ui_locales = {
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* (B04~B09 ) * (B04~B09 )
* ------------------------------------------------------------------------ */ * ------------------------------------------------------------------------ */
WF_Step_Surface: ["지표면 분석", "Surface Analysis"], WF_Step_Surface: ["전처리", "Preprocess"],
WF_Step_Route: ["경로 설계", "Route Design"], WF_Step_Route: ["종단설계", "Profile Design"],
WF_Step_ProfileCross: ["횡단 생성", "Profile & Cross-section"], WF_Step_ProfileCross: ["횡단설계", "Cross Design"],
WF_Step_DesignDetail: ["상세 설계", "Detailed Design"], WF_Step_DesignDetail: ["상세설계", "Detail Design"],
WF_Step_Quantity: ["수량 산출", "Quantity Takeoff"], WF_Step_Quantity: ["수량산출", "Quantity"],
WF_Step_Estimation: ["견적 / 문서", "Estimation / Docs"], WF_Step_Estimation: ["설계도서", "Design Docs"],
WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"], WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"],
WF_State_Failed: ["실패", "Failed"], WF_State_Failed: ["실패", "Failed"],
WF_State_Complete: ["완료", "Complete"], WF_State_Complete: ["완료", "Complete"],
@@ -525,7 +525,7 @@ export const ui_locales = {
B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."], B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."],
/* --- B03_FileInput 파일 입력 --- */ /* --- B03_FileInput 파일 입력 --- */
B03_File_Title: ["파일 입력", "File Input"], B03_File_Title: ["파일입력", "File Input"],
B03_File_Subtitle: [ B03_File_Subtitle: [
"필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.", "필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.",
"Upload the required planned route, terrain, and point cloud files.", "Upload the required planned route, terrain, and point cloud files.",
@@ -609,7 +609,7 @@ export const ui_locales = {
], ],
/* --- B04_wf1_Surface 지표면 모델 분석 --- */ /* --- B04_wf1_Surface 지표면 모델 분석 --- */
B04_Surface_Title: ["1차 · 지표면 모델 분석", "Step 1 · Surface Analysis"], B04_Surface_Title: ["전처리", "Preprocess"],
B04_Surface_Field_InputId: ["입력 파일 ID", "Input File ID"], B04_Surface_Field_InputId: ["입력 파일 ID", "Input File ID"],
B04_Surface_Field_InputId_Placeholder: ["예: 1", "e.g. 1"], B04_Surface_Field_InputId_Placeholder: ["예: 1", "e.g. 1"],
B04_Surface_Group_Filters: ["지면 필터 선택", "Ground Filters"], B04_Surface_Group_Filters: ["지면 필터 선택", "Ground Filters"],
@@ -651,6 +651,11 @@ export const ui_locales = {
B04_Surface_Map_Background: ["배경 지도", "Basemap"], B04_Surface_Map_Background: ["배경 지도", "Basemap"],
B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"], B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"],
B04_Surface_Map_None: ["없음", "None"], B04_Surface_Map_None: ["없음", "None"],
B04_Surface_Map_PlannedRoute: ["계획선", "Planned route"],
B04_Surface_Map_PlannedRouteEmpty: [
"B03에서 계획노선 파일을 올리면 표시됩니다.",
"Shown after the planned route file is uploaded in B03.",
],
B04_Surface_Map_Satellite: ["위성", "Satellite"], B04_Surface_Map_Satellite: ["위성", "Satellite"],
B04_Surface_Map_Hybrid: ["하이브리드", "Hybrid"], B04_Surface_Map_Hybrid: ["하이브리드", "Hybrid"],
B04_Surface_Map_White: ["백지도", "White map"], B04_Surface_Map_White: ["백지도", "White map"],
@@ -687,7 +692,7 @@ export const ui_locales = {
B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."], B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."],
/* --- B05_wf2_Route 경로 설계 --- */ /* --- B05_wf2_Route 경로 설계 --- */
B05_Route_Title: ["2차 · 경로 설계", "Step 2 · Route Design"], B05_Route_Title: ["종단설계", "Profile Design"],
B05_Route_Group_Points: ["경로 제어점", "Route Control Points"], B05_Route_Group_Points: ["경로 제어점", "Route Control Points"],
B05_Route_Point_BP: ["시점 (BP)", "Begin (BP)"], B05_Route_Point_BP: ["시점 (BP)", "Begin (BP)"],
B05_Route_Point_EP: ["종점 (EP)", "End (EP)"], B05_Route_Point_EP: ["종점 (EP)", "End (EP)"],
@@ -744,7 +749,7 @@ export const ui_locales = {
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"], B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
/* --- B06_wf3_ProfileCross 종·횡단 생성 --- */ /* --- B06_wf3_ProfileCross 종·횡단 생성 --- */
B06_Profile_Title: ["3차 · 종·횡단 생성", "Step 3 · Profile & Cross-section"], B06_Profile_Title: ["횡단설계", "Cross Design"],
B06_Profile_Group_Route: ["대상 경로", "Target Route"], B06_Profile_Group_Route: ["대상 경로", "Target Route"],
B06_Profile_Field_RouteId: ["경로 ID (routes.id)", "Route ID"], B06_Profile_Field_RouteId: ["경로 ID (routes.id)", "Route ID"],
B06_Profile_Field_Filter: ["지면 필터", "Ground filter"], B06_Profile_Field_Filter: ["지면 필터", "Ground filter"],
@@ -930,7 +935,7 @@ export const ui_locales = {
], ],
/* --- B07_wf4_DesignDetail 상세 설계 --- */ /* --- B07_wf4_DesignDetail 상세 설계 --- */
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"], B07_Design_Title: ["상세설계", "Detail Design"],
B07_Cad_Side_Pending: [ B07_Cad_Side_Pending: [
"사이드 패널은 전달 데이터 확정 후 구성됩니다.", "사이드 패널은 전달 데이터 확정 후 구성됩니다.",
"Side panel will be configured after the upstream data spec is finalized.", "Side panel will be configured after the upstream data spec is finalized.",
@@ -955,10 +960,10 @@ export const ui_locales = {
B07_Info_Station: ["측점", "Station"], B07_Info_Station: ["측점", "Station"],
/* --- B08_wf5_Quantity 수량 산출 --- */ /* --- B08_wf5_Quantity 수량 산출 --- */
B08_Quantity_Title: ["5차 · 수량 산출", "Step 5 · Quantity Takeoff"], B08_Quantity_Title: ["수량산출", "Quantity"],
/* --- B09_wf6_Estimation 견적·문서 --- */ /* --- B09_wf6_Estimation 견적·문서 --- */
B09_Estimation_Title: ["6차 · 견적·문서", "Step 6 · Estimation & Documents"], B09_Estimation_Title: ["설계도서", "Design Docs"],
/* --- B10_Payment 결재 --- */ /* --- B10_Payment 결재 --- */
B10_Payment_Title: ["결재", "Payment"], B10_Payment_Title: ["결재", "Payment"],
+34
View File
@@ -0,0 +1,34 @@
/* 공통 스플리터(드래그 리사이저) 패널 경계에 얹는 얇은 손잡이.
레이아웃을 밀지 않도록 경계 위에 절대배치하고, 평소에는 보이지 않다가
마우스를 얹거나 때만 색이 들어온다. */
.ui-resizer {
position: absolute;
z-index: 1;
background: transparent;
transition: background var(--transition-fast);
touch-action: none;
}
.ui-resizer:hover,
.ui-resizer.is-dragging {
background: var(--color-primary);
}
/* 위아래로 끌어 높이를 조절 — 패널 위쪽 경계에 가로로 눕는다. */
.ui-resizer--vertical {
top: -3px;
right: 0;
left: 0;
height: 6px;
cursor: ns-resize;
}
/* 좌우로 끌어 폭을 조절 — 패널 왼쪽 경계에 세로로 선다. */
.ui-resizer--horizontal {
top: 0;
bottom: 0;
left: -3px;
width: 6px;
cursor: ew-resize;
}
+121
View File
@@ -0,0 +1,121 @@
import "./ui_template_resizer.css";
/* =============================================================================
* ui_template_resizer.ts
* .
*
* `sessionStorage` .
* , (2026-08-01 ).
* .
* ========================================================================== */
export interface PanelResizerOptions {
/** vertical=위아래로 끌어 높이 조절, horizontal=좌우로 끌어 폭 조절 */
axis: "vertical" | "horizontal";
/** 크기를 적용할 패널 */
target: HTMLElement;
/**
* CSS (: `--b05-profile-height`).
* width/height로 `.is-collapsed { height: 0 }`
* . .
*/
cssVar: string;
/** 손잡이가 패널의 위/왼쪽에 있으면 -1 — 끌어올리거나 왼쪽으로 끌면 커진다. */
direction: 1 | -1;
/** 최소 크기(px) */
min: number;
/** 최대 크기(px). 함수면 끌 때마다 다시 계산한다(부모의 70% 같은 상대 상한). */
max: number | (() => number);
/** 세션 저장 키. 생략하면 저장하지 않는다. */
storageKey?: string;
/** 크기가 바뀔 때마다 호출한다(차트 재계산 등). */
onResize?: (size: number) => void;
}
export interface PanelResizer {
/** 패널 경계에 붙일 손잡이. 패널이 `position: relative`여야 제자리에 앉는다. */
root: HTMLElement;
/** 저장된 크기를 다시 적용한다(재렌더 후 복원용). */
restore: () => void;
/** 기본 크기로 되돌린다(저장값도 지운다). */
reset: () => void;
dispose: () => void;
}
export function createPanelResizer(options: PanelResizerOptions): PanelResizer {
const { axis, target, cssVar, direction, min, storageKey, onResize } = options;
const root = document.createElement("div");
root.className = `ui-resizer ui-resizer--${axis}`;
root.setAttribute("role", "separator");
root.setAttribute("aria-orientation", axis === "vertical" ? "horizontal" : "vertical");
const maxOf = (): number => (typeof options.max === "function" ? options.max() : options.max);
const clamp = (size: number): number => {
const max = Math.max(min, maxOf());
return Math.min(max, Math.max(min, size));
};
function apply(size: number, persist: boolean): void {
const next = clamp(size);
target.style.setProperty(cssVar, `${Math.round(next)}px`);
if (persist && storageKey) sessionStorage.setItem(storageKey, String(Math.round(next)));
onResize?.(next);
}
function restore(): void {
if (!storageKey) return;
const saved = Number(sessionStorage.getItem(storageKey));
// 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다.
if (Number.isFinite(saved) && saved > 0) apply(saved, false);
}
function reset(): void {
target.style.removeProperty(cssVar);
if (storageKey) sessionStorage.removeItem(storageKey);
onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth);
}
let startPosition = 0;
let startSize = 0;
root.addEventListener("pointerdown", (event) => {
// 왼쪽 버튼만 — 가운데·오른쪽 버튼은 브라우저 기본 동작에 맡긴다.
if (event.button !== 0) return;
event.preventDefault();
startPosition = axis === "vertical" ? event.clientY : event.clientX;
startSize = axis === "vertical" ? target.clientHeight : target.clientWidth;
root.classList.add("is-dragging");
// 끄는 동안 크기 transition이 걸려 있으면 손이 끄는 만큼 늦게 따라온다.
target.classList.add("is-resizing");
root.setPointerCapture(event.pointerId);
});
root.addEventListener("pointermove", (event) => {
if (!root.hasPointerCapture(event.pointerId)) return;
const current = axis === "vertical" ? event.clientY : event.clientX;
apply(startSize + (current - startPosition) * direction, true);
});
const stop = (event: PointerEvent): void => {
if (!root.hasPointerCapture(event.pointerId)) return;
root.releasePointerCapture(event.pointerId);
root.classList.remove("is-dragging");
target.classList.remove("is-resizing");
};
root.addEventListener("pointerup", stop);
root.addEventListener("pointercancel", stop);
// 더블클릭하면 기본 크기로 되돌린다(잘못 끌었을 때 되돌리는 가장 빠른 길).
root.addEventListener("dblclick", reset);
restore();
return {
root,
restore,
reset,
dispose() {
root.remove();
},
};
}