Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts
T
eomsangdonandClaude Opus 5 32fd199dbf feat(B04/B05): 흐름 강도 범례 + 배관 라벨 가독성 + 선택 3자 동기화
- 흐름 강도 색띠 범례를 지도 우측에 세로로 세운다(B04 2D 지도·B05 배수유역도 공용).
  색은 화면과 같은 색띠를, 눈금은 같은 로그 정규화를 되돌려 적는다. 제목 "흐름강도".
- 배관 누가거리 라벨에 흰 테두리를 깔았다. --map-* 토큰은 다크 테마에서 바뀌지 않아
  색만으로는 위성사진·다크 배경에서 묻힌다.
- 배수유역 영역 · 종단 그래프 세로선 · 좌측 구조물 폼 · 3D 마커의 선택이 서로를
  갱신한다. B05_wf2_Route_UI_Selection.ts로 경로를 한곳에 모으고 isSyncing 가드로
  재진입을 막았다. 유역 강조는 origin === "pipe" 항목에만 붙는다.

700줄 규칙 유지를 위한 분리
- B04_wf1_Surface_UI_MapOverlays.ts 신설(유역 채움·번호 배지·분수령·상류 세류망)
- reconcilePipes / fetchDrainageLayers / fitViewToRoute → _Drainage_Parts.ts

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

384 lines
16 KiB
TypeScript

