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 사용자 지시). * 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(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 { themeColor } from "@ui/ui_template_palette";
import type { RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples"; import type { RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples";
@@ -100,3 +102,70 @@ export function drawStrengthLine(
} }
context.restore(); 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"; } from "./B04_wf1_Surface_UI_MapRender";
import { import {
buildStrengthArray, buildStrengthArray,
createFlowLegend,
drawStrengthLine, drawStrengthLine,
normalizeStrength, normalizeStrength,
rampColor, rampColor,
@@ -45,6 +46,8 @@ export interface FlowStrengthOverlay {
button: HTMLButtonElement; button: HTMLButtonElement;
/** 유입 집중점 마커 전용 토글("집중유역"). 기본 꺼짐 — 마커가 관 마커와 겹쳐 읽기 어렵다. */ /** 유입 집중점 마커 전용 토글("집중유역"). 기본 꺼짐 — 마커가 관 마커와 겹쳐 읽기 어렵다. */
markerButton: HTMLButtonElement; markerButton: HTMLButtonElement;
/** 색띠 범례. 지도 뷰포트에 넣으면 CSS가 우측 세로로 세운다. */
legendElement: HTMLElement;
visible: () => boolean; visible: () => boolean;
/** 선택 요약 문구(없으면 빈 문자열). */ /** 선택 요약 문구(없으면 빈 문자열). */
status: () => string; 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.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-5", "#dc2626"));
button.setAttribute("aria-pressed", "true"); button.setAttribute("aria-pressed", "true");
button.title = L("B04_Surface_Flow_Strength_Tip"); button.title = L("B04_Surface_Flow_Strength_Tip");
const legend = createFlowLegend();
/** 범례는 강도 색칠이 켜져 있고 값이 있을 때만 띄운다. */
function syncLegend(): void {
legend.update(maxStrength, shown);
}
button.addEventListener("click", () => { button.addEventListener("click", () => {
shown = !shown; shown = !shown;
button.classList.toggle("is-active", shown); button.classList.toggle("is-active", shown);
button.setAttribute("aria-pressed", String(shown)); button.setAttribute("aria-pressed", String(shown));
syncLegend();
onChange(); onChange();
}); });
@@ -226,6 +237,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
return { return {
button, button,
markerButton, markerButton,
legendElement: legend.root,
visible: () => shown, visible: () => shown,
status: () => statusText, status: () => statusText,
setProject(nextProjectId) { setProject(nextProjectId) {
@@ -235,6 +247,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
const built = buildStrengthArray(profile); const built = buildStrengthArray(profile);
strength = built.strength; strength = built.strength;
maxStrength = built.maximum; maxStrength = built.maximum;
syncLegend();
hotspots = spots.map(([chainage, area, zone, rank]) => ({ chainage, area, zone, rank })); hotspots = spots.map(([chainage, area, zone, rank]) => ({ chainage, area, zone, rank }));
selected = null; selected = null;
selectionRings = []; selectionRings = [];
@@ -251,6 +264,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
clear() { clear() {
strength = new Float64Array(0); strength = new Float64Array(0);
maxStrength = 0; maxStrength = 0;
legend.update(0, false);
hotspots = []; hotspots = [];
selected = null; selected = null;
selectionRings = []; 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"; 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)"); export const haloColor = (): string => themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
@@ -565,145 +564,3 @@ export function drawPreparedLabels(
} }
/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */ /** 채움 폴리곤 오버레이(배수유역 등). 좌표는 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); statusStack.append(watershed.statusElement, flowStatus, detailBasins.statusElement);
statusTopRight.append(watershed.busyElement); statusTopRight.append(watershed.busyElement);
// 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다. // 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다.
viewport.append(detailBasins.menuElement); // 강도 색띠 범례도 같은 자리에 얹는다(CSS가 우측 세로로 세운다).
viewport.append(detailBasins.menuElement, flowStrength.legendElement);
function updateImageTransform(): void { function updateImageTransform(): void {
backgroundImages.forEach((image) => { backgroundImages.forEach((image) => {
@@ -2,7 +2,8 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette"; import { themeColor } from "@ui/ui_template_palette";
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch";
import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows"; 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 { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -2,11 +2,9 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer"; import { createPanelResizer } from "@ui/ui_template_resizer";
import { themeColor } from "@ui/ui_template_palette"; import { themeColor } from "@ui/ui_template_palette";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { import {
computeDetailBasins, computeDetailBasins,
fetchDetailPipePoints, fetchDetailPipePoints,
fetchVWorldMeta,
getVWorldMapUrl, getVWorldMapUrl,
saveDetailPipePoints, saveDetailPipePoints,
type DetailBasin, type DetailBasin,
@@ -16,12 +14,10 @@ import {
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import { import {
computeMapRect, computeMapRect,
computeRouteView,
createNormalizer, createNormalizer,
lonLatToScreen, lonLatToScreen,
prepareLayer, prepareLayer,
prepareMetricPolyline, prepareMetricPolyline,
type GeoJsonCollection,
type MapRect, type MapRect,
type Normalizer, type Normalizer,
type PreparedLayer, type PreparedLayer,
@@ -29,7 +25,10 @@ import {
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import { type RoutePoint } from "./B05_wf2_Route_Api_Fetch"; import { type RoutePoint } from "./B05_wf2_Route_Api_Fetch";
import type { FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; 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 { resampleRoute } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
import { createProgressCircle } from "@ui/ui_template_progress"; import { createProgressCircle } from "@ui/ui_template_progress";
@@ -39,6 +38,8 @@ import {
mountDrainageToggles, mountDrainageToggles,
basinColor, basinColor,
DRAINAGE_LAYERS, DRAINAGE_LAYERS,
fetchDrainageLayers,
fitViewToRoute,
bindPipeContextMenu, bindPipeContextMenu,
COLLAPSED_KEY, COLLAPSED_KEY,
MAX_PANEL_WIDTH_RATIO, MAX_PANEL_WIDTH_RATIO,
@@ -80,12 +81,16 @@ export interface DrainagePanel {
removePipe: (chainageM: number) => void; removePipe: (chainageM: number) => void;
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
savePipes: () => Promise<number>; savePipes: () => Promise<number>;
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
selectBasinByChainage: (chainageM: number | null) => void;
dispose: () => void; dispose: () => void;
} }
export interface DrainagePanelCallbacks { export interface DrainagePanelCallbacks {
/** 관 목록이 바뀔 때마다 누가거리 목록을 넘긴다 — 종단 테이블 구조물 라인 동기화용. */ /** 관 목록이 바뀔 때마다 누가거리 목록을 넘긴다 — 종단 테이블 구조물 라인 동기화용. */
onPipesChanged?: (chainages: number[]) => void; onPipesChanged?: (chainages: number[]) => void;
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
onBasinSelected?: (chainageM: number | null) => void;
} }
export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel {
@@ -138,7 +143,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
progress.root.hidden = true; progress.root.hidden = true;
// 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작). // 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작).
const contextMenu = createMapContextMenu("b05-drainage"); 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이면 서클을 감춘다. */ /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void { function showProgress(ratio: number | null, label: string | null): void {
@@ -241,6 +248,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
}, },
onStrength: (next) => { onStrength: (next) => {
showStrength = next; showStrength = next;
legend.update(maxStrength, showStrength);
scheduleDraw(); scheduleDraw();
}, },
onHotspots: (next) => { onHotspots: (next) => {
@@ -327,8 +335,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const basin = pipe const basin = pipe
? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51) ? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51)
: null; : null;
selectedBasin = basin ? basin.index : null; selectBasin(basin ? basin.index : null);
renderBasinList();
} }
function scheduleDraw(): void { function scheduleDraw(): void {
@@ -342,11 +349,22 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */ /** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 그 유역만 진하게 강조한다. */ /** 유역 제원 목록을 다시 그린다. 항목을 누르면 그 유역만 진하게 강조한다. */
function renderBasinList(): void { function renderBasinList(): void {
renderBasinRows(basinList, basins, selectedBasin, (index) => { renderBasinRows(basinList, basins, selectedBasin, (index) =>
selectedBasin = selectedBasin === index ? null : index; selectBasin(selectedBasin === index ? null : index),
renderBasinList(); );
scheduleDraw(); }
});
/** ** **. · · ·
* · (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 ** · ** /** . B04 ** · **
@@ -364,6 +382,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
); );
strength = built.strength; strength = built.strength;
maxStrength = built.maximum; maxStrength = built.maximum;
legend.update(maxStrength, showStrength);
maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0); maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0);
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
pipeEditor.setPipes( pipeEditor.setPipes(
@@ -372,7 +391,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
reason: pipe.source, reason: pipe.source,
})), })),
); );
selectedBasin = null; selectBasin(null, false);
renderBasinList(); renderBasinList();
syncPipeSelection(); syncPipeSelection();
summary.textContent = L("B05_Drainage_Summary") summary.textContent = L("B05_Drainage_Summary")
@@ -446,30 +465,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
* (2026-08-01 ). . * (2026-08-01 ). .
* . */ * . */
function fitToRoute(): void { 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 rect = viewport.getBoundingClientRect();
const view = computeRouteView( const fitted = fitViewToRoute(meta, routePoints, rect.width, rect.height);
meta, scale = fitted.scale;
{ x_min: minX, x_max: maxX, y_min: minY, y_max: maxY }, offsetX = fitted.offsetX;
Math.max(rect.width, 1), offsetY = fitted.offsetY;
Math.max(rect.height, 1),
);
scale = view.scale;
offsetX = view.offsetX;
offsetY = view.offsetY;
} }
async function loadLayers(): Promise<void> { async function loadLayers(): Promise<void> {
@@ -483,18 +483,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
status.textContent = L("B05_Drainage_Status_LoadingBase"); status.textContent = L("B05_Drainage_Status_LoadingBase");
showProgress(0, L("B05_Drainage_Status_LoadingBase")); showProgress(0, L("B05_Drainage_Status_LoadingBase"));
try { try {
const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite"); const { meta: nextMeta, layers: loaded } = await fetchDrainageLayers(activeProjectId, () =>
showProgress(1 / 3, L("B05_Drainage_Status_LoadingSheets")); 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;
}
}),
); );
if (sequence !== loadSequence) return; if (sequence !== loadSequence) return;
meta = nextMeta; meta = nextMeta;
@@ -592,10 +582,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
hit = basin.index; hit = basin.index;
} }
}); });
if (hit === null && selectedBasin === null) return; selectBasin(hit === selectedBasin ? null : hit);
selectedBasin = hit === selectedBasin ? null : hit;
renderBasinList();
scheduleDraw();
} }
const stopDragging = (): void => { const stopDragging = (): void => {
@@ -648,6 +635,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
if (index < 0) return; if (index < 0) return;
pipeEditor.moveTo(index, toChainage); 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) { setPipeChainages(chainages) {
const next = reconcilePipes(pipeEditor.pipes(), chainages); const next = reconcilePipes(pipeEditor.pipes(), chainages);
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다 if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
@@ -8,9 +8,13 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette"; import { themeColor } from "@ui/ui_template_palette";
import { DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; 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 { normalizeStrength, rampColor } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import type { MapContextMenu } from "@ui/ui_template_context_menu"; import type { MapContextMenu } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; 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" }; 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.textAlign = "center";
context.textBaseline = "middle"; context.textBaseline = "middle";
context.fillText(String(position + 1), screen.x, screen.y); 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.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.fillStyle = themeColor("--map-label-text", "#1f2937");
context.fillText( context.fillText(label, labelX, labelY);
`${pipe.chainage_m.toFixed(0)}m`,
screen.x + radius + 3,
screen.y - radius,
);
}); });
}, },
}; };
@@ -11,18 +11,20 @@
* ========================================================================== */ * ========================================================================== */
import { import {
drawFilledRing,
drawRingBadge,
ringCenterOnScreen,
drawPreparedLayer, drawPreparedLayer,
drawRidgeRing,
drawUpstreamLines,
routeLineColor, routeLineColor,
ROUTE_LINE_WIDTH, ROUTE_LINE_WIDTH,
type Normalizer, type Normalizer,
type PreparedLayer, type PreparedLayer,
type ViewState, type ViewState,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; } 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 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 { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
import { drawStrengthLine } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp"; 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"; } from "./B05_wf2_Route_UI_Markers";
import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel"; import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel";
import { createRouteProfilePanel } from "./B05_wf2_Route_UI_Profile_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 { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
import { import {
irregularLabel, irregularLabel,
@@ -209,6 +210,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
}, },
onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage), onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), 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` * 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 confirmedSurface: SurfaceModelSummary | null = null;
let latest: RouteLatestResponse | null = null; let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS; let roadWidths = DEFAULT_ROAD_WIDTHS;
@@ -259,6 +249,19 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
} catch { } 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 { function persistUphillOverrides(): void {
try { try {
window.sessionStorage.setItem( window.sessionStorage.setItem(
@@ -312,6 +315,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const id = station ? irregularStationId(station.id) : null; const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id); viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id); profilePanel.setSelectedStation(id);
// 사이드 패널에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다.
syncBasinHighlight(id);
} finally { } finally {
selectionSyncing = false; selectionSyncing = false;
} }
@@ -228,6 +228,8 @@ export interface RouteProfilePanelCallbacks {
onPipeAdd?: (chainageM: number) => void; onPipeAdd?: (chainageM: number) => void;
/** 구조물 라인을 눌러 고름. */ /** 구조물 라인을 눌러 고름. */
onIrregularSelect?: (station: IrregularStation) => void; onIrregularSelect?: (station: IrregularStation) => void;
/** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */
onBasinSelected?: (chainageM: number | null) => void;
} }
export function createRouteProfilePanel( export function createRouteProfilePanel(
@@ -262,6 +264,7 @@ export function createRouteProfilePanel(
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
const drainagePanel = createDrainagePanel({ const drainagePanel = createDrainagePanel({
onPipesChanged: (chainages) => callbacks?.onPipesChanged?.(chainages), onPipesChanged: (chainages) => callbacks?.onPipesChanged?.(chainages),
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
}); });
content.append(bodyWrap, drainagePanel.root); 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 구간마다 그 자리로 모이는 상류 면적을 색으로 칠하고, 물이 특히 많이 모이는 자리를 마커로 찍습니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.", "도로 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.", "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: ["집중유역", "Inflow hotspots"],
B04_Surface_Flow_Hotspots_Tip: [ B04_Surface_Flow_Hotspots_Tip: [
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.", "노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",