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>
This commit is contained in:
2026-08-02 00:14:37 +09:00
co-authored by Claude Opus 5
parent a7f09a827e
commit 32fd199dbf
15 changed files with 529 additions and 226 deletions
@@ -8,6 +8,8 @@
* 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(2026-08-01 사용자 지시).
* ========================================================================== */
import "@ui/ui_template_flow_legend.css";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import type { RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples";
@@ -100,3 +102,70 @@ export function drawStrengthLine(
}
context.restore();
}
/** 범례 눈금 수(맨 위=최대, 맨 아래=0). 5개면 로그 눈금이 촘촘하지도 성기지도 않다. */
const LEGEND_TICKS = 5;
export interface FlowLegend {
/** 지도 뷰포트에 append할 요소. 위치는 CSS가 정한다(우측 세로). */
root: HTMLElement;
/** 최대 유입면적(㎡)을 넘겨 눈금을 갱신한다. 0 이하이거나 `visible=false`면 감춘다. */
update: (maxAreaM2: number, visible: boolean) => void;
}
/** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */
function formatLegendArea(areaM2: number): string {
if (areaM2 >= 10000) return `${(areaM2 / 10000).toFixed(1)}ha`;
if (areaM2 >= 1000) return `${Math.round(areaM2 / 100) / 10}k㎡`;
return `${Math.round(areaM2)}`;
}
/**
* 유입 강도 색띠 범례. 색은 화면과 **같은 색띠**를, 눈금은 **같은 로그 정규화**를 쓴다 —
* 다른 규칙으로 그리면 범례가 오히려 오독을 만든다(2026-08-02 사용자 지시).
*
* 색칠은 `normalizeStrength()`가 log1p 비율을 쓰므로, 막대 위치 t에 해당하는 값은
* `expm1(t · log1p(max))`로 되돌린다.
*/
export function createFlowLegend(): FlowLegend {
const root = document.createElement("div");
root.className = "ui-flow-legend";
root.hidden = true;
const title = document.createElement("span");
title.className = "ui-flow-legend__title";
title.textContent = ui_locales.B04_Surface_Flow_Legend_Title[currentLanguageIndex];
const bar = document.createElement("div");
bar.className = "ui-flow-legend__bar";
const ticks = document.createElement("div");
ticks.className = "ui-flow-legend__ticks";
const tickLabels = Array.from({ length: LEGEND_TICKS }, () => {
const label = document.createElement("span");
ticks.append(label);
return label;
});
root.append(title, bar, ticks);
return {
root,
update(maxAreaM2, visible) {
root.hidden = !visible || !(maxAreaM2 > 0);
if (root.hidden) return;
// 색띠는 화면과 같은 색을 그대로 쓴다(아래가 적음 → 위가 많음).
const stops = Array.from({ length: 11 }, (_, index) => {
const ratio = index / 10;
return `${rampColor(ratio)} ${(ratio * 100).toFixed(0)}%`;
});
bar.style.background = `linear-gradient(to top, ${stops.join(", ")})`;
const span = Math.log1p(maxAreaM2);
tickLabels.forEach((label, index) => {
// 위에서부터 최대 → 0 순으로 적는다(막대와 같은 방향).
const ratio = 1 - index / (LEGEND_TICKS - 1);
label.textContent = formatLegendArea(Math.expm1(ratio * span));
});
},
};
}
@@ -22,6 +22,7 @@ import {
} from "./B04_wf1_Surface_UI_MapRender";
import {
buildStrengthArray,
createFlowLegend,
drawStrengthLine,
normalizeStrength,
rampColor,
@@ -45,6 +46,8 @@ export interface FlowStrengthOverlay {
button: HTMLButtonElement;
/** 유입 집중점 마커 전용 토글("집중유역"). 기본 꺼짐 — 마커가 관 마커와 겹쳐 읽기 어렵다. */
markerButton: HTMLButtonElement;
/** 색띠 범례. 지도 뷰포트에 넣으면 CSS가 우측 세로로 세운다. */
legendElement: HTMLElement;
visible: () => boolean;
/** 선택 요약 문구(없으면 빈 문자열). */
status: () => string;
@@ -78,10 +81,18 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
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();
});
@@ -226,6 +237,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
return {
button,
markerButton,
legendElement: legend.root,
visible: () => shown,
status: () => statusText,
setProject(nextProjectId) {
@@ -235,6 +247,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
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 = [];
@@ -251,6 +264,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
clear() {
strength = new Float64Array(0);
maxStrength = 0;
legend.update(0, false);
hotspots = [];
selected = null;
selectionRings = [];
@@ -0,0 +1,149 @@
/* =============================================================================
* 2D 지도 오버레이 도형 (B04 지도 · B05 배수유역도 공용)
*
* 유역 채움·번호 배지·분수령 파선·상류 세류망처럼 **배경 레이어 위에 얹는** 도형만 모았다.
* 좌표 변환과 레이어 렌더는 `B04_wf1_Surface_UI_MapRender.ts`가 맡는다 — 그 파일이 700줄
* 한계에 닿아 분리했다.
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
/** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
import {
haloColor,
lonLatToScreen,
type Normalizer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
export type FilledRing = {
ring: ReadonlyArray<readonly [number, number]>;
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
label?: string;
};
/**
* lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다.
* 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다.
*/
export function drawFilledRing(
context: CanvasRenderingContext2D,
entry: FilledRing,
normalizer: Normalizer,
view: ViewState,
color: string,
): void {
if (entry.ring.length < 3) return;
let sumX = 0;
let sumY = 0;
context.beginPath();
entry.ring.forEach(([lon, lat], index) => {
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
sumX += x;
sumY += y;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = color;
context.fill();
context.strokeStyle = color;
context.lineWidth = 1.6;
context.stroke();
if (!entry.label) return;
drawRingBadge(context, [sumX / entry.ring.length, sumY / entry.ring.length], color, entry.label);
}
/** 유역 번호 배지 한 개(원 + 숫자). 화면 좌표를 그대로 받는다.
*
* 채움과 따로 떼어 둔 이유: 번호는 **맨 위에** 있어야 한다. 채움과 함께 그리면 그 위에
* 얹히는 등고선·화살표·관 마커에 가려 읽을 수 없다(2026-08-01 사용자 지시). */
export function drawRingBadge(
context: CanvasRenderingContext2D,
center: readonly [number, number],
color: string,
label: string,
): void {
const [centerX, centerY] = center;
context.save();
context.beginPath();
context.arc(centerX, centerY, 11, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
context.strokeStyle = haloColor();
context.lineWidth = 1.5;
context.stroke();
context.font = "600 12px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillStyle = themeColor("--map-label-text", "#1f2937");
context.fillText(label, centerX, centerY);
context.restore();
}
/** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */
export function ringCenterOnScreen(
ring: ReadonlyArray<readonly [number, number]>,
normalizer: Normalizer,
view: ViewState,
): [number, number] {
let sumX = 0;
let sumY = 0;
ring.forEach(([lon, lat]) => {
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
sumX += x;
sumY += y;
});
return [sumX / ring.length, sumY / ring.length];
}
/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */
export function drawUpstreamLines(
context: CanvasRenderingContext2D,
lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
normalizer: Normalizer,
view: ViewState,
): void {
if (lines.length === 0) return;
context.save();
context.setLineDash([]);
context.lineWidth = 4;
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = upstreamLineColor();
lines.forEach((line) => {
if (line.length < 2) return;
context.beginPath();
line.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.stroke();
});
context.restore();
}
/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */
export function drawRidgeRing(
context: CanvasRenderingContext2D,
ring: ReadonlyArray<readonly [number, number]>,
normalizer: Normalizer,
view: ViewState,
): void {
if (ring.length < 3) return;
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.save();
context.strokeStyle = themeColor("--map-basin-outline", "#92400e");
context.lineWidth = 1.8;
context.setLineDash([7, 4]);
context.stroke();
context.restore();
}
@@ -23,7 +23,6 @@ export type GeoJsonCollection = {
export type MarkerKind = "dot" | "x";
/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
/** 배경 위에서 선·글자가 묻히지 않게 뒤에 까는 흰 테두리. */
export const haloColor = (): string => themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
@@ -565,145 +564,3 @@ export function drawPreparedLabels(
}
/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */
export type FilledRing = {
ring: ReadonlyArray<readonly [number, number]>;
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
label?: string;
};
/**
* lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다.
* 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다.
*/
export function drawFilledRing(
context: CanvasRenderingContext2D,
entry: FilledRing,
normalizer: Normalizer,
view: ViewState,
color: string,
): void {
if (entry.ring.length < 3) return;
const affine = affineOf(view);
let sumX = 0;
let sumY = 0;
context.beginPath();
entry.ring.forEach(([lon, lat], index) => {
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
const x = nx * affine.ax + affine.bx;
const y = ny * affine.ay + affine.by;
sumX += x;
sumY += y;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = color;
context.fill();
context.strokeStyle = color;
context.lineWidth = 1.6;
context.stroke();
if (!entry.label) return;
drawRingBadge(context, [sumX / entry.ring.length, sumY / entry.ring.length], color, entry.label);
}
/** 유역 번호 배지 한 개(원 + 숫자). 화면 좌표를 그대로 받는다.
*
* 채움과 따로 떼어 둔 이유: 번호는 **맨 위에** 있어야 한다. 채움과 함께 그리면 그 위에
* 얹히는 등고선·화살표·관 마커에 가려 읽을 수 없다(2026-08-01 사용자 지시). */
export function drawRingBadge(
context: CanvasRenderingContext2D,
center: readonly [number, number],
color: string,
label: string,
): void {
const [centerX, centerY] = center;
context.save();
context.beginPath();
context.arc(centerX, centerY, 11, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
context.strokeStyle = haloColor();
context.lineWidth = 1.5;
context.stroke();
context.font = "600 12px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillStyle = themeColor("--map-label-text", "#1f2937");
context.fillText(label, centerX, centerY);
context.restore();
}
/** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */
export function ringCenterOnScreen(
ring: ReadonlyArray<readonly [number, number]>,
normalizer: Normalizer,
view: ViewState,
): [number, number] {
const affine = affineOf(view);
let sumX = 0;
let sumY = 0;
ring.forEach(([lon, lat]) => {
sumX += ((lon - normalizer.lonMin) / normalizer.lonRange) * affine.ax + affine.bx;
sumY += (1 - (lat - normalizer.latMin) / normalizer.latRange) * affine.ay + affine.by;
});
return [sumX / ring.length, sumY / ring.length];
}
/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */
export function drawUpstreamLines(
context: CanvasRenderingContext2D,
lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
normalizer: Normalizer,
view: ViewState,
): void {
if (lines.length === 0) return;
const affine = affineOf(view);
context.save();
context.setLineDash([]);
context.lineWidth = 4;
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = upstreamLineColor();
lines.forEach((line) => {
if (line.length < 2) return;
context.beginPath();
line.forEach(([lon, lat], index) => {
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
const x = nx * affine.ax + affine.bx;
const y = ny * affine.ay + affine.by;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
});
context.restore();
}
/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */
export function drawRidgeRing(
context: CanvasRenderingContext2D,
ring: ReadonlyArray<readonly [number, number]>,
normalizer: Normalizer,
view: ViewState,
): void {
if (ring.length < 3) return;
const affine = affineOf(view);
context.beginPath();
ring.forEach(([lon, lat], index) => {
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
const x = nx * affine.ax + affine.bx;
const y = ny * affine.ay + affine.by;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.save();
context.strokeStyle = themeColor("--map-basin-outline", "#92400e");
context.lineWidth = 1.8;
context.setLineDash([7, 4]);
context.stroke();
context.restore();
}
@@ -308,7 +308,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
statusStack.append(watershed.statusElement, flowStatus, detailBasins.statusElement);
statusTopRight.append(watershed.busyElement);
// 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다.
viewport.append(detailBasins.menuElement);
// 강도 색띠 범례도 같은 자리에 얹는다(CSS가 우측 세로로 세운다).
viewport.append(detailBasins.menuElement, flowStrength.legendElement);
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
@@ -2,7 +2,8 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch";
import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows";
import { drawUpstreamLines, type Normalizer, type ViewState } from "./B04_wf1_Surface_UI_MapRender";
import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender";
import { drawUpstreamLines } from "./B04_wf1_Surface_UI_MapOverlays";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -2,11 +2,9 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { themeColor } from "@ui/ui_template_palette";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
computeDetailBasins,
fetchDetailPipePoints,
fetchVWorldMeta,
getVWorldMapUrl,
saveDetailPipePoints,
type DetailBasin,
@@ -16,12 +14,10 @@ import {
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
computeMapRect,
computeRouteView,
createNormalizer,
lonLatToScreen,
prepareLayer,
prepareMetricPolyline,
type GeoJsonCollection,
type MapRect,
type Normalizer,
type PreparedLayer,
@@ -29,7 +25,10 @@ import {
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import { type RoutePoint } from "./B05_wf2_Route_Api_Fetch";
import type { FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
import { buildStrengthArray } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import {
buildStrengthArray,
createFlowLegend,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import { resampleRoute } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
import { createProgressCircle } from "@ui/ui_template_progress";
@@ -39,6 +38,8 @@ import {
mountDrainageToggles,
basinColor,
DRAINAGE_LAYERS,
fetchDrainageLayers,
fitViewToRoute,
bindPipeContextMenu,
COLLAPSED_KEY,
MAX_PANEL_WIDTH_RATIO,
@@ -80,12 +81,16 @@ export interface DrainagePanel {
removePipe: (chainageM: number) => void;
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
savePipes: () => Promise<number>;
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
selectBasinByChainage: (chainageM: number | null) => void;
dispose: () => void;
}
export interface DrainagePanelCallbacks {
/** 관 목록이 바뀔 때마다 누가거리 목록을 넘긴다 — 종단 테이블 구조물 라인 동기화용. */
onPipesChanged?: (chainages: number[]) => void;
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
onBasinSelected?: (chainageM: number | null) => void;
}
export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel {
@@ -138,7 +143,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
progress.root.hidden = true;
// 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작).
const contextMenu = createMapContextMenu("b05-drainage");
viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element);
// 유입 강도 색띠 범례 — CSS가 지도 우측 세로로 세운다.
const legend = createFlowLegend();
viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element, legend.root);
/** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
@@ -241,6 +248,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
},
onStrength: (next) => {
showStrength = next;
legend.update(maxStrength, showStrength);
scheduleDraw();
},
onHotspots: (next) => {
@@ -327,8 +335,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const basin = pipe
? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51)
: null;
selectedBasin = basin ? basin.index : null;
renderBasinList();
selectBasin(basin ? basin.index : null);
}
function scheduleDraw(): void {
@@ -342,11 +349,22 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 그 유역만 진하게 강조한다. */
function renderBasinList(): void {
renderBasinRows(basinList, basins, selectedBasin, (index) => {
selectedBasin = selectedBasin === index ? null : index;
renderBasinList();
scheduleDraw();
});
renderBasinRows(basinList, basins, selectedBasin, (index) =>
selectBasin(selectedBasin === index ? null : index),
);
}
/** ** **. · · ·
* · (2026-08-02 ).
* `notify=false` . */
function selectBasin(index: number | null, notify = true): void {
if (selectedBasin === index) return;
selectedBasin = index;
renderBasinList();
scheduleDraw();
if (!notify) return;
const picked = basins.find((basin) => basin.index === index);
callbacks.onBasinSelected?.(picked ? picked.chainage_m : null);
}
/** . B04 ** · **
@@ -364,6 +382,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
);
strength = built.strength;
maxStrength = built.maximum;
legend.update(maxStrength, showStrength);
maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0);
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
pipeEditor.setPipes(
@@ -372,7 +391,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
reason: pipe.source,
})),
);
selectedBasin = null;
selectBasin(null, false);
renderBasinList();
syncPipeSelection();
summary.textContent = L("B05_Drainage_Summary")
@@ -446,30 +465,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
* (2026-08-01 ). .
* . */
function fitToRoute(): void {
scale = 1;
offsetX = 0;
offsetY = 0;
if (!meta || routePoints.length < 2) return;
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 rect = viewport.getBoundingClientRect();
const view = computeRouteView(
meta,
{ x_min: minX, x_max: maxX, y_min: minY, y_max: maxY },
Math.max(rect.width, 1),
Math.max(rect.height, 1),
);
scale = view.scale;
offsetX = view.offsetX;
offsetY = view.offsetY;
const fitted = fitViewToRoute(meta, routePoints, rect.width, rect.height);
scale = fitted.scale;
offsetX = fitted.offsetX;
offsetY = fitted.offsetY;
}
async function loadLayers(): Promise<void> {
@@ -483,18 +483,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
status.textContent = L("B05_Drainage_Status_LoadingBase");
showProgress(0, L("B05_Drainage_Status_LoadingBase"));
try {
const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite");
showProgress(1 / 3, L("B05_Drainage_Status_LoadingSheets"));
const loaded = await Promise.all(
DRAINAGE_LAYERS.map(async (layer) => {
try {
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
const data = await fetchCachedSheetLayer<GeoJsonCollection>(activeProjectId, layer);
return [layer, data] as const;
} catch {
return [layer, null] as const;
}
}),
const { meta: nextMeta, layers: loaded } = await fetchDrainageLayers(activeProjectId, () =>
showProgress(1 / 3, L("B05_Drainage_Status_LoadingSheets")),
);
if (sequence !== loadSequence) return;
meta = nextMeta;
@@ -592,10 +582,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
hit = basin.index;
}
});
if (hit === null && selectedBasin === null) return;
selectedBasin = hit === selectedBasin ? null : hit;
renderBasinList();
scheduleDraw();
selectBasin(hit === selectedBasin ? null : hit);
}
const stopDragging = (): void => {
@@ -648,6 +635,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
if (index < 0) return;
pipeEditor.moveTo(index, toChainage);
},
selectBasinByChainage(chainageM) {
const picked =
chainageM === null
? null
: (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null);
// 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단).
selectBasin(picked ? picked.index : null, false);
},
setPipeChainages(chainages) {
const next = reconcilePipes(pipeEditor.pipes(), chainages);
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
@@ -8,9 +8,13 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import { DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache";
import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
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";
@@ -325,3 +329,55 @@ export function reconcilePipes(
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 };
}
@@ -289,15 +289,20 @@ export function createPipeEditor(
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(String(position + 1), screen.x, screen.y);
// 누가거리 라벨 — 마커 우상단.
context.font = "10px sans-serif";
// 누가거리 라벨 — 마커 우상단. 위성사진·다크 배경에서도 읽히도록 흰 테두리를 깔고
// 그 위에 글자를 얹는다. 색만 테마 토큰으로 바꾸면 어두운 배경에서 묻힌다
// (2026-08-02 사용자 지시).
const label = `${pipe.chainage_m.toFixed(0)}m`;
const labelX = screen.x + radius + 3;
const labelY = screen.y - radius;
context.font = "600 10px sans-serif";
context.textAlign = "left";
context.lineJoin = "round";
context.lineWidth = 3;
context.strokeStyle = themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
context.strokeText(label, labelX, labelY);
context.fillStyle = themeColor("--map-label-text", "#1f2937");
context.fillText(
`${pipe.chainage_m.toFixed(0)}m`,
screen.x + radius + 3,
screen.y - radius,
);
context.fillText(label, labelX, labelY);
});
},
};
@@ -11,18 +11,20 @@
* ========================================================================== */
import {
drawFilledRing,
drawRingBadge,
ringCenterOnScreen,
drawPreparedLayer,
drawRidgeRing,
drawUpstreamLines,
routeLineColor,
ROUTE_LINE_WIDTH,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import {
drawFilledRing,
drawRidgeRing,
drawRingBadge,
drawUpstreamLines,
ringCenterOnScreen,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapOverlays";
import type { DetailBasin, VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
import { drawStrengthLine } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
+18 -13
View File
@@ -34,6 +34,7 @@ import {
} from "./B05_wf2_Route_UI_Markers";
import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel";
import { createRouteProfilePanel } from "./B05_wf2_Route_UI_Profile_Panel";
import { createSelectionSync } from "./B05_wf2_Route_UI_Selection";
import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
import {
irregularLabel,
@@ -209,6 +210,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
},
onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역을 고르면 그 관의 구조물 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
},
);
@@ -217,19 +220,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
* 3D·· , `selectionSyncing`
* ( ·) .
*/
function syncIrregularSelection(stationId: string | null): void {
if (selectionSyncing) return;
const prefix = irregularStationId("");
if (!stationId?.startsWith(prefix)) return;
const id = stationId.slice(prefix.length);
const station = irregularStations.find((entry) => entry.id === id);
selectionSyncing = true;
try {
panel.irregularStations.selectByChainage(station ? station.chainage_m : null);
} finally {
selectionSyncing = false;
}
}
let confirmedSurface: SurfaceModelSummary | null = null;
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
@@ -259,6 +249,19 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
} catch {
/* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */
}
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
stations: () => irregularStations,
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
selectionSyncing = value;
},
selectMarker: (id) => viewer.markers.selectStation(id),
selectGraph: (id) => profilePanel.setSelectedStation(id),
selectSidebar: (chainageM) => panel.irregularStations.selectByChainage(chainageM),
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
});
function persistUphillOverrides(): void {
try {
window.sessionStorage.setItem(
@@ -312,6 +315,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
// 사이드 패널에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다.
syncBasinHighlight(id);
} finally {
selectionSyncing = false;
}
@@ -228,6 +228,8 @@ export interface RouteProfilePanelCallbacks {
onPipeAdd?: (chainageM: number) => void;
/** 구조물 라인을 눌러 고름. */
onIrregularSelect?: (station: IrregularStation) => void;
/** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */
onBasinSelected?: (chainageM: number | null) => void;
}
export function createRouteProfilePanel(
@@ -262,6 +264,7 @@ export function createRouteProfilePanel(
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
const drainagePanel = createDrainagePanel({
onPipesChanged: (chainages) => callbacks?.onPipesChanged?.(chainages),
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
});
content.append(bodyWrap, drainagePanel.root);
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
@@ -0,0 +1,94 @@
/* =============================================================================
* (B05)
*
* 3D · · ·
* .
* (2026-08-02 ).
*
* `isSyncing` . "배관"
* (`origin: "pipe"`) .
* ========================================================================== */
import { irregularStationId, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
export interface SelectionSyncPorts {
/** 현재 구조물 측점 목록(수동 + 배관 투영분). */
stations: () => ReadonlyArray<IrregularStation>;
isSyncing: () => boolean;
setSyncing: (value: boolean) => void;
/** 3D 뷰어 마커 선택. */
selectMarker: (stationId: string | null) => void;
/** 종단 그래프 세로선 선택. */
selectGraph: (stationId: string | null) => void;
/** 좌측 구조물 폼 선택(누가거리 기준). */
selectSidebar: (chainageM: number | null) => void;
/** 배수유역도 세부유역 강조(누가거리 기준). */
selectBasin: (chainageM: number | null) => void;
}
export interface SelectionSync {
/** 그래프·3D에서 측점을 골랐을 때 — 좌측 폼과 배수유역도를 맞춘다. */
syncIrregularSelection: (stationId: string | null) => void;
/** 고른 측점이 배관이면 그 유역을 강조한다(아니면 강조 해제). */
syncBasinHighlight: (stationId: string | null) => void;
/** 배수유역도에서 유역을 골랐을 때 — 그래프·3D·좌측 폼을 맞춘다. */
selectStationOfPipe: (chainageM: number | null) => void;
}
/** 같은 누가거리로 볼 여유(m). 관 지점과 구조물 측점은 소수점 둘째 자리까지 같은 값을 쓴다. */
const SAME_CHAINAGE_M = 0.51;
export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
const prefix = irregularStationId("");
/** 측점 id로 구조물 측점을 찾는다(비정규 id가 아니면 undefined). */
function irregularOf(stationId: string | null): IrregularStation | undefined {
if (!stationId?.startsWith(prefix)) return undefined;
const id = stationId.slice(prefix.length);
return ports.stations().find((entry) => entry.id === id);
}
function syncBasinHighlight(stationId: string | null): void {
const station = irregularOf(stationId);
ports.selectBasin(station?.origin === "pipe" ? station.chainage_m : null);
}
return {
syncBasinHighlight,
syncIrregularSelection(stationId) {
if (ports.isSyncing()) return;
syncBasinHighlight(stationId);
// 규칙 측점을 고른 것이면 구조물 폼은 건드리지 않는다(고를 항목이 없다).
if (stationId !== null && !stationId.startsWith(prefix)) return;
const station = irregularOf(stationId);
ports.setSyncing(true);
try {
ports.selectSidebar(station ? station.chainage_m : null);
} finally {
ports.setSyncing(false);
}
},
selectStationOfPipe(chainageM) {
if (ports.isSyncing()) return;
const matched =
chainageM === null
? undefined
: ports
.stations()
.find(
(entry) =>
entry.origin === "pipe" &&
Math.abs(entry.chainage_m - chainageM) < SAME_CHAINAGE_M,
);
const id = matched ? irregularStationId(matched.id) : null;
ports.setSyncing(true);
try {
ports.selectMarker(id);
ports.selectGraph(id);
ports.selectSidebar(matched ? matched.chainage_m : null);
} finally {
ports.setSyncing(false);
}
},
};
}
+51
View File
@@ -0,0 +1,51 @@
/* 도로 유입 강도 색띠 범례 — 지도 우측에 세로로 세운다(B04 2D 지도 · B05 배수유역도 공용). */
.ui-flow-legend {
position: absolute;
z-index: 5;
top: 50%;
right: var(--spacing-12);
display: flex;
align-items: stretch;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
/* 위성사진 위에서도 눈금이 읽히도록 반투명 배경 + 블러를 깐다. */
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
backdrop-filter: blur(3px);
gap: var(--spacing-8);
pointer-events: none;
transform: translateY(-50%);
}
.ui-flow-legend[hidden] {
display: none;
}
/* 색띠 자체 — 아래가 적음(파랑), 위가 많음(빨강). */
.ui-flow-legend__bar {
width: 12px;
height: 140px;
flex: 0 0 auto;
border: 1px solid var(--color-border);
border-radius: 3px;
}
.ui-flow-legend__ticks {
display: flex;
flex-direction: column;
justify-content: space-between;
color: var(--color-text-body);
font-size: 10px;
line-height: 1;
white-space: nowrap;
}
.ui-flow-legend__title {
color: var(--color-text-body);
font-size: 10px;
font-weight: 600;
/* 세로 막대 옆에 세로쓰기로 붙여 폭을 아낀다. */
writing-mode: vertical-rl;
text-orientation: mixed;
}
+1
View File
@@ -709,6 +709,7 @@ export const ui_locales = {
"도로 1m 구간마다 그 자리로 모이는 상류 면적을 색으로 칠하고, 물이 특히 많이 모이는 자리를 마커로 찍습니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
"Colors each 1m stretch of road by the upstream area draining into it, and marks the spots that collect the most. Click a marker to outline the cells that drain into it.",
],
B04_Surface_Flow_Legend_Title: ["흐름강도", "Flow strength"],
B04_Surface_Flow_Hotspots: ["집중유역", "Inflow hotspots"],
B04_Surface_Flow_Hotspots_Tip: [
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",