Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts
T
eomsangdonandClaude Opus 5 07c4a7897c feat(B04): 상세 배수유역 계산 — 관 매설 지점 편집 + 세부유역 분할
관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 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>
2026-08-01 21:48:28 +09:00

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;
},
};
}