/* =============================================================================
* 배수유역도 패널 부품 (B05)
*
* 레이어 목록·색·보관 키 같은 상수와, 상태를 갖지 않는 DOM 조각(표시 토글 버튼, 유역 제원
* 목록)을 모았다. 패널 본체(`B05_wf2_Route_UI_Drainage_Panel.ts`)가 700줄 한계에 닿아
* 분리한 것으로, 여기 있는 것들은 패널의 내부 상태를 알지 못한다 — 전부 인자로 받는다.
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { fetchVWorldMeta, type VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
computeRouteView,
type GeoJsonCollection,
type ViewState,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import { normalizeStrength, rampColor } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import type { MapContextMenu } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { PipeEditor, PipePoint } from "./B05_wf2_Route_UI_Drainage_Pipes";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시).
* 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */
export const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS;
export type DrainageLayer = (typeof DRAINAGE_LAYERS)[number];
/** 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다. 여기는 이름만 잇는다. */
const LAYER_COLOR_TOKENS: Record<DrainageLayer, [name: string, fallback: string]> = {
도엽_등고선: ["--map-sheet-contour", "#a5b4fc"],
도엽_하천중심선: ["--map-sheet-stream", "#2563eb"],
};
export const layerColor = (layer: DrainageLayer): string =>
themeColor(...LAYER_COLOR_TOKENS[layer]);
export const LAYER_LABEL_KEYS: Record<DrainageLayer, keyof typeof ui_locales> = {
도엽_등고선: "B05_Drainage_Layer_Contour",
도엽_하천중심선: "B05_Drainage_Layer_Stream",
};
/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */
export const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed");
export const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8");
export const strengthToggleColor = (): string => themeColor("--map-flow-ramp-5", "#dc2626");
export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4", "#f97316");
/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */
export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b");
export const COLLAPSED_KEY = "b05-route-drainage-collapsed";
/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
export const WIDTH_KEY = "b05-route-drainage-width";
/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */
export const MIN_PANEL_WIDTH = 320;
/** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */
export const MAX_PANEL_WIDTH_RATIO = 0.7;
/** 유역 오버레이 파스텔 색상 8종(정의처: `--map-basin-1`~`-8`). 번호 순으로 돌려쓴다. */
const BASIN_COLOR_FALLBACKS = [
"rgba(167, 216, 199, 0.45)",
"rgba(247, 208, 168, 0.45)",
"rgba(186, 199, 240, 0.45)",
"rgba(241, 183, 199, 0.45)",
"rgba(214, 226, 168, 0.45)",
"rgba(202, 186, 227, 0.45)",
"rgba(168, 214, 232, 0.45)",
"rgba(240, 219, 168, 0.45)",
] as const;
/** 유역 번호(1부터)에 대응하는 채움색. 8종을 넘어가면 처음부터 다시 쓴다. */
export function basinColor(index: number): string {
const slot = (index - 1) % BASIN_COLOR_FALLBACKS.length;
return themeColor(`--map-basin-${slot + 1}`, BASIN_COLOR_FALLBACKS[slot]);
}
/** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */
export function formatArea(areaM2: number): string {
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
}
/** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */
export function addLayerToggle(
container: HTMLElement,
label: string,
color: string,
initial: boolean,
onToggle: (next: boolean) => void,
title?: string,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-drainage__layer-button" + (initial ? " is-active" : "");
button.textContent = label;
button.style.setProperty("--b05-layer-color", color);
button.setAttribute("aria-pressed", String(initial));
if (title) button.title = title;
let active = initial;
button.addEventListener("click", () => {
active = !active;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", String(active));
onToggle(active);
});
container.append(button);
return button;
}
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 `onPick`으로 번호를 돌려준다. */
export function renderBasinRows(
container: HTMLElement,
basins: ReadonlyArray<DetailBasin>,
selected: number | null,
onPick: (index: number) => void,
): void {
container.textContent = "";
container.hidden = basins.length === 0;
basins.forEach((basin) => {
const row = document.createElement("button");
row.type = "button";
row.className = "b05-drainage__basin" + (selected === basin.index ? " is-selected" : "");
const badge = document.createElement("span");
badge.className = "b05-drainage__basin-index";
badge.textContent = String(basin.index);
badge.style.background = basinColor(basin.index);
const metrics = document.createElement("span");
metrics.className = "b05-drainage__basin-metrics";
// 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다.
const pipe =
basin.pipe_diameter_mm === null
? L("B05_Drainage_Basin_Undecided")
: ${Math.round(basin.pipe_diameter_mm)}mm`;
metrics.textContent = L("B05_Drainage_Basin_Metrics")
.replace("{area}", formatArea(basin.area_m2))
.replace("{relief}", basin.relief_m.toFixed(1))
.replace("{flow}", String(Math.round(basin.flow_length_m)))
.replace("{pipe}", pipe);
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1));
row.append(badge, metrics);
row.addEventListener("click", () => onPick(basin.index));
container.append(row);
});
}
/** 사업지 좌표계(m) → 화면 px 변환기. 흐름 화살표와 강도 색칠이 같은 식을 쓴다 —
* 둘이 어긋나면 색은 계획선 위인데 화살표만 밀린 것처럼 보인다. */
export function createMetricProjector(
meta: VWorldMeta,
view: ViewState,
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return {
toScreen: (x, y) => [
((x - meta.x_min) / spanX) * ax + bx,
(1 - (y - meta.y_min) / spanY) * ay + by,
],
pxPerMeter: ax / spanX,
};
}
/** 배관 우클릭 메뉴를 지도 뷰포트에 붙인다 — 마커 위면 삭제, 계획선 위면 추가.
* 배관 편집 토글을 없애면서 추가·삭제를 이쪽으로 옮겼다(2026-08-01 사용자 지시). 계획선을
* 그냥 누르는 것으로 추가하면 유역을 고르려 할 때마다 배관이 생기기 때문이다. */
export function bindPipeContextMenu(
viewport: HTMLElement,
menu: MapContextMenu,
editor: PipeEditor,
viewOf: () => ViewState,
): void {
viewport.addEventListener("contextmenu", (event) => {
if (menu.contains(event.target)) {
event.preventDefault();
return;
}
const rect = viewport.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const view = viewOf();
const hit = editor.hitAt(view, x, y);
if (hit !== null) {
event.preventDefault();
menu.open(x, y, [
[
L("B05_Drainage_Menu_Delete"),
() => {
editor.select(hit);
editor.deleteSelected();
},
],
]);
return;
}
// 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다.
if (!editor.canAddAt(view, x, y)) return;
event.preventDefault();
menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]);
});
}
/** 유입 집중점 마커 — 계획선 위에서 물이 특히 많이 모이는 자리. 크기·색은 유입면적 로그 스케일.
* B04 지도와 같은 색띠를 쓴다(같은 값을 다르게 보여 주면 안 된다). */
export function drawHotspots(
context: CanvasRenderingContext2D,
toScreen: (x: number, y: number) => [number, number],
samples: ReadonlyArray<{ x: number; y: number }>,
spots: ReadonlyArray<{ chainage: number; area: number }>,
maximum: number,
): void {
if (spots.length === 0 || samples.length === 0) return;
context.save();
spots.forEach((spot) => {
const index = Math.min(samples.length - 1, Math.max(0, Math.round(spot.chainage)));
const [x, y] = toScreen(samples[index].x, samples[index].y);
const ratio = normalizeStrength(spot.area, maximum);
const radius = 3 + 5 * ratio;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fillStyle = rampColor(ratio);
context.fill();
context.lineWidth = 1.2;
context.strokeStyle = themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
context.stroke();
});
context.restore();
}
/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */
export function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const [xi, yi] = ring[index];
const [xj, yj] = ring[previous];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 표시 토글 한 줄을 통째로 만든다 — 위성/도엽/화살표/강도/집중점/상류세류 6종.
* 패널이 700줄 한계에 닿아 옮겼다. 상태는 갖지 않고 켜고 끌 때 넘겨받은 함수만 부른다. */
export function mountDrainageToggles(
container: HTMLElement,
handlers: {
initial: { arrows: boolean; strength: boolean; hotspots: boolean; upstream: boolean };
onSatellite: (next: boolean) => void;
onSheetLayer: (layer: DrainageLayer, next: boolean) => void;
onArrows: (next: boolean) => void;
onStrength: (next: boolean) => void;
onHotspots: (next: boolean) => void;
onUpstream: (next: boolean) => void;
},
): void {
// 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이
// 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다.
addLayerToggle(
container,
L("B05_Drainage_Layer_Satellite"),
satelliteToggleColor(),
true,
handlers.onSatellite,
L("B05_Drainage_Layer_Satellite_Tip"),
);
DRAINAGE_LAYERS.forEach((layer) => {
addLayerToggle(container, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) =>
handlers.onSheetLayer(layer, next),
);
});
// 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식).
addLayerToggle(
container,
L("B05_Drainage_Layer_Arrows"),
arrowToggleColor(),
handlers.initial.arrows,
handlers.onArrows,
L("B05_Drainage_Layer_Arrows_Tip"),
);
// 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다.
addLayerToggle(
container,
L("B05_Drainage_Layer_Strength"),
strengthToggleColor(),
handlers.initial.strength,
handlers.onStrength,
L("B05_Drainage_Layer_Strength_Tip"),
);
// 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 관 마커와 겹쳐 읽기 어려우므로 기본 꺼짐.
addLayerToggle(
container,
L("B05_Drainage_Layer_Hotspots"),
hotspotToggleColor(),
handlers.initial.hotspots,
handlers.onHotspots,
L("B05_Drainage_Layer_Hotspots_Tip"),
);
// 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다.
addLayerToggle(
container,
L("B05_Drainage_Layer_Upstream"),
upstreamToggleColor(),
handlers.initial.upstream,
handlers.onUpstream,
L("B05_Drainage_Layer_Upstream_Tip"),
);
}
/** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다.
* 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */
export function reconcilePipes(
before: ReadonlyArray<PipePoint>,
chainages: ReadonlyArray<number>,
): PipePoint[] | null {
const next = [...chainages].map((value) => Math.round(value * 100) / 100).sort((a, b) => a - b);
const current = before
.map((pipe) => Math.round(pipe.chainage_m * 100) / 100)
.sort((a, b) => a - b);
if (next.length === current.length && next.every((value, index) => value === current[index])) {
return null;
}
return next.map((chainage) => {
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51);
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
});
}
/** 배경지도 메타와 도엽 레이어를 한 번에 받아 온다. 한 레이어가 실패해도 나머지는 살린다.
* 상태를 만지지 않는 I/O 전용 — 패널이 700줄 한계에 닿아 떼어냈다. */
export async function fetchDrainageLayers(
projectId: string,
onProgress: (ratio: number) => void,
): Promise<{
meta: VWorldMeta;
layers: Array<readonly [DrainageLayer, GeoJsonCollection | null]>;
}> {
const meta = await fetchVWorldMeta(projectId, "satellite");
onProgress(1 / 3);
const layers = await Promise.all(
DRAINAGE_LAYERS.map(async (layer) => {
try {
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
return [layer, await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)] as const;
} catch {
return [layer, null] as const;
}
}),
);
return { meta, layers };
}
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 배경 전체(배율 1)를 그대로 본다.
* B05는 노선 주변 유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다(2026-08-01 사용자 지시). */
export function fitViewToRoute(
meta: VWorldMeta | null,
routePoints: ReadonlyArray<{ x: number; y: number }>,
width: number,
height: number,
): { scale: number; offsetX: number; offsetY: number } {
if (!meta || routePoints.length < 2) return { scale: 1, offsetX: 0, offsetY: 0 };
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
routePoints.forEach((point) => {
if (point.x < minX) minX = point.x;
if (point.x > maxX) maxX = point.x;
if (point.y < minY) minY = point.y;
if (point.y > maxY) maxY = point.y;
});
const view = computeRouteView(
meta,
{ x_min: minX, x_max: maxX, y_min: minY, y_max: maxY },
Math.max(width, 1),
Math.max(height, 1),
);
return { scale: view.scale, offsetX: view.offsetX, offsetY: view.offsetY };
}