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:
@@ -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) ────────────────────
|
||||
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
|
||||
* 결과를 영구저장소에 남긴다. 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""
|
||||
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)";
|
||||
|
||||
/**
|
||||
* 계획선(노선) 표기 색 — 주황. B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다.
|
||||
* 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다.
|
||||
*/
|
||||
export const ROUTE_LINE_COLOR = "#f97316";
|
||||
/** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */
|
||||
export const ROUTE_LINE_WIDTH = 2.4;
|
||||
|
||||
/**
|
||||
* 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열.
|
||||
* weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
fetchGisGeoJson,
|
||||
fetchPlannedRoute,
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
type VWorldMeta,
|
||||
@@ -16,6 +17,9 @@ import {
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
prepareLayer,
|
||||
prepareMetricPolyline,
|
||||
ROUTE_LINE_COLOR,
|
||||
ROUTE_LINE_WIDTH,
|
||||
type GeoJsonCollection,
|
||||
type MapRect,
|
||||
type Normalizer,
|
||||
@@ -143,11 +147,14 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
empty.textContent = L("B04_Surface_Map_Empty");
|
||||
const status = document.createElement("span");
|
||||
status.className = "b04-map__status";
|
||||
// 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다.
|
||||
// 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다(2026-08-01 사용자 지시).
|
||||
// 지도 위 좌상단 묶음 — 배수유역 정보 자리다(2026-08-01 사용자 지시).
|
||||
// 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다.
|
||||
const statusStack = document.createElement("div");
|
||||
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");
|
||||
scaleBar.className = "b04-map__scale";
|
||||
const scaleText = document.createElement("span");
|
||||
@@ -160,6 +167,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
canvas,
|
||||
empty,
|
||||
statusStack,
|
||||
statusCorner,
|
||||
scaleBar,
|
||||
progress.root,
|
||||
);
|
||||
@@ -184,6 +192,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
);
|
||||
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer]));
|
||||
let showContourLabels = CONTOUR_LABEL_DEFAULT_ON;
|
||||
// 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
|
||||
let routeLayer: PreparedLayer | null = null;
|
||||
let showRoute = true;
|
||||
let scale = 1;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
@@ -244,6 +255,22 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
도엽_성절토: L("B04_Surface_Map_SheetCutFill"),
|
||||
도엽_옹벽석축: 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) => {
|
||||
gisButtons.append(
|
||||
makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]),
|
||||
@@ -380,6 +407,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
}
|
||||
// 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다.
|
||||
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();
|
||||
drawScaleBar(mapRect);
|
||||
}
|
||||
@@ -400,11 +433,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
backgroundImages.forEach((image) => image.removeAttribute("src"));
|
||||
meta = null;
|
||||
preparedLayers.clear();
|
||||
routeLayer = null;
|
||||
resetView();
|
||||
status.textContent = L("B04_Surface_Map_Loading");
|
||||
showProgress(0, L("B04_Surface_Map_Loading"));
|
||||
try {
|
||||
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
|
||||
// 계획선은 지도 메타와 같은 좌표계라 함께 받아 둔다. 실패해도 지도는 그대로 그린다.
|
||||
const [nextMeta, planned] = await Promise.all([
|
||||
fetchVWorldMeta(projectId, "satellite"),
|
||||
fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })),
|
||||
]);
|
||||
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
|
||||
let done = 0;
|
||||
const loadedLayers = await Promise.all(
|
||||
@@ -431,6 +469,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
meta = nextMeta;
|
||||
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
|
||||
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;
|
||||
loadedLayers.forEach(([layer, data]) => {
|
||||
if (!data) return;
|
||||
@@ -479,7 +522,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
|
||||
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
|
||||
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 };
|
||||
viewport.style.cursor = "grabbing";
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
});
|
||||
viewport.addEventListener("pointermove", (event) => {
|
||||
@@ -490,6 +538,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
});
|
||||
const stopDragging = (): void => {
|
||||
dragStart = null;
|
||||
viewport.style.removeProperty("cursor");
|
||||
};
|
||||
viewport.addEventListener("pointerup", stopDragging);
|
||||
viewport.addEventListener("pointercancel", stopDragging);
|
||||
|
||||
@@ -574,16 +574,15 @@
|
||||
height: 560px;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
/* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만
|
||||
JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.b04-map__viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.b04-map__image,
|
||||
.b04-map__canvas {
|
||||
position: absolute;
|
||||
@@ -612,7 +611,7 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다.
|
||||
/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시).
|
||||
지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */
|
||||
.b04-map__status-stack {
|
||||
position: absolute;
|
||||
@@ -627,6 +626,21 @@
|
||||
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)와 칸을 나눠 쓰면
|
||||
나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다.
|
||||
배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 {
|
||||
fetchVWorldMeta,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
drawUpstreamLines,
|
||||
prepareLayer,
|
||||
prepareMetricPolyline,
|
||||
ROUTE_LINE_COLOR,
|
||||
ROUTE_LINE_WIDTH,
|
||||
type GeoJsonCollection,
|
||||
type MapRect,
|
||||
type Normalizer,
|
||||
@@ -57,9 +60,18 @@ const LAYER_LABELS: Record<DrainageLayer, string> = {
|
||||
/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */
|
||||
const ARROW_TOGGLE_COLOR = "#7c3aed";
|
||||
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";
|
||||
/** 드래그로 조절한 패널 폭(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 = [
|
||||
@@ -163,7 +175,18 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
const basinList = document.createElement("div");
|
||||
basinList.className = "b05-drainage__basins";
|
||||
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 meta: VWorldMeta | null = null;
|
||||
@@ -229,6 +252,17 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
return button;
|
||||
}
|
||||
|
||||
// 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이
|
||||
// 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다.
|
||||
addLayerToggle(
|
||||
"위성사진",
|
||||
SATELLITE_TOGGLE_COLOR,
|
||||
true,
|
||||
(next) => {
|
||||
backgroundImage.hidden = !next;
|
||||
},
|
||||
"배경 위성사진을 보이거나 숨깁니다.",
|
||||
);
|
||||
DRAINAGE_LAYERS.forEach((layer) => {
|
||||
addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => {
|
||||
if (next) activeLayers.add(layer);
|
||||
@@ -314,7 +348,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
drawUpstreamLines(context, upstreamLines, normalizer, view);
|
||||
}
|
||||
if (routeLayer) {
|
||||
context.lineWidth = 2.4;
|
||||
context.lineWidth = ROUTE_LINE_WIDTH;
|
||||
context.strokeStyle = ROUTE_COLOR;
|
||||
drawPreparedLayer(context, routeLayer, view, "dot");
|
||||
}
|
||||
@@ -606,7 +640,12 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다
|
||||
// (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
|
||||
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
|
||||
if (event.pointerType === "mouse" && event.button !== 1) return;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
|
||||
viewport.style.cursor = "grabbing";
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
});
|
||||
viewport.addEventListener("pointermove", (event) => {
|
||||
@@ -634,6 +673,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
boundaryEditor.handleUp();
|
||||
pipeEditor.handleUp();
|
||||
dragStart = null;
|
||||
viewport.style.removeProperty("cursor");
|
||||
};
|
||||
viewport.addEventListener("pointerup", stopDragging);
|
||||
viewport.addEventListener("pointercancel", stopDragging);
|
||||
@@ -676,6 +716,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
frameHandle = 0;
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
widthResizer.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { purgeOtherProjects } from "../A00_Common/b_asset_cache";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
@@ -50,6 +51,10 @@ import "./B05_wf2_Route_UI_Style.css";
|
||||
type GradeClass = RoutePanelValues["gradeClass"];
|
||||
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 };
|
||||
|
||||
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.append(viewer.root, profilePanel.root);
|
||||
const layout = createWorkflowLayout({
|
||||
title: "종단 설계",
|
||||
title: L("B05_Route_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 2,
|
||||
leftPanel: panel.root,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
|
||||
import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
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";
|
||||
|
||||
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이 테이블). */
|
||||
const CHART_HEIGHT_RATIO = 0.4;
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
|
||||
const TABLE_ROW_COUNT = 12;
|
||||
|
||||
@@ -284,7 +294,18 @@ export function createRouteProfilePanel(
|
||||
content.className = "b05-route-profile__content";
|
||||
const drainagePanel = createDrainagePanel();
|
||||
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);
|
||||
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
@@ -301,6 +322,8 @@ export function createRouteProfilePanel(
|
||||
let redrawPending = false;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
/** 처음 그릴 때 잡힌 도면 테이블 높이(px). 패널을 끌어도 이 값을 지킨다. */
|
||||
let fixedTableHeight = Number(sessionStorage.getItem(TABLE_HEIGHT_KEY)) || 0;
|
||||
|
||||
function renderBalance(): void {
|
||||
balanceBar.replaceChildren();
|
||||
@@ -430,8 +453,14 @@ export function createRouteProfilePanel(
|
||||
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
|
||||
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 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
|
||||
? Math.max(MIN_CHART_HEIGHT, Math.round(available * CHART_HEIGHT_RATIO))
|
||||
? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
|
||||
: available;
|
||||
const tableHeight = available - chartHeight;
|
||||
|
||||
@@ -655,6 +684,7 @@ export function createRouteProfilePanel(
|
||||
dispose() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeObserver.disconnect();
|
||||
heightResizer.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
left: 0;
|
||||
display: flex;
|
||||
height: 60vh;
|
||||
height: 60dvh;
|
||||
/* 기본은 화면의 60%. 위쪽 경계를 끌면 --b05-profile-height(px)가 대신 들어온다. */
|
||||
height: var(--b05-profile-height, 60dvh);
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
@@ -56,6 +57,18 @@
|
||||
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 {
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
@@ -835,9 +848,11 @@
|
||||
.b05-drainage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 38%;
|
||||
/* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다.
|
||||
상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */
|
||||
width: var(--b05-drainage-width, 38%);
|
||||
min-width: 320px;
|
||||
max-width: 640px;
|
||||
max-width: 70%;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -915,14 +930,13 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
cursor: grab;
|
||||
/* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가
|
||||
grabbing으로 바꾼다(2026-08-01 사용자 지시). */
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b05-drainage__viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.b05-drainage__image,
|
||||
.b05-drainage__canvas {
|
||||
position: absolute;
|
||||
|
||||
@@ -99,12 +99,12 @@ export const ui_locales = {
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 워크플로우 공통 (B04~B09 상단 진행 단계 라벨)
|
||||
* ------------------------------------------------------------------------ */
|
||||
WF_Step_Surface: ["지표면 분석", "Surface Analysis"],
|
||||
WF_Step_Route: ["경로 설계", "Route Design"],
|
||||
WF_Step_ProfileCross: ["종횡단 생성", "Profile & Cross-section"],
|
||||
WF_Step_DesignDetail: ["상세 설계", "Detailed Design"],
|
||||
WF_Step_Quantity: ["수량 산출", "Quantity Takeoff"],
|
||||
WF_Step_Estimation: ["견적 / 문서", "Estimation / Docs"],
|
||||
WF_Step_Surface: ["전처리", "Preprocess"],
|
||||
WF_Step_Route: ["종단설계", "Profile Design"],
|
||||
WF_Step_ProfileCross: ["횡단설계", "Cross Design"],
|
||||
WF_Step_DesignDetail: ["상세설계", "Detail Design"],
|
||||
WF_Step_Quantity: ["수량산출", "Quantity"],
|
||||
WF_Step_Estimation: ["설계도서", "Design Docs"],
|
||||
WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"],
|
||||
WF_State_Failed: ["실패", "Failed"],
|
||||
WF_State_Complete: ["완료", "Complete"],
|
||||
@@ -525,7 +525,7 @@ export const ui_locales = {
|
||||
B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."],
|
||||
|
||||
/* --- B03_FileInput 파일 입력 --- */
|
||||
B03_File_Title: ["파일 입력", "File Input"],
|
||||
B03_File_Title: ["파일입력", "File Input"],
|
||||
B03_File_Subtitle: [
|
||||
"필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.",
|
||||
"Upload the required planned route, terrain, and point cloud files.",
|
||||
@@ -609,7 +609,7 @@ export const ui_locales = {
|
||||
],
|
||||
|
||||
/* --- 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_Placeholder: ["예: 1", "e.g. 1"],
|
||||
B04_Surface_Group_Filters: ["지면 필터 선택", "Ground Filters"],
|
||||
@@ -651,6 +651,11 @@ export const ui_locales = {
|
||||
B04_Surface_Map_Background: ["배경 지도", "Basemap"],
|
||||
B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"],
|
||||
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_Hybrid: ["하이브리드", "Hybrid"],
|
||||
B04_Surface_Map_White: ["백지도", "White map"],
|
||||
@@ -687,7 +692,7 @@ export const ui_locales = {
|
||||
B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."],
|
||||
|
||||
/* --- 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_Point_BP: ["시점 (BP)", "Begin (BP)"],
|
||||
B05_Route_Point_EP: ["종점 (EP)", "End (EP)"],
|
||||
@@ -744,7 +749,7 @@ export const ui_locales = {
|
||||
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
|
||||
|
||||
/* --- 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_Field_RouteId: ["경로 ID (routes.id)", "Route ID"],
|
||||
B06_Profile_Field_Filter: ["지면 필터", "Ground filter"],
|
||||
@@ -930,7 +935,7 @@ export const ui_locales = {
|
||||
],
|
||||
|
||||
/* --- B07_wf4_DesignDetail 상세 설계 --- */
|
||||
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],
|
||||
B07_Design_Title: ["상세설계", "Detail Design"],
|
||||
B07_Cad_Side_Pending: [
|
||||
"사이드 패널은 전달 데이터 확정 후 구성됩니다.",
|
||||
"Side panel will be configured after the upstream data spec is finalized.",
|
||||
@@ -955,10 +960,10 @@ export const ui_locales = {
|
||||
B07_Info_Station: ["측점", "Station"],
|
||||
|
||||
/* --- B08_wf5_Quantity 수량 산출 --- */
|
||||
B08_Quantity_Title: ["5차 · 수량 산출", "Step 5 · Quantity Takeoff"],
|
||||
B08_Quantity_Title: ["수량산출", "Quantity"],
|
||||
|
||||
/* --- B09_wf6_Estimation 견적·문서 --- */
|
||||
B09_Estimation_Title: ["6차 · 견적·문서", "Step 6 · Estimation & Documents"],
|
||||
B09_Estimation_Title: ["설계도서", "Design Docs"],
|
||||
|
||||
/* --- B10_Payment 결재 --- */
|
||||
B10_Payment_Title: ["결재", "Payment"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user