From 7a7cd217b6214034103d6316f11074f2a7903a06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 19:23:57 +0900 Subject: [PATCH] auto: 2026-07-28 19:23 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 44 +++ .../B05_wf2_Route_UI_Drainage_Panel.ts | 310 ++++++++++++++++++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 1 + .../B05_wf2_Route_UI_Profile_Panel.ts | 14 +- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 123 +++++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 4fbdf35e..061e3f85 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -280,6 +280,50 @@ export function prepareLayer( return { features }; } +/** + * 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다. + * meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한 + * 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다. + */ +export function prepareMetricPolyline( + points: ReadonlyArray<{ x: number; y: number }>, + meta: VWorldMeta, +): PreparedLayer { + if (points.length < 2) return { features: [] }; + const widthMeters = meta.width_meters || 1; + const heightMeters = meta.height_meters || 1; + const coords = new Float64Array(points.length * 2); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + points.forEach((point, index) => { + const nx = (point.x - meta.x_min) / widthMeters; + const ny = 1 - (point.y - meta.y_min) / heightMeters; + coords[index * 2] = nx; + coords[index * 2 + 1] = ny; + if (nx < minX) minX = nx; + if (nx > maxX) maxX = nx; + if (ny < minY) minY = ny; + if (ny > maxY) maxY = ny; + }); + return { + features: [ + { + kind: "line", + parts: [{ coords, closed: false, weights: null }], + minX, + minY, + maxX, + maxY, + labelAnchorX: 0, + labelAnchorY: 0, + labelText: null, + }, + ], + }; +} + /** * 프레임당 1회 계산하는 어파인 계수. * base = mapRect.x + norm * mapRect.width diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts new file mode 100644 index 00000000..5f3ad08d --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -0,0 +1,310 @@ +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { + fetchGisGeoJson, + fetchVWorldMeta, + getVWorldMapUrl, + type VWorldMeta, +} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import { + computeMapRect, + createNormalizer, + drawPreparedLayer, + prepareLayer, + prepareMetricPolyline, + type GeoJsonCollection, + type MapRect, + type PreparedLayer, + type ViewState, +} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; +import type { RoutePoint } from "./B05_wf2_Route_Api_Fetch"; + +// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. +// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). +// 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해 +// 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다. + +/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). */ +const DRAINAGE_LAYERS = ["도엽_등고선", "도엽_하천중심선", "도엽_표고점"] as const; +type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; + +const LAYER_COLORS: Record = { + 도엽_등고선: "#a5b4fc", + 도엽_하천중심선: "#2563eb", + 도엽_표고점: "#f9a8d4", +}; + +const LAYER_LABELS: Record = { + 도엽_등고선: "등고선", + 도엽_하천중심선: "세류", + 도엽_표고점: "표고점", +}; + +const ROUTE_COLOR = "#f97316"; +const COLLAPSED_KEY = "b05-route-drainage-collapsed"; + +export interface DrainagePanel { + root: HTMLElement; + /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ + load: (projectId: string) => void; + /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ + setRoute: (points: ReadonlyArray) => void; + dispose: () => void; +} + +export function createDrainagePanel(): DrainagePanel { + const root = document.createElement("aside"); + root.className = "b05-drainage"; + const panelHandle = createWorkflowPanelHandle("side"); + + const header = document.createElement("div"); + header.className = "b05-drainage__header"; + const title = document.createElement("h3"); + title.textContent = "배수유역도"; + const layerButtons = document.createElement("div"); + layerButtons.className = "b05-drainage__layers"; + header.append(title, layerButtons); + + const viewport = document.createElement("div"); + viewport.className = "b05-drainage__viewport"; + const backgroundImage = document.createElement("img"); + backgroundImage.className = "b05-drainage__image"; + backgroundImage.alt = "배경 위성지도"; + backgroundImage.draggable = false; + const canvas = document.createElement("canvas"); + canvas.className = "b05-drainage__canvas"; + const status = document.createElement("span"); + status.className = "b05-drainage__status"; + status.textContent = "노선을 확정하면 배수유역 배경도가 표시됩니다."; + viewport.append(backgroundImage, canvas, status); + root.append(panelHandle.root, header, viewport); + + let projectId: string | null = null; + let meta: VWorldMeta | null = null; + const preparedLayers = new Map(); + const activeLayers = new Set(DRAINAGE_LAYERS); + let routeLayer: PreparedLayer | null = null; + let routePoints: ReadonlyArray = []; + let scale = 1; + let offsetX = 0; + let offsetY = 0; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + let frameHandle = 0; + let loadSequence = 0; + let canvasWidth = 0; + let canvasHeight = 0; + let canvasDpr = 0; + + DRAINAGE_LAYERS.forEach((layer) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-drainage__layer-button is-active"; + button.textContent = LAYER_LABELS[layer]; + button.style.setProperty("--b05-layer-color", LAYER_COLORS[layer]); + button.setAttribute("aria-pressed", "true"); + button.addEventListener("click", () => { + if (activeLayers.has(layer)) activeLayers.delete(layer); + else activeLayers.add(layer); + const isActive = activeLayers.has(layer); + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + scheduleDraw(); + }); + layerButtons.append(button); + }); + + function updateImageTransform(): void { + backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + } + + function draw(): void { + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + const dpr = window.devicePixelRatio || 1; + if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { + canvasWidth = width; + canvasHeight = height; + canvasDpr = dpr; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } + const context = canvas.getContext("2d"); + if (!context) return; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); + const mapRect: MapRect = computeMapRect(meta, width, height); + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; + // 등고선을 가장 아래에 얇게 깔고 세류·표고점을 그 위에, 노선을 맨 위에 둔다. + DRAINAGE_LAYERS.forEach((layer) => { + if (!activeLayers.has(layer)) return; + const prepared = preparedLayers.get(layer); + if (!prepared) return; + context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; + context.strokeStyle = LAYER_COLORS[layer]; + drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot"); + }); + if (routeLayer) { + context.lineWidth = 2.4; + context.strokeStyle = ROUTE_COLOR; + drawPreparedLayer(context, routeLayer, view, "dot"); + } + updateImageTransform(); + } + + function scheduleDraw(): void { + if (frameHandle) return; + frameHandle = window.requestAnimationFrame(() => { + frameHandle = 0; + draw(); + }); + } + + /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ + function fitToRoute(): void { + scale = 1; + offsetX = 0; + offsetY = 0; + if (!meta || routePoints.length < 2) return; + const rect = viewport.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + const mapRect = computeMapRect(meta, width, height); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + routePoints.forEach((point) => { + if (point.x < minX) minX = point.x; + if (point.x > maxX) maxX = point.x; + if (point.y < minY) minY = point.y; + if (point.y > maxY) maxY = point.y; + }); + const routeWidth = Math.max(maxX - minX, 1); + const routeHeight = Math.max(maxY - minY, 1); + scale = Math.min(meta.width_meters / routeWidth, meta.height_meters / routeHeight) * 0.85; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + offsetX = -(baseX - width / 2) * scale; + offsetY = -(baseY - height / 2) * scale; + } + + async function loadLayers(): Promise { + if (!projectId) return; + const activeProjectId = projectId; + const sequence = ++loadSequence; + meta = null; + preparedLayers.clear(); + routeLayer = null; + backgroundImage.removeAttribute("src"); + status.hidden = false; + status.textContent = "배경도를 불러오는 중…"; + try { + const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite"); + const loaded = await Promise.all( + DRAINAGE_LAYERS.map(async (layer) => { + try { + const data = (await fetchGisGeoJson(activeProjectId, layer)) as GeoJsonCollection; + return [layer, data] as const; + } catch { + return [layer, null] as const; + } + }), + ); + if (sequence !== loadSequence) return; + meta = nextMeta; + const normalizer = createNormalizer(nextMeta); + let featureCount = 0; + loaded.forEach(([layer, data]) => { + if (!data) return; + featureCount += data.features?.length ?? 0; + preparedLayers.set(layer, prepareLayer(data, normalizer)); + }); + backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`; + if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); + status.hidden = featureCount > 0; + if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; + fitToRoute(); + scheduleDraw(); + } catch (error) { + if (sequence !== loadSequence) return; + status.hidden = false; + status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다."; + } + } + + viewport.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const prevScale = scale; + scale = Math.min(16, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); + // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). + const ratio = scale / prevScale; + const rect = viewport.getBoundingClientRect(); + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + offsetX = cursorX * (1 - ratio) + offsetX * ratio; + offsetY = cursorY * (1 - ratio) + offsetY * ratio; + scheduleDraw(); + }, + { passive: false }, + ); + viewport.addEventListener("pointerdown", (event) => { + // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. + if (event.button === 1) event.preventDefault(); + dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; + viewport.setPointerCapture(event.pointerId); + }); + viewport.addEventListener("pointermove", (event) => { + if (!dragStart) return; + offsetX = dragStart.offsetX + event.clientX - dragStart.x; + offsetY = dragStart.offsetY + event.clientY - dragStart.y; + scheduleDraw(); + }); + const stopDragging = (): void => { + dragStart = null; + }; + viewport.addEventListener("pointerup", stopDragging); + viewport.addEventListener("pointercancel", stopDragging); + + const resizeObserver = new ResizeObserver(scheduleDraw); + resizeObserver.observe(viewport); + + function setCollapsed(collapsed: boolean): void { + root.classList.toggle("is-collapsed", collapsed); + panelHandle.setOpen(!collapsed); + sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + if (!collapsed) scheduleDraw(); + } + panelHandle.root.addEventListener("click", () => + setCollapsed(!root.classList.contains("is-collapsed")), + ); + setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) !== "false"); + + return { + root, + load(nextProjectId: string) { + if (projectId === nextProjectId && meta) return; + projectId = nextProjectId; + void loadLayers(); + }, + setRoute(points) { + routePoints = points; + routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; + if (routeLayer) fitToRoute(); + scheduleDraw(); + }, + dispose() { + loadSequence += 1; + if (frameHandle) { + window.cancelAnimationFrame(frameHandle); + frameHandle = 0; + } + resizeObserver.disconnect(); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 61b2d44c..3f1ed77b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -447,6 +447,7 @@ export async function renderB05Route(root: HTMLElement): Promise { routeReady = Boolean(next.route && next.route_points.length > 1); stale = false; panel.setStale(false); + profilePanel.setRoutePolyline(next.route_points ?? []); if (next.route) { const stored = next.route.algorithm_params ?? {}; viewer.markers.renderRoute( diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index d4fd64fb..ac71c7b2 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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 { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch"; import type { @@ -269,7 +270,14 @@ export function createRouteProfilePanel( empty.className = "b05-route-profile__empty"; empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; body.append(empty); - root.append(panelHandle.root, balanceBar, body); + // 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성. + // 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). + const content = document.createElement("div"); + content.className = "b05-route-profile__content"; + const drainagePanel = createDrainagePanel(); + content.append(body, drainagePanel.root); + root.append(panelHandle.root, balanceBar, content); + drainagePanel.load(projectId); let detail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; @@ -583,6 +591,10 @@ export function createRouteProfilePanel( selectedStationId = stationId; draw(); }, + /** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */ + setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) { + drainagePanel.setRoute(points); + }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { irregularStations = stations; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index f475fa62..d9d16d5b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -61,9 +61,18 @@ min-height: 0; } +/* 종단면 본문 + 우측 배수유역 패널의 2단 가로 배치. */ +.b05-route-profile__content { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + .b05-route-profile__body { box-sizing: border-box; flex: 1 1 auto; + min-width: 0; min-height: 0; overflow-x: scroll; overflow-y: hidden; @@ -807,3 +816,117 @@ color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); opacity: 0.95; } + +/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ +.b05-drainage { + position: relative; + display: flex; + width: 38%; + min-width: 320px; + max-width: 640px; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--color-border); + background: var(--color-surface-raised); + transition: width var(--transition-fast); +} + +/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ +.b05-drainage.is-collapsed { + width: 0; + min-width: 0; +} + +.b05-drainage.is-collapsed .b05-drainage__header, +.b05-drainage.is-collapsed .b05-drainage__viewport { + display: none; +} + +/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. */ +.b05-drainage .ui-workflow-overlay__handle--side { + z-index: 2; + right: auto; + left: calc(-1 * var(--spacing-24)); + border-right: 0; + border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); +} + +.b05-drainage__header { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); + border-bottom: 1px solid var(--color-border); +} + +.b05-drainage__header h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.b05-drainage__layers { + display: flex; + gap: var(--spacing-4); +} + +.b05-drainage__layer-button { + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--text-caption); + cursor: pointer; +} + +/* 켜진 레이어는 그 레이어의 선 색을 그대로 띠 색으로 써서 지도와 바로 대조된다. */ +.b05-drainage__layer-button.is-active { + border-color: var(--b05-layer-color, var(--color-border)); + box-shadow: inset 3px 0 0 var(--b05-layer-color, transparent); + color: var(--color-text-body); +} + +.b05-drainage__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: var(--color-surface); + cursor: grab; + touch-action: none; +} + +.b05-drainage__viewport:active { + cursor: grabbing; +} + +.b05-drainage__image, +.b05-drainage__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b05-drainage__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b05-drainage__canvas { + pointer-events: none; +} + +.b05-drainage__status { + position: absolute; + inset: var(--spacing-8) var(--spacing-8) auto var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); + pointer-events: none; +}