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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [
|
||||
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
|
||||
|
||||
Reference in New Issue
Block a user