- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
566 lines
22 KiB
TypeScript
566 lines
22 KiB
TypeScript
/* =============================================================================
|
|
* 상세 배수유역 오버레이 (B04 2D 지도 — 관리자 검토용)
|
|
*
|
|
* 계획선 위 **관 매설 지점**을 편집하고, 그 관마다 물이 모이는 **세부유역**을 겹쳐 그린다.
|
|
*
|
|
* · 기본 관 = 도로 × 상류 세류선 교차점 (백엔드 분석 산출물)
|
|
* · 자동 보충 = 관 최대 간격을 넘는 구간에 최소 개수로 채운 자리
|
|
* · 수동 = 계획선 위에서 우클릭해 넣었거나, 끌어서 옮긴 관
|
|
*
|
|
* 경계는 여기서 긋지 않는다. 백엔드가 1m 격자 셀마다 "이 셀 물이 어느 도로 셀로 가는가"를
|
|
* 이미 풀어 두었고, 도로 셀은 종단 내리막을 따라 담당 관으로 묶인다. 그래서 같은 관으로
|
|
* 묶인 셀 덩어리의 바깥선이 곧 세부유역 경계다 — 화면은 그 폴리곤을 받아 칠하기만 한다.
|
|
*
|
|
* 재계산은 **버튼을 눌렀을 때만** 돈다(2026-08-01 사용자 지시). 편집 중에는 마커만 움직이고,
|
|
* 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다.
|
|
* ========================================================================== */
|
|
|
|
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import {
|
|
computeDetailBasins,
|
|
fetchDetailPipePoints,
|
|
saveDetailPipePoints,
|
|
type DetailBasin,
|
|
type PipeSource,
|
|
type VWorldMeta,
|
|
} from "./B04_PreProcess_Api_Fetch";
|
|
import {
|
|
haloColor,
|
|
lonLatToScreen,
|
|
metricToScreen,
|
|
type Normalizer,
|
|
type ViewState,
|
|
} from "./B04_PreProcess_UI_MapRender";
|
|
import {
|
|
nearestChainage,
|
|
pointAtChainage,
|
|
resampleRoute,
|
|
type RoutePoint,
|
|
} from "./B04_PreProcess_UI_RouteSamples";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
/** 관 마커 반지름(px)과 잡기 여유(px). 유입 집중점 마커보다 조금 크게 둬서 구분된다. */
|
|
const PIPE_RADIUS = 8;
|
|
const PIPE_HIT_SLACK = 5;
|
|
/** 계획선에서 이보다 멀면 "노선 위 우클릭"으로 보지 않는다(px). */
|
|
const ROUTE_HIT_PX = 14;
|
|
|
|
/** 관 생성 사유별 색. 정의처는 `ui_template_theme.css`. */
|
|
const PIPE_COLORS: Record<PipeSource, [token: string, fallback: string]> = {
|
|
stream: ["--map-pipe-marker", "#f97316"],
|
|
spacing: ["--map-pipe-auto", "#0ea5e9"],
|
|
user: ["--map-pipe-user", "#16a34a"],
|
|
};
|
|
|
|
/** 세부유역 채움 — 이웃끼리 구분만 되면 되므로 색상환을 균등 분할해 돌려 쓴다. */
|
|
function basinColor(index: number, alpha: number): string {
|
|
const hue = (index * 137.508) % 360; // 황금각 — 인접 유역이 비슷한 색으로 붙지 않는다
|
|
return `hsla(${hue.toFixed(0)}, 70%, 55%, ${alpha})`;
|
|
}
|
|
|
|
/** 유역 번호 서클 반지름(px). 관 마커(8)보다 커야 둘이 겹쳐도 구분된다. */
|
|
const BASIN_NUMBER_RADIUS = 12;
|
|
|
|
/** 유역 채움 투명도 — 아무것도 안 골랐을 때 / 고른 것 / 고르지 않은 나머지. */
|
|
const BASIN_ALPHA_PLAIN = 0.22;
|
|
const BASIN_ALPHA_SELECTED = 0.38;
|
|
const BASIN_ALPHA_MUTED = 0.06;
|
|
|
|
/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */
|
|
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;
|
|
}
|
|
|
|
interface PipeMarker {
|
|
chainage: number;
|
|
source: PipeSource;
|
|
}
|
|
|
|
/** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */
|
|
function formatArea(areaM2: number): string {
|
|
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
|
|
}
|
|
|
|
/** 폴리곤 면적 중심(화면 px). 면적이 0에 가까우면 정점 평균으로 물러난다. */
|
|
function ringCentroid(ring: ReadonlyArray<[number, number]>): [number, number] {
|
|
let twiceArea = 0;
|
|
let cx = 0;
|
|
let cy = 0;
|
|
for (let index = 0; index < ring.length; index += 1) {
|
|
const [x0, y0] = ring[index];
|
|
const [x1, y1] = ring[(index + 1) % ring.length];
|
|
const cross = x0 * y1 - x1 * y0;
|
|
twiceArea += cross;
|
|
cx += (x0 + x1) * cross;
|
|
cy += (y0 + y1) * cross;
|
|
}
|
|
if (Math.abs(twiceArea) < 1e-6) {
|
|
const sum = ring.reduce((acc, [x, y]) => [acc[0] + x, acc[1] + y] as [number, number], [0, 0]);
|
|
return [sum[0] / ring.length, sum[1] / ring.length];
|
|
}
|
|
return [cx / (3 * twiceArea), cy / (3 * twiceArea)];
|
|
}
|
|
|
|
/** 유역 번호를 서클 숫자로 얹는다. 번호는 시점에서 종점 순(백엔드가 누가거리 순으로 준다). */
|
|
function drawBasinNumbers(
|
|
context: CanvasRenderingContext2D,
|
|
labels: ReadonlyArray<{ index: number; number: number; point: [number, number] }>,
|
|
): void {
|
|
if (labels.length === 0) return;
|
|
context.save();
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.font = "bold 13px sans-serif";
|
|
labels.forEach(({ index, number, point: [x, y] }) => {
|
|
context.beginPath();
|
|
context.arc(x, y, BASIN_NUMBER_RADIUS, 0, Math.PI * 2);
|
|
context.fillStyle = basinColor(index, 0.92);
|
|
context.fill();
|
|
context.lineWidth = 2;
|
|
context.strokeStyle = haloColor();
|
|
context.stroke();
|
|
context.fillStyle = haloColor();
|
|
context.fillText(String(number), x, y);
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
export interface DetailBasinOverlay {
|
|
/** 세부유역 재계산 버튼. */
|
|
button: HTMLButtonElement;
|
|
/** 표시 토글 — 관 마커와 세부유역을 함께 켜고 끈다(하나로 묶임). */
|
|
partButtons: HTMLButtonElement[];
|
|
statusElement: HTMLElement;
|
|
/** 우클릭 메뉴. 지도 뷰포트에 얹는다(뷰포트 기준 절대 위치). */
|
|
menuElement: HTMLElement;
|
|
setProject: (projectId: string) => void;
|
|
/** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */
|
|
setRoute: (points: ReadonlyArray<RoutePoint>, meta: VWorldMeta | null) => void;
|
|
clear: () => void;
|
|
/** 마커를 잡았으면 true(지도 팬 대신 마커 끌기로 넘어간다). */
|
|
handlePointerDown: (view: ViewState, x: number, y: number) => boolean;
|
|
handlePointerMove: (view: ViewState, x: number, y: number) => boolean;
|
|
handlePointerUp: () => boolean;
|
|
/** 우클릭 메뉴를 띄웠으면 true. */
|
|
handleContextMenu: (view: ViewState, x: number, y: number) => boolean;
|
|
/** 세부유역을 고르거나 골랐던 것을 풀었으면 true(다른 오버레이가 같은 클릭을 먹지 않게). */
|
|
handleClick: (view: ViewState, x: number, y: number) => boolean;
|
|
/** 모델 확정 시점에 관 지점과 세부유역을 영구저장한다. */
|
|
commit: () => Promise<number>;
|
|
draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverlay {
|
|
let projectId: string | null = null;
|
|
let meta: VWorldMeta | null = null;
|
|
let samples: RoutePoint[] = [];
|
|
let pipes: PipeMarker[] = [];
|
|
let basins: DetailBasin[] = [];
|
|
let zSource = "";
|
|
let minSpacing = 20;
|
|
let busy = false;
|
|
let dirty = false;
|
|
/** 끌고 있는 마커 번호. null이면 잡은 것이 없다. */
|
|
let dragging: number | null = null;
|
|
/** 이번 끌기에서 마커가 실제로 움직였는지 — 안 움직였으면 "고르기"로 본다. */
|
|
let dragMoved = false;
|
|
/** 고른 세부유역 번호(백엔드 index). null이면 전부 같은 진하기로 보여 준다. */
|
|
let selectedBasin: number | null = null;
|
|
/** 마지막 프레임의 좌표 변환기 — 유역을 눌러 고를 때 폴리곤을 화면으로 옮기는 데 쓴다. */
|
|
let normalizerRef: Normalizer | null = null;
|
|
let requestSequence = 0;
|
|
|
|
const statusElement = document.createElement("span");
|
|
statusElement.className = "b04-map__watershed-status";
|
|
statusElement.hidden = true;
|
|
|
|
const menu = createMapContextMenu("b04-map");
|
|
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "b04-map__layer-button b04-map__layer-button--gis";
|
|
button.textContent = L("B04_Surface_Basin_Btn");
|
|
button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
|
|
button.title = L("B04_Surface_Basin_Btn_Tip");
|
|
|
|
// 관 마커와 세부유역은 늘 같이 본다 — 따로 끄면 관을 옮겨도 유역이 안 보여 판단할 수 없다.
|
|
// 그래서 토글은 하나뿐이다(2026-08-01 사용자 지시).
|
|
let shown = true;
|
|
const partButton = document.createElement("button");
|
|
partButton.type = "button";
|
|
partButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
|
|
partButton.textContent = L("B04_Surface_Basin_Part_Basins");
|
|
partButton.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
|
|
partButton.setAttribute("aria-pressed", "true");
|
|
partButton.addEventListener("click", () => {
|
|
shown = !shown;
|
|
partButton.classList.toggle("is-active", shown);
|
|
partButton.setAttribute("aria-pressed", String(shown));
|
|
if (!shown) menu.close();
|
|
onChange();
|
|
});
|
|
const partButtons = [partButton];
|
|
|
|
function say(text: string): void {
|
|
statusElement.textContent = text;
|
|
statusElement.hidden = text === "";
|
|
}
|
|
|
|
function summary(): string {
|
|
const count = (source: PipeSource): number =>
|
|
pipes.filter((pipe) => pipe.source === source).length;
|
|
const line = L("B04_Surface_Basin_Summary")
|
|
.replace("{pipes}", String(pipes.length))
|
|
.replace("{stream}", String(count("stream")))
|
|
.replace("{spacing}", String(count("spacing")))
|
|
.replace("{user}", String(count("user")))
|
|
.replace("{basins}", String(basins.length))
|
|
.replace("{source}", zSource || "-");
|
|
const picked = basins.find((basin) => basin.index === selectedBasin);
|
|
const detail = picked
|
|
? ` · ${L("B04_Surface_Basin_Selected")
|
|
.replace("{index}", String(picked.index))
|
|
.replace("{chainage}", picked.chainage_m.toFixed(1))
|
|
.replace("{area}", formatArea(picked.area_m2))
|
|
.replace("{relief}", picked.relief_m.toFixed(1))
|
|
.replace("{flow}", String(Math.round(picked.flow_length_m)))}`
|
|
: "";
|
|
return (dirty ? `${line} · ${L("B04_Surface_Basin_Edited")}` : line) + detail;
|
|
}
|
|
|
|
function pipeScreen(view: ViewState, chainage: number): [number, number] | null {
|
|
if (!meta) return null;
|
|
const point = pointAtChainage(samples, chainage);
|
|
return point ? metricToScreen(meta, view, point.x, point.y) : null;
|
|
}
|
|
|
|
/** 마커 히트 판정. 가장 가까운 것 하나. */
|
|
function hitPipe(view: ViewState, x: number, y: number): number | null {
|
|
let hit: number | null = null;
|
|
let best = Number.POSITIVE_INFINITY;
|
|
pipes.forEach((pipe, index) => {
|
|
const screen = pipeScreen(view, pipe.chainage);
|
|
if (!screen) return;
|
|
const distance = Math.hypot(screen[0] - x, screen[1] - y);
|
|
if (distance <= PIPE_RADIUS + PIPE_HIT_SLACK && distance < best) {
|
|
hit = index;
|
|
best = distance;
|
|
}
|
|
});
|
|
return hit;
|
|
}
|
|
|
|
/** 화면 좌표를 계획선 위 누가거리로 스냅한다. 계획선에서 멀면 null. */
|
|
function snap(view: ViewState, x: number, y: number): number | null {
|
|
if (!meta) return null;
|
|
const found = nearestChainage(
|
|
samples,
|
|
(point) => metricToScreen(meta as VWorldMeta, view, point.x, point.y),
|
|
x,
|
|
y,
|
|
);
|
|
return found && found.distance <= ROUTE_HIT_PX ? found.chainage : null;
|
|
}
|
|
|
|
/** 관끼리 최소 간격을 지키는지 본다(자기 자신은 제외). */
|
|
function tooClose(chainage: number, exceptIndex: number | null): boolean {
|
|
return pipes.some(
|
|
(pipe, index) =>
|
|
index !== exceptIndex && Math.abs(pipe.chainage - chainage) < minSpacing - 1e-6,
|
|
);
|
|
}
|
|
|
|
/** 화면 좌표를 담고 있는 세부유역 번호. 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). */
|
|
function basinAt(view: ViewState, x: number, y: number): number | null {
|
|
if (!normalizerRef) return null;
|
|
let hit: number | null = null;
|
|
let smallest = Number.POSITIVE_INFINITY;
|
|
basins.forEach((basin) => {
|
|
if (basin.polygon_lonlat.length < 3) return;
|
|
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
|
lonLatToScreen(normalizerRef as Normalizer, view, lon, lat),
|
|
);
|
|
if (!pointInRing(ring, x, y)) return;
|
|
if (basin.area_m2 < smallest) {
|
|
smallest = basin.area_m2;
|
|
hit = basin.index;
|
|
}
|
|
});
|
|
return hit;
|
|
}
|
|
|
|
/** 관 마커를 고르면 그 관이 받는 유역을 강조한다(둘은 같은 누가거리로 짝지어져 있다). */
|
|
function selectBasinOfPipe(pipeIndex: number): void {
|
|
const pipe = pipes[pipeIndex];
|
|
if (!pipe) return;
|
|
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51);
|
|
selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null;
|
|
say(summary());
|
|
}
|
|
|
|
function markEdited(): void {
|
|
dirty = true;
|
|
say(summary());
|
|
onChange();
|
|
}
|
|
|
|
/** 응답을 화면 상태로 옮긴다. */
|
|
function apply(response: Awaited<ReturnType<typeof fetchDetailPipePoints>>): void {
|
|
pipes = response.pipe_points.map((point) => ({
|
|
chainage: point.chainage_m,
|
|
source: point.source,
|
|
}));
|
|
basins = response.basins;
|
|
zSource = response.z_source;
|
|
minSpacing = response.min_spacing_m;
|
|
dirty = false;
|
|
// 유역 구성이 바뀌었으므로 이전 강조는 의미를 잃는다.
|
|
selectedBasin = null;
|
|
say(summary());
|
|
}
|
|
|
|
/** 세부유역을 다시 나눈다. 편집 중인 관 목록을 그대로 보낸다. */
|
|
async function recompute(): Promise<void> {
|
|
if (!projectId || busy) return;
|
|
busy = true;
|
|
button.disabled = true;
|
|
button.textContent = L("B04_Surface_Basin_Btn_Busy");
|
|
const sequence = ++requestSequence;
|
|
try {
|
|
const response = await computeDetailBasins(
|
|
projectId,
|
|
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
|
|
);
|
|
if (sequence !== requestSequence) return;
|
|
apply(response);
|
|
} catch (error) {
|
|
if (sequence !== requestSequence) return;
|
|
say(error instanceof Error ? error.message : L("B04_Surface_Basin_Failed"));
|
|
} finally {
|
|
busy = false;
|
|
button.disabled = false;
|
|
button.textContent = L("B04_Surface_Basin_Btn");
|
|
onChange();
|
|
}
|
|
}
|
|
|
|
/** 저장분(없으면 자동 생성분)을 불러온다. 지도를 열 때 조용히 돈다. */
|
|
async function loadSaved(): Promise<void> {
|
|
if (!projectId) return;
|
|
const sequence = ++requestSequence;
|
|
try {
|
|
const response = await fetchDetailPipePoints(projectId);
|
|
if (sequence !== requestSequence) return;
|
|
apply(response);
|
|
} catch {
|
|
if (sequence !== requestSequence) return;
|
|
// 배수유역 분석 전이면 여기서 실패하는 것이 정상이다 — 조용히 비워 둔다.
|
|
pipes = [];
|
|
basins = [];
|
|
say("");
|
|
}
|
|
onChange();
|
|
}
|
|
|
|
button.addEventListener("click", () => {
|
|
void recompute();
|
|
});
|
|
|
|
return {
|
|
button,
|
|
partButtons,
|
|
statusElement,
|
|
menuElement: menu.element,
|
|
setProject(next) {
|
|
projectId = next;
|
|
pipes = [];
|
|
basins = [];
|
|
dirty = false;
|
|
say("");
|
|
void loadSaved();
|
|
},
|
|
setRoute(points, nextMeta) {
|
|
meta = nextMeta;
|
|
samples = resampleRoute(points);
|
|
},
|
|
clear() {
|
|
pipes = [];
|
|
basins = [];
|
|
zSource = "";
|
|
dirty = false;
|
|
dragging = null;
|
|
selectedBasin = null;
|
|
requestSequence += 1;
|
|
menu.close();
|
|
say("");
|
|
},
|
|
handlePointerDown(view, x, y) {
|
|
menu.close();
|
|
if (!shown || pipes.length === 0) return false;
|
|
const hit = hitPipe(view, x, y);
|
|
if (hit === null) return false;
|
|
dragging = hit;
|
|
dragMoved = false;
|
|
return true;
|
|
},
|
|
handlePointerMove(view, x, y) {
|
|
if (dragging === null) return false;
|
|
const chainage = snap(view, x, y);
|
|
// 계획선에서 벗어난 위치는 무시한다 — 관은 늘 노선 위에 있어야 한다.
|
|
if (chainage === null || tooClose(chainage, dragging)) return true;
|
|
if (Math.abs(pipes[dragging].chainage - chainage) < 1e-6) return true;
|
|
pipes[dragging] = { chainage, source: "user" };
|
|
pipes.sort((left, right) => left.chainage - right.chainage);
|
|
dragging = pipes.findIndex((pipe) => pipe.chainage === chainage);
|
|
dragMoved = true;
|
|
markEdited();
|
|
return true;
|
|
},
|
|
handlePointerUp() {
|
|
if (dragging === null) return false;
|
|
const released = dragging;
|
|
dragging = null;
|
|
// 잡았다가 그대로 뗐으면 옮긴 것이 아니라 고른 것이다 — 그 관이 받는 유역을 강조한다.
|
|
if (!dragMoved) selectBasinOfPipe(released);
|
|
onChange();
|
|
return true;
|
|
},
|
|
handleClick(view, x, y) {
|
|
if (!shown) return false;
|
|
const hit = basinAt(view, x, y);
|
|
// 유역 밖을 누르면 강조를 푼다. 이미 아무것도 안 골랐으면 다른 오버레이에 클릭을 넘긴다.
|
|
if (hit === null && selectedBasin === null) return false;
|
|
selectedBasin = hit === selectedBasin ? null : hit;
|
|
say(summary());
|
|
onChange();
|
|
return true;
|
|
},
|
|
handleContextMenu(view, x, y) {
|
|
menu.close();
|
|
if (!shown) return false;
|
|
const hit = hitPipe(view, x, y);
|
|
if (hit !== null) {
|
|
menu.open(x, y, [
|
|
[
|
|
L("B04_Surface_Basin_Menu_Delete"),
|
|
() => {
|
|
pipes.splice(hit, 1);
|
|
markEdited();
|
|
},
|
|
],
|
|
]);
|
|
return true;
|
|
}
|
|
const chainage = snap(view, x, y);
|
|
if (chainage === null) return false;
|
|
menu.open(x, y, [
|
|
[
|
|
L("B04_Surface_Basin_Menu_Add"),
|
|
() => {
|
|
if (tooClose(chainage, null)) {
|
|
say(L("B04_Surface_Basin_TooClose").replace("{min}", String(minSpacing)));
|
|
onChange();
|
|
return;
|
|
}
|
|
pipes.push({ chainage, source: "user" });
|
|
pipes.sort((left, right) => left.chainage - right.chainage);
|
|
markEdited();
|
|
},
|
|
],
|
|
]);
|
|
return true;
|
|
},
|
|
async commit() {
|
|
if (!projectId) return 0;
|
|
const response = await saveDetailPipePoints(
|
|
projectId,
|
|
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
|
|
);
|
|
apply(response);
|
|
onChange();
|
|
return response.pipe_count;
|
|
},
|
|
draw(context, normalizer, view) {
|
|
normalizerRef = normalizer;
|
|
if (!shown) return;
|
|
// 유역 번호는 **맨 마지막에** 얹는다 — 채움과 함께 그리면 관 마커에 가려진다
|
|
// (2026-08-01 사용자 지시). 자리는 채움을 그리면서 같이 모아 둔다.
|
|
const labels: Array<{ index: number; number: number; point: [number, number] }> = [];
|
|
if (normalizer && basins.length > 0) {
|
|
// 유역 번호를 얹을 자리는 폴리곤을 그리면서 같이 모은다 — 화면 좌표를 두 번 계산하지
|
|
// 않는다. 번호는 채움 위에 한꺼번에 얹어야 이웃 유역 채움에 덮이지 않는다.
|
|
context.save();
|
|
context.lineJoin = "round";
|
|
basins.forEach((basin, index) => {
|
|
if (basin.polygon_lonlat.length < 3) return;
|
|
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
|
lonLatToScreen(normalizer, view, lon, lat),
|
|
);
|
|
context.beginPath();
|
|
ring.forEach(([px, py], order) => {
|
|
if (order === 0) context.moveTo(px, py);
|
|
else context.lineTo(px, py);
|
|
});
|
|
context.closePath();
|
|
// 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게.
|
|
const muted = selectedBasin !== null && selectedBasin !== basin.index;
|
|
const alpha = muted
|
|
? BASIN_ALPHA_MUTED
|
|
: selectedBasin === basin.index
|
|
? BASIN_ALPHA_SELECTED
|
|
: BASIN_ALPHA_PLAIN;
|
|
context.fillStyle = basinColor(index, alpha);
|
|
context.fill();
|
|
context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95);
|
|
context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8;
|
|
context.stroke();
|
|
if (!muted) labels.push({ index, number: basin.index, point: ringCentroid(ring) });
|
|
});
|
|
context.restore();
|
|
}
|
|
if (pipes.length === 0) {
|
|
drawBasinNumbers(context, labels);
|
|
return;
|
|
}
|
|
context.save();
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
pipes.forEach((pipe, index) => {
|
|
const screen = pipeScreen(view, pipe.chainage);
|
|
if (!screen) return;
|
|
const [x, y] = screen;
|
|
const [token, fallback] = PIPE_COLORS[pipe.source];
|
|
context.beginPath();
|
|
context.arc(x, y, PIPE_RADIUS, 0, Math.PI * 2);
|
|
context.fillStyle = themeColor(token, fallback);
|
|
context.fill();
|
|
context.lineWidth = index === dragging ? 3 : 1.6;
|
|
context.strokeStyle = haloColor();
|
|
context.stroke();
|
|
context.font = "bold 10px sans-serif";
|
|
context.fillStyle = themeColor("--map-marker-text", "#111827");
|
|
context.fillText(String(index + 1), x, y);
|
|
});
|
|
context.restore();
|
|
// 유역 번호는 맨 마지막 — 관 마커에도 가리지 않게 한다(2026-08-01 사용자 지시).
|
|
drawBasinNumbers(context, labels);
|
|
},
|
|
dispose() {
|
|
requestSequence += 1;
|
|
menu.close();
|
|
},
|
|
};
|
|
}
|