- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
346 lines
14 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|