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 사용자 지시).
|
* 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 구간마다 그 자리로 모이는 상류 면적을 색으로 칠하고, 물이 특히 많이 모이는 자리를 마커로 찍습니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
|
"도로 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: [
|
||||||
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
|
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
|
||||||
|
|||||||
Reference in New Issue
Block a user