Files
Aislo/B04_PreProcess/B04_PreProcess_UI_FlowStrength.ts
eomsangdonandClaude Fable 5 f7528a4aa4 refactor(B04): B04_wf1_Surface -> B04_PreProcess 전면 개명
- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존)
- 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess),
  라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석
- 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:01:36 +09:00

346 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_PreProcess_Api_Fetch";
import {
haloColor,
lonLatToScreen,
metricToScreen,
type Normalizer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import {
buildStrengthArray,
createFlowLegend,
drawStrengthLine,
normalizeStrength,
rampColor,
} from "./B04_PreProcess_UI_FlowRamp";
import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_PreProcess_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;
/** 색띠 범례. 지도 뷰포트에 넣으면 CSS가 우측 세로로 세운다. */
legendElement: HTMLElement;
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;
/** 유입 집중점 마커와 그 유입 외곽선. **맨 마지막에** 부른다 — 세부유역 채움·관 마커에
* 가리면 무엇을 고른 것인지 알 수 없다(2026-08-02 사용자 지시). */
drawTop: (
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");
const legend = createFlowLegend();
/** 범례는 강도 색칠이 켜져 있고 값이 있을 때만 띄운다. */
function syncLegend(): void {
legend.update(maxStrength, shown);
}
button.addEventListener("click", () => {
shown = !shown;
button.classList.toggle("is-active", shown);
button.setAttribute("aria-pressed", String(shown));
syncLegend();
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,
legendElement: legend.root,
visible: () => shown,
status: () => statusText,
setProject(nextProjectId) {
projectId = nextProjectId;
},
setData(profile, spots) {
const built = buildStrengthArray(profile);
strength = built.strength;
maxStrength = built.maximum;
syncLegend();
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;
legend.update(0, false);
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) return;
const mapMeta = meta;
drawStrengthLine(context, samples, strength, maxStrength, (point) =>
metricToScreen(mapMeta, view, point.x, point.y),
);
},
drawTop(context, normalizer, view) {
// 집중점 마커와 그 유입 외곽선은 전용 토글이 켜졌을 때만 그린다.
if (!markersShown) return;
drawSelection(context, normalizer, view);
drawMarkers(context, view);
},
dispose() {
requestSequence += 1;
},
};
}