관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 B04 2D 지도에 추가한다.
경계는 B04 전처리 격자(03_road_routing.npz)의 road_slot — 1m 셀마다 물이 도달하는
도로 셀 — 을 담당 관으로 라벨링해 그 경계로 잡는다. 종단 Z는 경계를 긋지 않고
"도로 셀이 어느 관으로 흐르는가"만 정한다.
공용 승격 (B04 관리자 화면과 B05 사용자 화면이 같은 결과를 내야 함)
- common_util_drainage_detail.py: 관 보충(9)·세부유역 분할(10) 알고리즘
- common_util_drainage_context.py: 노선·종단 Z·좌표계 입력 준비
- common_util_drainage_pipes.py: 관 지점 정본 저장소(edits/pipe_points.json)
- common_util_route_profile.py: 종단 Z 해석기(계획고 > 경로 정점 > 지표면 > CSV)
- common_util_surface_sampler.py: B05 종횡단 sampler 이동
- B05 _prepare()의 노선 소스를 원청 계획노선 CSV로 정정(B04 격자와 누가거리 정합)
B04 신규 API
- GET /{project_id}/drainage/pipe-points 저장분 조회(없으면 자동 생성)
- POST /{project_id}/drainage/detail-basins 편집 중 목록으로 재분할(저장 안 함)
- PUT /{project_id}/drainage/pipe-points 모델 확정 시 관 지점·세부유역 커밋
B04 화면
- 관 마커 기본/자동/수동 색 구분, 계획선 스냅 드래그 이동
- 계획선 우클릭 "관 매설 추가" / 마커 우클릭 "관 매설 삭제"
- 표시 토글 2그룹(관 매설 / 세부 유역)을 유입 집중점과 분리
- "상세유역 분석" 버튼을 눌렀을 때만 재계산
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* 도로 유입 흐름 강도 오버레이 (B04 2D 지도)
|
|
*
|
|
* 계획선 위에 **1m 구간마다** 그 구간으로 모이는 상류 면적을 색으로 칠하고, 물이 특히
|
|
* 많이 모이는 자리(유입 집중점)를 마커로 찍는다. 마커를 고르면 그 지점으로 들어오는
|
|
* 셀들의 외곽선을 백엔드에서 받아 겹쳐 그린다.
|
|
*
|
|
* 계산은 전부 백엔드가 원본 격자(1m, 방향 지정 원본)에서 끝낸 값이다 — 여기서는 그리기만
|
|
* 한다. 색 단계는 로그 스케일이다. 계곡 한 지점이 사면보다 수백 배 크기 때문에 선형으로
|
|
* 칠하면 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(2026-08-01 사용자 지시).
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import { fetchRoadInflow, type VWorldMeta } from "./B04_wf1_Surface_Api_Fetch";
|
|
import {
|
|
haloColor,
|
|
lonLatToScreen,
|
|
metricToScreen,
|
|
type Normalizer,
|
|
type ViewState,
|
|
} from "./B04_wf1_Surface_UI_MapRender";
|
|
import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
/** 강도 색띠 — 파랑(적음) → 빨강(많음). 정의처는 `ui_template_theme.css`. */
|
|
const RAMP_TOKENS: ReadonlyArray<[name: string, fallback: string]> = [
|
|
["--map-flow-ramp-0", "#2563eb"],
|
|
["--map-flow-ramp-1", "#06b6d4"],
|
|
["--map-flow-ramp-2", "#22c55e"],
|
|
["--map-flow-ramp-3", "#eab308"],
|
|
["--map-flow-ramp-4", "#f97316"],
|
|
["--map-flow-ramp-5", "#dc2626"],
|
|
];
|
|
|
|
/** 강도 선 굵기(px). 계획선(2.4)보다 굵어야 그 위에 얹힌 것으로 읽힌다. */
|
|
const STRENGTH_LINE_WIDTH = 5;
|
|
/** 마커 반지름(px) — 강도 최소~최대. 작게 둬서 지도를 가리지 않는다(사용자 지시). */
|
|
const MARKER_MIN_RADIUS = 4;
|
|
const MARKER_MAX_RADIUS = 10;
|
|
/** 마커를 눌렀다고 볼 여유(px). */
|
|
const MARKER_HIT_SLACK = 4;
|
|
|
|
export type { RoutePoint };
|
|
|
|
export interface FlowStrengthOverlay {
|
|
/** 지도 헤더 버튼 줄에 넣을 토글. */
|
|
button: HTMLButtonElement;
|
|
visible: () => boolean;
|
|
/** 선택 요약 문구(없으면 빈 문자열). */
|
|
status: () => string;
|
|
/** 분석 결과를 받는다. 프로젝트가 바뀌면 `clear()`를 먼저 부른다. */
|
|
setData: (
|
|
profile: ReadonlyArray<readonly [number, number]>,
|
|
hotspots: ReadonlyArray<readonly [number, number, number, number]>,
|
|
) => void;
|
|
/** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */
|
|
setRoute: (points: ReadonlyArray<RoutePoint>, meta: VWorldMeta | null) => void;
|
|
setProject: (projectId: string) => void;
|
|
clear: () => void;
|
|
/** 마커를 눌렀으면 true. 누른 것이 없으면 선택을 풀고 false. */
|
|
handleClick: (normalizer: Normalizer | null, view: ViewState, x: number, y: number) => boolean;
|
|
draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
/** `#rrggbb` → [r,g,b]. 색띠는 hex로만 정의한다(보간을 위해). */
|
|
function parseHex(color: string): [number, number, number] {
|
|
const hex = color.trim().replace("#", "");
|
|
const full =
|
|
hex.length === 3
|
|
? hex
|
|
.split("")
|
|
.map((c) => c + c)
|
|
.join("")
|
|
: hex;
|
|
const value = Number.parseInt(full.slice(0, 6), 16);
|
|
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
|
}
|
|
|
|
/** 0~1을 색띠 위에서 보간한다. */
|
|
function rampColor(t: number): string {
|
|
const clamped = Math.min(1, Math.max(0, t));
|
|
const last = RAMP_TOKENS.length - 1;
|
|
const position = clamped * last;
|
|
const low = Math.min(last, Math.floor(position));
|
|
const high = Math.min(last, low + 1);
|
|
const ratio = position - low;
|
|
const a = parseHex(themeColor(RAMP_TOKENS[low][0], RAMP_TOKENS[low][1]));
|
|
const b = parseHex(themeColor(RAMP_TOKENS[high][0], RAMP_TOKENS[high][1]));
|
|
const mix = (index: number): number => Math.round(a[index] + (b[index] - a[index]) * ratio);
|
|
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
|
|
}
|
|
|
|
/** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */
|
|
function formatArea(areaM2: number): string {
|
|
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
|
|
}
|
|
|
|
export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOverlay {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
// 기본 켜짐(2026-08-01 사용자 지시) — 분석 결과가 들어오면 바로 보이게 한다.
|
|
let shown = true;
|
|
button.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
|
|
button.textContent = L("B04_Surface_Flow_Strength");
|
|
button.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-5", "#dc2626"));
|
|
button.setAttribute("aria-pressed", "true");
|
|
button.title = L("B04_Surface_Flow_Strength_Tip");
|
|
button.addEventListener("click", () => {
|
|
shown = !shown;
|
|
button.classList.toggle("is-active", shown);
|
|
button.setAttribute("aria-pressed", String(shown));
|
|
onChange();
|
|
});
|
|
|
|
let projectId: string | null = null;
|
|
let meta: VWorldMeta | null = null;
|
|
let routePoints: ReadonlyArray<RoutePoint> = [];
|
|
/** 누가거리 1m 간격으로 다시 찍은 계획선 점 — 색칠·마커 위치의 기준. */
|
|
let samples: RoutePoint[] = [];
|
|
/** 인덱스 = 누가거리(m), 값 = 그 1m 구간의 유입면적(㎡). */
|
|
let strength: Float64Array = new Float64Array(0);
|
|
let maxStrength = 0;
|
|
let hotspots: Array<{ chainage: number; area: number; zone: number; rank: number }> = [];
|
|
let selected: number | null = null;
|
|
let selectionRings: Array<Array<[number, number]>> = [];
|
|
let statusText = "";
|
|
// 늦게 도착한 응답이 최신 선택을 덮어쓰지 않게 요청마다 번호를 매긴다.
|
|
let requestSequence = 0;
|
|
|
|
/** 로그 스케일 정규화 — 계곡 한 점이 사면보다 수백 배라 선형으로는 못 읽는다. */
|
|
function normalize(value: number): number {
|
|
if (maxStrength <= 0 || value <= 0) return 0;
|
|
return Math.log1p(value) / Math.log1p(maxStrength);
|
|
}
|
|
|
|
function drawStrengthLine(context: CanvasRenderingContext2D, view: ViewState): void {
|
|
if (!meta || samples.length < 2 || strength.length === 0) return;
|
|
context.save();
|
|
context.lineWidth = STRENGTH_LINE_WIDTH;
|
|
context.lineCap = "round";
|
|
const limit = Math.min(strength.length, samples.length - 1);
|
|
for (let i = 0; i < limit; i += 1) {
|
|
const value = strength[i];
|
|
if (value <= 0) continue; // 유입 없는 구간은 계획선 원래 색을 그대로 둔다
|
|
const [x0, y0] = metricToScreen(meta, view, samples[i].x, samples[i].y);
|
|
const [x1, y1] = metricToScreen(meta, view, samples[i + 1].x, samples[i + 1].y);
|
|
context.strokeStyle = rampColor(normalize(value));
|
|
context.beginPath();
|
|
context.moveTo(x0, y0);
|
|
context.lineTo(x1, y1);
|
|
context.stroke();
|
|
}
|
|
context.restore();
|
|
}
|
|
|
|
function markerRadius(area: number): number {
|
|
return MARKER_MIN_RADIUS + (MARKER_MAX_RADIUS - MARKER_MIN_RADIUS) * normalize(area);
|
|
}
|
|
|
|
function markerScreen(view: ViewState, chainage: number): [number, number] | null {
|
|
if (!meta) return null;
|
|
const point = pointAtChainage(samples, chainage);
|
|
if (!point) return null;
|
|
return metricToScreen(meta, view, point.x, point.y);
|
|
}
|
|
|
|
function drawMarkers(context: CanvasRenderingContext2D, view: ViewState): void {
|
|
if (!meta || hotspots.length === 0) return;
|
|
context.save();
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
hotspots.forEach((spot, index) => {
|
|
const screen = markerScreen(view, spot.chainage);
|
|
if (!screen) return;
|
|
const [x, y] = screen;
|
|
const radius = markerRadius(spot.area);
|
|
context.beginPath();
|
|
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
context.fillStyle = rampColor(normalize(spot.area));
|
|
context.fill();
|
|
context.lineWidth = index === selected ? 3 : 1.5;
|
|
context.strokeStyle =
|
|
index === selected ? themeColor("--map-marker-text", "#111827") : haloColor();
|
|
context.stroke();
|
|
// 번호는 마커가 충분히 클 때만 안에 넣는다 — 작은 원에 글자를 넣으면 뭉갠다.
|
|
context.font = "bold 9px sans-serif";
|
|
if (radius >= 8) {
|
|
context.fillStyle = haloColor();
|
|
context.fillText(String(index + 1), x, y);
|
|
} else {
|
|
context.fillStyle = themeColor("--map-marker-text", "#111827");
|
|
context.strokeStyle = haloColor();
|
|
context.lineWidth = 2.5;
|
|
context.strokeText(String(index + 1), x + radius + 6, y);
|
|
context.fillText(String(index + 1), x + radius + 6, y);
|
|
}
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
function drawSelection(
|
|
context: CanvasRenderingContext2D,
|
|
normalizer: Normalizer | null,
|
|
view: ViewState,
|
|
): void {
|
|
if (!normalizer || selectionRings.length === 0) return;
|
|
context.save();
|
|
context.lineWidth = 2.4;
|
|
context.lineJoin = "round";
|
|
context.strokeStyle = themeColor("--map-inflow-outline", "#dc2626");
|
|
selectionRings.forEach((ring) => {
|
|
context.beginPath();
|
|
ring.forEach(([lon, lat], index) => {
|
|
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.closePath();
|
|
context.stroke();
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
/** 선택한 마커의 기여 셀 외곽선을 받아 온다. */
|
|
async function loadSelection(index: number): Promise<void> {
|
|
const spot = hotspots[index];
|
|
if (!projectId || !spot) return;
|
|
const sequence = ++requestSequence;
|
|
statusText = L("B04_Surface_Flow_Inflow_Loading");
|
|
onChange();
|
|
try {
|
|
const response = await fetchRoadInflow(projectId, spot.chainage);
|
|
if (sequence !== requestSequence) return; // 더 최근 선택이 있다
|
|
selectionRings = response.rings_lonlat as Array<Array<[number, number]>>;
|
|
statusText = L("B04_Surface_Flow_Inflow_Summary")
|
|
.replace("{index}", String(index + 1))
|
|
.replace("{chainage}", spot.chainage.toFixed(1))
|
|
.replace("{area}", formatArea(response.area_m2))
|
|
.replace("{cells}", response.cell_count.toLocaleString())
|
|
.replace("{path}", response.max_path_length_m.toFixed(0));
|
|
} catch (error) {
|
|
if (sequence !== requestSequence) return;
|
|
selectionRings = [];
|
|
statusText = error instanceof Error ? error.message : L("B04_Surface_Flow_Inflow_Failed");
|
|
}
|
|
onChange();
|
|
}
|
|
|
|
return {
|
|
button,
|
|
visible: () => shown,
|
|
status: () => statusText,
|
|
setProject(nextProjectId) {
|
|
projectId = nextProjectId;
|
|
},
|
|
setData(profile, spots) {
|
|
const length = profile.reduce((max, [chainage]) => Math.max(max, chainage), 0);
|
|
strength = new Float64Array(Math.floor(length) + 1);
|
|
profile.forEach(([chainage, area]) => {
|
|
const index = Math.round(chainage);
|
|
if (index >= 0 && index < strength.length) strength[index] = area;
|
|
});
|
|
// 노선이 길면 점이 수만 개가 되므로 spread(Math.max(...))로 최대를 구하지 않는다.
|
|
maxStrength = strength.reduce((max, value) => (value > max ? value : max), 0);
|
|
hotspots = spots.map(([chainage, area, zone, rank]) => ({ chainage, area, zone, rank }));
|
|
selected = null;
|
|
selectionRings = [];
|
|
statusText = "";
|
|
// 진행 중인 유입 조회를 무효화한다 — 그러지 않으면 늦게 온 응답이 방금 지운 선택을
|
|
// 되살려 "고른 마커는 없는데 외곽선만 떠 있는" 상태가 된다.
|
|
requestSequence += 1;
|
|
},
|
|
setRoute(points, nextMeta) {
|
|
routePoints = points;
|
|
meta = nextMeta;
|
|
samples = resampleRoute(routePoints);
|
|
},
|
|
clear() {
|
|
strength = new Float64Array(0);
|
|
maxStrength = 0;
|
|
hotspots = [];
|
|
selected = null;
|
|
selectionRings = [];
|
|
statusText = "";
|
|
requestSequence += 1;
|
|
},
|
|
handleClick(_normalizer, view, x, y) {
|
|
if (!shown) return false;
|
|
// 마커가 없어도 남은 외곽선은 지워 준다 — 안 그러면 지울 방법이 없다.
|
|
if (hotspots.length === 0) {
|
|
if (selected === null && selectionRings.length === 0) return false;
|
|
selected = null;
|
|
selectionRings = [];
|
|
statusText = "";
|
|
requestSequence += 1;
|
|
onChange();
|
|
return false;
|
|
}
|
|
let hit: number | null = null;
|
|
let hitDistance = Number.POSITIVE_INFINITY;
|
|
hotspots.forEach((spot, index) => {
|
|
const screen = markerScreen(view, spot.chainage);
|
|
if (!screen) return;
|
|
const distance = Math.hypot(screen[0] - x, screen[1] - y);
|
|
if (distance <= markerRadius(spot.area) + MARKER_HIT_SLACK && distance < hitDistance) {
|
|
hit = index;
|
|
hitDistance = distance;
|
|
}
|
|
});
|
|
if (hit === null) {
|
|
// 마커 밖을 누르면 선택 해제. 도로 아무 데나 고를 수는 없다(사용자 지시).
|
|
if (selected === null && selectionRings.length === 0) return false;
|
|
selected = null;
|
|
selectionRings = [];
|
|
statusText = "";
|
|
requestSequence += 1;
|
|
onChange();
|
|
return false;
|
|
}
|
|
if (selected === hit) {
|
|
selected = null;
|
|
selectionRings = [];
|
|
statusText = "";
|
|
requestSequence += 1;
|
|
onChange();
|
|
return true;
|
|
}
|
|
selected = hit;
|
|
selectionRings = [];
|
|
void loadSelection(hit);
|
|
return true;
|
|
},
|
|
draw(context, normalizer, view) {
|
|
if (!shown) return;
|
|
drawStrengthLine(context, view);
|
|
drawSelection(context, normalizer, view);
|
|
drawMarkers(context, view);
|
|
},
|
|
dispose() {
|
|
requestSequence += 1;
|
|
},
|
|
};
|
|
}
|