Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1

This commit is contained in:
2026-09-04 19:14:09 +09:00
35 changed files with 727 additions and 92 deletions
+3 -3
View File
@@ -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;
+31 -2
View File
@@ -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).