- 흐름 강도 색띠 범례를 지도 우측에 세로로 세운다(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>
384 lines
16 KiB
TypeScript
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 };
|
|
}
|