Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts
T
eomsangdonandClaude Opus 5 8c37f2f6f3 feat(B04/B05): 유역 선택 강조, B05 배관 상시 드래그·자동 재산정, 강도 색칠 공용화
B04
- 세부유역을 눌러 고르면 그 유역만 진하게, 나머지는 옅게 물러난다. 관 마커를
  눌러도 그 관이 받는 유역이 골라지고, 유역 밖을 누르면 강조가 풀린다.
- 고른 유역의 제원(측점·면적·표고차·유하장)을 상태 줄에 표기한다.

B05
- "배관 편집" 토글을 없애고 마커를 언제나 끌 수 있게 했다. 계획선을 그냥 누르는
  것으로는 배관이 생기지 않는다 — 유역을 고르려 할 때마다 배관이 생기기 때문이다.
  추가·삭제는 B04와 같은 우클릭 메뉴로 옮겼다.
- 배관을 옮기거나 넣거나 지우면 세부유역을 즉시 다시 나눈다(격자 해석 없음).
- "자동 제안" 버튼 이름을 "초기화"로 바꿨다.
- 노선 유입 강도 색칠을 B04 지도와 같은 색띠로 추가했다(토글 포함).

공용화
- common_util_drainage_detail: 1m 구간별 유입 면적 곡선을 응답에 포함(B04·B05 동일).
- B04_wf1_Surface_UI_FlowRamp.ts: 색띠·정규화·강도선 렌더러를 두 화면이 공유.
- ui_template_context_menu.ts: 지도 우클릭 메뉴를 공용 부품으로 분리.
- B05_wf2_Route_UI_Drainage_Parts.ts 신설로 패널을 700줄 규칙 안으로 되돌림(677줄).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:36:02 +09:00

323 lines
13 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 {
buildStrengthArray,
drawStrengthLine,
normalizeStrength,
rampColor,
} from "./B04_wf1_Surface_UI_FlowRamp";
import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 마커 반지름(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;
/** 유입 집중점 마커 전용 토글("집중유역"). 기본 꺼짐 — 마커가 관 마커와 겹쳐 읽기 어렵다. */
markerButton: 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;
}
/** 면적을 사람이 읽는 문구로. 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();
});
// 유입 집중점 마커는 관 매설 마커와 같은 계획선 위에 찍혀 서로 가린다. 그래서 강도 색칠과
// 떼어 내 별도 토글을 두고, 기본은 꺼 둔다(2026-08-01 사용자 지시).
let markersShown = false;
const markerButton = document.createElement("button");
markerButton.type = "button";
markerButton.className = "b04-map__layer-button b04-map__layer-button--gis";
markerButton.textContent = L("B04_Surface_Flow_Hotspots");
markerButton.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-4", "#f97316"));
markerButton.setAttribute("aria-pressed", "false");
markerButton.title = L("B04_Surface_Flow_Hotspots_Tip");
markerButton.addEventListener("click", () => {
markersShown = !markersShown;
markerButton.classList.toggle("is-active", markersShown);
markerButton.setAttribute("aria-pressed", String(markersShown));
if (!markersShown) {
// 마커를 감추면 골라 둔 유입 외곽선도 같이 걷는다 — 고른 마커가 안 보이는데 외곽선만
// 남으면 무엇을 본 결과인지 알 수 없다.
selected = null;
selectionRings = [];
statusText = "";
requestSequence += 1;
}
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 {
return normalizeStrength(value, maxStrength);
}
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,
markerButton,
visible: () => shown,
status: () => statusText,
setProject(nextProjectId) {
projectId = nextProjectId;
},
setData(profile, spots) {
const built = buildStrengthArray(profile);
strength = built.strength;
maxStrength = built.maximum;
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 (!markersShown) 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 && meta) {
const mapMeta = meta;
drawStrengthLine(context, samples, strength, maxStrength, (point) =>
metricToScreen(mapMeta, view, point.x, point.y),
);
}
// 집중점 마커와 그 유입 외곽선은 전용 토글이 켜졌을 때만 그린다.
if (!markersShown) return;
drawSelection(context, normalizer, view);
drawMarkers(context, view);
},
dispose() {
requestSequence += 1;
},
};
}