Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1
This commit is contained in:
@@ -137,7 +137,7 @@ export interface SurfaceConfirmedResponse {
|
||||
z_min: number;
|
||||
z_max: number;
|
||||
} | null;
|
||||
/** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */
|
||||
/** 계획노선(B03 정본)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */
|
||||
route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null;
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
/** 계획노선(B03 업로드 CSV)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */
|
||||
/** 계획노선(B03 정본)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */
|
||||
export interface PlannedRouteResponse {
|
||||
status: string;
|
||||
points: Array<{ x: number; y: number }>;
|
||||
@@ -322,7 +322,7 @@ export async function fetchPlannedRoute(projectId: string): Promise<PlannedRoute
|
||||
}
|
||||
|
||||
/* ── 배수유역 분석 (B04_PreProcess_Router_Watershed.py) ────────────────────
|
||||
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
|
||||
* 관리자 확인용. 계획 노선(B03 정본) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
|
||||
* 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { stationLabel } from "@util/common_util_svg";
|
||||
|
||||
/** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */
|
||||
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
|
||||
@@ -184,3 +185,100 @@ export function drawRidgeRing(
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 계획선 위 측점 눈금·번호 (2026-09-04 사용자 지시)
|
||||
*
|
||||
* 종단·3D와 같은 `측점번호+잔여거리` 표기다. 배율이 낮으면 글자가 붙으므로 3D 라벨과 같은
|
||||
* 단계 규칙으로 솎는다(5칸 → 2칸 → 전부). 관 마커가 있는 측점은 라벨을 계획선 **반대쪽**
|
||||
* 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface StationTickOptions {
|
||||
/** 규칙 측점 간격(m). */
|
||||
intervalM: number;
|
||||
/** 화면 1m 당 픽셀 — 라벨 솎기 단계를 여기서 정한다. */
|
||||
pxPerMeter: number;
|
||||
toScreen: (x: number, y: number) => [number, number];
|
||||
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
|
||||
avoidChainages?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
export function drawStationTicks(
|
||||
context: CanvasRenderingContext2D,
|
||||
points: ReadonlyArray<{ x: number; y: number }>,
|
||||
options: StationTickOptions,
|
||||
): void {
|
||||
if (points.length < 2) return;
|
||||
const interval = options.intervalM > 0 ? options.intervalM : 20;
|
||||
// 라벨 사이가 좁아지면 솎는다 — 화면에서 잰 간격(px)으로 정한다.
|
||||
const gapPx = interval * options.pxPerMeter;
|
||||
const step = gapPx >= 90 ? 1 : gapPx >= 40 ? 2 : 5;
|
||||
const avoid = options.avoidChainages ?? [];
|
||||
|
||||
// 정점 누가거리 — 측점 자리는 정점 사이에 떨어지므로 보간해서 찍는다.
|
||||
const cumulative: number[] = [0];
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
cumulative.push(
|
||||
cumulative[index - 1] +
|
||||
Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y),
|
||||
);
|
||||
}
|
||||
const total = cumulative[cumulative.length - 1];
|
||||
if (total <= 0) return;
|
||||
|
||||
context.save();
|
||||
context.font = "11px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과
|
||||
// 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다).
|
||||
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
||||
let cursor = 1;
|
||||
for (let chainage = 0; chainage <= total; chainage += interval) {
|
||||
const stationNo = Math.round(chainage / interval);
|
||||
if (stationNo % step !== 0) continue;
|
||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||
const back = points[cursor - 1];
|
||||
const front = points[cursor];
|
||||
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
|
||||
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
|
||||
const px = back.x + (front.x - back.x) * ratio;
|
||||
const py = back.y + (front.y - back.y) * ratio;
|
||||
const [sx, sy] = options.toScreen(px, py);
|
||||
const [bx, by] = options.toScreen(back.x, back.y);
|
||||
const [fx, fy] = options.toScreen(front.x, front.y);
|
||||
const dx = fx - bx;
|
||||
const dy = fy - by;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
// 계획선에 직각인 방향 — 눈금과 라벨을 이 방향으로 놓는다.
|
||||
const ux = -dy / length;
|
||||
const uy = dx / length;
|
||||
const nearPipe = avoid.some((pipe) => Math.abs(pipe - chainage) < interval / 2);
|
||||
const side = nearPipe ? -1 : 1;
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(sx - ux * 6, sy - uy * 6);
|
||||
context.lineTo(sx + ux * 6, sy + uy * 6);
|
||||
context.lineWidth = 1.2;
|
||||
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
|
||||
context.stroke();
|
||||
|
||||
const label = stationLabel(chainage, interval);
|
||||
const lx = sx + ux * side * 16;
|
||||
const ly = sy + uy * side * 16;
|
||||
const width = context.measureText(label).width + 6;
|
||||
const half = width / 2;
|
||||
const collides = drawn.some(
|
||||
(item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16,
|
||||
);
|
||||
if (collides) continue;
|
||||
drawn.push({ x: lx, y: ly, half });
|
||||
// 배경을 깔아 등고선 위에서도 읽히게 한다.
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.78)";
|
||||
context.fillRect(lx - half, ly - 8, width, 16);
|
||||
context.fillStyle = "#222222";
|
||||
context.fillText(label, lx, ly);
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -121,6 +121,59 @@ export function computeMapRect(meta: VWorldMeta | null, width: number, height: n
|
||||
* (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */
|
||||
export const ROUTE_VIEW_MARGIN_M = 200;
|
||||
|
||||
/**
|
||||
* 사업지 미터 좌표를 화면 좌표로 옮기는 변환기 (B04 지도·B05 배수유역도 공용).
|
||||
*
|
||||
* 배수유역도에서 쓰던 것을 여기로 옮겼다 — 두 화면이 같은 자리에 측점 눈금을 찍어야 한다
|
||||
* (2026-09-04). `pxPerMeter` 는 라벨 솎기·축척 계산에 쓴다.
|
||||
*/
|
||||
export function createMetricProjector(
|
||||
meta: VWorldMeta,
|
||||
view: ViewState,
|
||||
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
|
||||
const spanX = meta.width_meters || 1;
|
||||
const spanY = meta.height_meters || 1;
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
return {
|
||||
toScreen: (x, y) => [
|
||||
((x - meta.x_min) / spanX) * ax + bx,
|
||||
(1 - (y - meta.y_min) / spanY) * ay + by,
|
||||
],
|
||||
pxPerMeter: ax / spanX,
|
||||
};
|
||||
}
|
||||
|
||||
/** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */
|
||||
export const MAP_STATION_INTERVAL_M = 20;
|
||||
|
||||
/** 최대 확대에서 화면 폭에 들어올 실거리(m) — 규칙 측점 20m 기준 1~2측점
|
||||
* (2026-09-04 사용자 지시). 고정 배율(8배·16배)로는 도엽 크기마다 체감이 달라진다. */
|
||||
export const MAX_ZOOM_VIEW_WIDTH_M = 20;
|
||||
|
||||
/** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */
|
||||
export const ZOOM_SCALE_HARD_CAP = 2000;
|
||||
|
||||
/**
|
||||
* 「화면 폭이 `MAX_ZOOM_VIEW_WIDTH_M` 가 될 때까지」에 해당하는 배율 상한을 구한다.
|
||||
*
|
||||
* 배율 1에서 도엽 실폭(`meta.width_meters`)이 지도 사각형 폭(px)을 채우므로,
|
||||
* 화면 폭(px)에 들어오는 실거리 = width_meters × viewportWidth / (mapRect.width × scale) 이다.
|
||||
* 이것을 20m 로 놓고 scale 을 푼다. 메타가 없으면 종전 고정값으로 되돌아간다.
|
||||
*/
|
||||
export function computeMaxScale(
|
||||
meta: VWorldMeta | null,
|
||||
mapRectWidth: number,
|
||||
viewportWidth: number,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback;
|
||||
const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M);
|
||||
return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale));
|
||||
}
|
||||
|
||||
/** 평면 좌표(m) 범위. */
|
||||
export interface PlanBounds {
|
||||
x_min: number;
|
||||
|
||||
@@ -26,7 +26,10 @@ import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength";
|
||||
import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed";
|
||||
import {
|
||||
computeMapRect,
|
||||
computeMaxScale,
|
||||
computeRouteView,
|
||||
createMetricProjector,
|
||||
MAP_STATION_INTERVAL_M,
|
||||
createNormalizer,
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "./B04_PreProcess_UI_MapRender";
|
||||
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
|
||||
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
|
||||
|
||||
export interface SurfaceMapViewer {
|
||||
@@ -160,8 +164,10 @@ 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) 폴리라인을 배경 지도 위에 겹친다.
|
||||
// 계획선(B03 계획노선 정본) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
|
||||
let routeLayer: PreparedLayer | null = null;
|
||||
// 측점 눈금·번호를 찍기 위한 원본 점 목록 (2026-09-04 사용자 지시).
|
||||
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
|
||||
let showRoute = true;
|
||||
let scale = 1;
|
||||
let offsetX = 0;
|
||||
@@ -314,6 +320,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
function updateImageTransform(): void {
|
||||
backgroundImages.forEach((image) => {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
|
||||
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
|
||||
image.style.imageRendering = scale > 4 ? "pixelated" : "auto";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -416,6 +425,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
context.strokeStyle = routeLineColor();
|
||||
drawPreparedLayer(context, routeLayer, view, "dot");
|
||||
}
|
||||
// 측점 눈금·번호 — 계획선 위, 유역 오버레이 아래. B05 배수유역도와 같은 규칙이다.
|
||||
if (showRoute && meta && routePoints.length > 1) {
|
||||
const projector = createMetricProjector(meta, view);
|
||||
drawStationTicks(context, routePoints, {
|
||||
intervalM: MAP_STATION_INTERVAL_M,
|
||||
pxPerMeter: projector.pxPerMeter,
|
||||
toScreen: projector.toScreen,
|
||||
});
|
||||
}
|
||||
// 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다.
|
||||
flowStrength.draw(context, normalizer, view);
|
||||
// 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다.
|
||||
@@ -443,6 +461,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
meta = null;
|
||||
preparedLayers.clear();
|
||||
routeLayer = null;
|
||||
routePoints = [];
|
||||
resetView();
|
||||
status.textContent = L("B04_Surface_Map_Loading");
|
||||
showProgress(0, L("B04_Surface_Map_Loading"));
|
||||
@@ -480,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
normalizer = createNormalizer(nextMeta);
|
||||
routeLayer =
|
||||
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
|
||||
routePoints = planned.points;
|
||||
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
|
||||
flowStrength.setRoute(planned.points, nextMeta);
|
||||
// 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다.
|
||||
@@ -518,7 +538,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
event.preventDefault();
|
||||
const prevScale = scale;
|
||||
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다.
|
||||
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
|
||||
// 상한은 「화면 폭 20m」로 계산한다 — 도엽 크기가 달라도 체감이 같다(2026-09-04 사용자 지시).
|
||||
const wheelRect = viewport.getBoundingClientRect();
|
||||
const wheelWidth = Math.max(1, Math.floor(wheelRect.width));
|
||||
const maxScale = computeMaxScale(
|
||||
meta,
|
||||
computeMapRect(meta, wheelWidth, Math.max(1, Math.floor(wheelRect.height))).width,
|
||||
wheelWidth,
|
||||
8,
|
||||
);
|
||||
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
|
||||
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
|
||||
// screen = center + (base - center)·scale + offset 이므로,
|
||||
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
|
||||
|
||||
Reference in New Issue
Block a user