auto: 2026-07-28 19:23 (EOMSANGDON-HOME)
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user