단일 링 자료형이 곧 중첩·빈공간이었다. 아래 유역이 위 유역을 감싸면 구멍이 사라져 위 유역을 통째로 덮고(합성 실측 256㎡ 전량), 한 관의 유역이 두 조각이면 작은 쪽이 사라져 빈공간이 됐다(144㎡). - `polygon_parts()` 신설 — 조각마다 [외곽 링, 구멍 링...], 넓은 조각부터. - `WatershedBasin.boundary_parts` 로 교체. `boundary_xy`(가장 넓은 조각의 외곽)와 `boundary_rings`(편 링 목록)는 파생 속성이라 옛 소비처는 그대로 동작한다. - API 에 `polygon_rings_lonlat` 추가. 저장 GeoJSON 은 구멍을 가진 Polygon, 조각이 여럿이면 MultiPolygon. - 캔버스 4곳(B04 유역화면 채움·선택, B05 배수유역도 채움·선택)은 even-odd 로 한 번에 채우고 `pointInRings()` 로 판정 — 구멍 안을 눌러도 바깥 유역이 잡히지 않는다. 중복이던 지역 `pointInRing` 은 삭제하고 공용 것으로 통일. 검증: 단위 5건 신규(도넛 구멍 보존·조각 2개 보존·파생 속성·빈 경계·조립 경로 전체에서 링 2개), 전체 168 passed, typecheck 통과. 브라우저 — API 가 유역 22개에 링 목록을 실어 보내고, 캔버스 even-odd 실측(가운데 알파 0 / 고리 255)으로 구멍이 실제로 뚫린다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
7.4 KiB
TypeScript
195 lines
7.4 KiB
TypeScript
/* =============================================================================
|
|
* 배수유역도 캔버스 렌더러 (B05)
|
|
*
|
|
* 한 프레임에 무엇을 어떤 순서로 그릴지만 담는다. 상태는 갖지 않고 패널이 매 프레임 넘긴
|
|
* 묶음(`DrainageScene`)만 본다 — 패널 본체(`_UI_Drainage_Panel.ts`)가 700줄 한계에 닿아
|
|
* 분리했다.
|
|
*
|
|
* 쌓는 순서(아래 → 위)
|
|
* 세부유역 채움 · 전체 유역 외곽선 → 도엽 레이어 → 상류 세류 → 계획선 →
|
|
* 유입 강도 색칠 → 평균 흐름 화살표 → 유입 집중점 → 배관 마커
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
drawPreparedLayer,
|
|
routeLineColor,
|
|
ROUTE_LINE_WIDTH,
|
|
type Normalizer,
|
|
type PreparedLayer,
|
|
type ViewState,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import {
|
|
drawFilledRing,
|
|
drawRidgeRing,
|
|
drawRingBadge,
|
|
drawUpstreamLines,
|
|
ringCenterOnScreen,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
|
import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { drawFlowArrows, type FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
|
|
import { drawStrengthLine } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
|
import type { RoutePoint } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
|
|
import {
|
|
basinColor,
|
|
createMetricProjector,
|
|
drawHotspots,
|
|
DRAINAGE_LAYERS,
|
|
layerColor,
|
|
type DrainageLayer,
|
|
} from "./B05_Profile_UI_Drainage_Parts";
|
|
import type { PipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
|
|
|
export interface DrainageScene {
|
|
meta: VWorldMeta | null;
|
|
normalizer: Normalizer | null;
|
|
basins: ReadonlyArray<DetailBasin>;
|
|
selectedBasin: number | null;
|
|
mainBoundary: ReadonlyArray<[number, number]>;
|
|
preparedLayers: ReadonlyMap<DrainageLayer, PreparedLayer>;
|
|
activeLayers: ReadonlySet<DrainageLayer>;
|
|
routeLayer: PreparedLayer | null;
|
|
upstreamLines: ReadonlyArray<Array<[number, number]>>;
|
|
showUpstream: boolean;
|
|
strengthSamples: ReadonlyArray<RoutePoint>;
|
|
strength: Float64Array;
|
|
maxStrength: number;
|
|
showStrength: boolean;
|
|
flowArrows: ReadonlyArray<FlowArrow>;
|
|
arrowSpacingM: number;
|
|
showArrows: boolean;
|
|
hotspots: ReadonlyArray<{ chainage: number; area: number }>;
|
|
maxHotspotArea: number;
|
|
showHotspots: boolean;
|
|
pipeEditor: PipeEditor;
|
|
pipeColor: (chainage: number, position: number) => string;
|
|
/** 선택된 측점의 누가거리(m). 계획선 위 그 자리에 선택 표식을 그린다(null=없음). */
|
|
markedChainage: number | null;
|
|
}
|
|
|
|
export function drawDrainageScene(
|
|
context: CanvasRenderingContext2D,
|
|
view: ViewState,
|
|
scene: DrainageScene,
|
|
): void {
|
|
const { meta, normalizer } = scene;
|
|
// 유역 번호는 **맨 위에** 얹는다 — 채움과 함께 그리면 등고선·화살표·관 마커에 가려진다
|
|
// (2026-08-01 사용자 지시). 자리는 채움을 그리면서 같이 모아 둔다.
|
|
const badges: Array<{ center: [number, number]; color: string; label: string }> = [];
|
|
// 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다.
|
|
if (normalizer) {
|
|
scene.basins.forEach((basin) => {
|
|
const color = basinColor(basin.index);
|
|
if (basin.polygon_lonlat.length >= 3) {
|
|
badges.push({
|
|
center: ringCenterOnScreen(basin.polygon_lonlat, normalizer, view),
|
|
color,
|
|
label: String(basin.index),
|
|
});
|
|
}
|
|
drawFilledRing(
|
|
context,
|
|
{ ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat },
|
|
normalizer,
|
|
view,
|
|
// 하나를 고르면 나머지는 옅게 물러난다.
|
|
scene.selectedBasin === null || scene.selectedBasin === basin.index
|
|
? color
|
|
: color.replace(/0\.45\)$/, "0.18)"),
|
|
);
|
|
});
|
|
// 전체 유역 외곽선 = 분수령(능선). 해석 결과를 그대로 그린다 — 손으로 고치지 않는다.
|
|
if (scene.mainBoundary.length > 2) {
|
|
drawRidgeRing(context, scene.mainBoundary as Array<[number, number]>, normalizer, view);
|
|
}
|
|
}
|
|
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
|
|
DRAINAGE_LAYERS.forEach((layer) => {
|
|
if (!scene.activeLayers.has(layer)) return;
|
|
const prepared = scene.preparedLayers.get(layer);
|
|
if (!prepared) return;
|
|
context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5;
|
|
context.strokeStyle = layerColor(layer);
|
|
drawPreparedLayer(context, prepared, view, "dot");
|
|
});
|
|
// 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래. B04와 같은 공용 렌더러.
|
|
if (scene.showUpstream && normalizer && scene.upstreamLines.length > 0) {
|
|
drawUpstreamLines(
|
|
context,
|
|
scene.upstreamLines as Array<Array<[number, number]>>,
|
|
normalizer,
|
|
view,
|
|
);
|
|
}
|
|
if (scene.routeLayer) {
|
|
context.lineWidth = ROUTE_LINE_WIDTH;
|
|
context.strokeStyle = routeLineColor();
|
|
drawPreparedLayer(context, scene.routeLayer, view, "dot");
|
|
}
|
|
if (!meta) {
|
|
scene.pipeEditor.draw(context, view, scene.pipeColor);
|
|
drawBadges(context, badges);
|
|
return;
|
|
}
|
|
const projector = createMetricProjector(meta, view);
|
|
// 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다.
|
|
if (scene.showStrength && scene.strength.length > 0) {
|
|
drawStrengthLine(context, scene.strengthSamples, scene.strength, scene.maxStrength, (point) =>
|
|
projector.toScreen(point.x, point.y),
|
|
);
|
|
}
|
|
// 평균 흐름 화살표 — 좌표는 사업지 CRS(m)라 도엽 메타로 바로 화면에 옮긴다.
|
|
if (scene.showArrows && scene.flowArrows.length > 0) {
|
|
drawFlowArrows(
|
|
context,
|
|
scene.flowArrows as FlowArrow[],
|
|
scene.arrowSpacingM,
|
|
projector.pxPerMeter,
|
|
projector.toScreen,
|
|
view,
|
|
);
|
|
}
|
|
// 유입 집중점 마커 — 강도 색칠 위, 배관 마커 아래.
|
|
if (scene.showHotspots && scene.hotspots.length > 0) {
|
|
drawHotspots(
|
|
context,
|
|
projector.toScreen,
|
|
scene.strengthSamples,
|
|
scene.hotspots,
|
|
scene.maxHotspotArea,
|
|
);
|
|
}
|
|
// 배관(관 매설) 마커 — 계획선 위.
|
|
scene.pipeEditor.draw(context, view, scene.pipeColor);
|
|
// 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의
|
|
// 배수유역도 쪽 표현. 유역이 없는 구조물 측점도 위치가 보여야 한다).
|
|
if (scene.markedChainage !== null && scene.strengthSamples.length > 0) {
|
|
const index = Math.min(
|
|
scene.strengthSamples.length - 1,
|
|
Math.max(0, Math.round(scene.markedChainage)),
|
|
);
|
|
const sample = scene.strengthSamples[index];
|
|
const [x, y] = projector.toScreen(sample.x, sample.y);
|
|
context.save();
|
|
context.translate(x, y);
|
|
context.rotate(Math.PI / 4);
|
|
const half = 7;
|
|
context.beginPath();
|
|
context.rect(-half, -half, half * 2, half * 2);
|
|
context.fillStyle = "rgba(255, 196, 0, 0.35)";
|
|
context.fill();
|
|
context.lineWidth = 2.5;
|
|
context.strokeStyle = "#ff9800";
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
// 유역 번호 — 무엇에도 가리지 않게 맨 마지막.
|
|
drawBadges(context, badges);
|
|
}
|
|
|
|
function drawBadges(
|
|
context: CanvasRenderingContext2D,
|
|
badges: ReadonlyArray<{ center: [number, number]; color: string; label: string }>,
|
|
): void {
|
|
badges.forEach(({ center, color, label }) => drawRingBadge(context, center, color, label));
|
|
}
|