From 32fd199dbfa87324f810c6dc2679c377a3fc31ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 2 Aug 2026 00:14:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04/B05):=20=ED=9D=90=EB=A6=84=20=EA=B0=95?= =?UTF-8?q?=EB=8F=84=20=EB=B2=94=EB=A1=80=20+=20=EB=B0=B0=EA=B4=80=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EA=B0=80=EB=8F=85=EC=84=B1=20+=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=203=EC=9E=90=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흐름 강도 색띠 범례를 지도 우측에 세로로 세운다(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) --- .../B04_wf1_Surface_UI_FlowRamp.ts | 69 ++++++++ .../B04_wf1_Surface_UI_FlowStrength.ts | 14 ++ .../B04_wf1_Surface_UI_MapOverlays.ts | 149 ++++++++++++++++++ .../B04_wf1_Surface_UI_MapRender.ts | 143 ----------------- .../B04_wf1_Surface_UI_MapViewer.ts | 3 +- .../B04_wf1_Surface_UI_Watershed.ts | 3 +- .../B05_wf2_Route_UI_Drainage_Panel.ts | 101 ++++++------ .../B05_wf2_Route_UI_Drainage_Parts.ts | 62 +++++++- .../B05_wf2_Route_UI_Drainage_Pipes.ts | 19 ++- .../B05_wf2_Route_UI_Drainage_Render.ts | 12 +- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 31 ++-- .../B05_wf2_Route_UI_Profile_Panel.ts | 3 + B05_wf2_Route/B05_wf2_Route_UI_Selection.ts | 94 +++++++++++ ui_template/ui_template_flow_legend.css | 51 ++++++ ui_template/ui_template_locale.ts | 1 + 15 files changed, 529 insertions(+), 226 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_MapOverlays.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Selection.ts create mode 100644 ui_template/ui_template_flow_legend.css diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp.ts index 9449b87a..eebd88e6 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp.ts @@ -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)); + }); + }, + }; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts index b734e3b6..1cc2b026 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts @@ -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 = []; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapOverlays.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapOverlays.ts new file mode 100644 index 00000000..bc78871d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapOverlays.ts @@ -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; + /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ + 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, + 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>, + 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, + 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(); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index f624ac39..3349f6ec 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -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; - /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ - 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, - 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>, - 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, - 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(); -} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index feca464e..0382c90f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -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) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 71ce5ccd..d4e29a56 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -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]; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 5a688386..b7860e90 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -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; + /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ + 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 { @@ -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(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; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다 diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts index b4e2982d..16e80ec9 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts @@ -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; +}> { + const meta = await fetchVWorldMeta(projectId, "satellite"); + onProgress(1 / 3); + const layers = await Promise.all( + DRAINAGE_LAYERS.map(async (layer) => { + try { + // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. + return [layer, await fetchCachedSheetLayer(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 }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index 0c34257b..519bf82c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -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); }); }, }; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts index c1121a29..6aa2b8fe 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts @@ -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"; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index c4e122e2..3b902294 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -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 { }, 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 { * 선택은 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 { } 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 { const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); profilePanel.setSelectedStation(id); + // 사이드 패널에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다. + syncBasinHighlight(id); } finally { selectionSyncing = false; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index e78fe1b3..adbc7f73 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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); // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Selection.ts b/B05_wf2_Route/B05_wf2_Route_UI_Selection.ts new file mode 100644 index 00000000..381393e1 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Selection.ts @@ -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; + 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); + } + }, + }; +} diff --git a/ui_template/ui_template_flow_legend.css b/ui_template/ui_template_flow_legend.css new file mode 100644 index 00000000..bf109187 --- /dev/null +++ b/ui_template/ui_template_flow_legend.css @@ -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; +} diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index ea135494..4d5b48f6 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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: [ "노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",