auto: 2026-07-28 19:23 (EOMSANGDON-HOME)

This commit is contained in:
2026-07-28 19:23:57 +09:00
parent 294151f862
commit 7a7cd217b6
5 changed files with 491 additions and 1 deletions
@@ -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
@@ -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<DrainageLayer, string> = {
_등고선: "#a5b4fc",
_하천중심선: "#2563eb",
_표고점: "#f9a8d4",
};
const LAYER_LABELS: Record<DrainageLayer, string> = {
_등고선: "등고선",
_하천중심선: "세류",
_표고점: "표고점",
};
const ROUTE_COLOR = "#f97316";
const COLLAPSED_KEY = "b05-route-drainage-collapsed";
export interface DrainagePanel {
root: HTMLElement;
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
load: (projectId: string) => void;
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
setRoute: (points: ReadonlyArray<RoutePoint>) => 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<DrainageLayer, PreparedLayer>();
const activeLayers = new Set<DrainageLayer>(DRAINAGE_LAYERS);
let routeLayer: PreparedLayer | null = null;
let routePoints: ReadonlyArray<RoutePoint> = [];
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<void> {
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();
},
};
}
+1
View File
@@ -447,6 +447,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
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(
@@ -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;
+123
View File
@@ -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;
}