From f659aec8a174df4184599333e9a2edde66c96f17 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:14:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=ED=8F=89=EB=A9=B4(=EC=A7=80?= =?UTF-8?q?=EB=8F=84)=EC=97=90=EB=8F=84=20=EA=B5=AC=EA=B0=84=ED=98=95=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=84=20=EB=9D=A0=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=A2=85=EB=8B=A8=EA=B3=BC=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=9E=90=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-07). 계획서 3-6 마지막 항목 — 산마루측구·도수로·옹벽처럼 **구간**으로 놓이는 시설이 종단에는 띠로 보이는데 **평면(배수유역도)에는 아무 표시가 없었음**. **「부품이 없어 새로 짜야 함」이 사실이 아니었음** — 계획선 표본(`strengthSamples`)이 **1m 간격** 이라 **배열 인덱스가 곧 누가거리**임(`resampleRoute`). 구간 → 화면 선은 그 토막을 잘라 굵게 긋기만 하면 됨. 새 파일 하나(88줄)로 끝남. - `B05_Profile_UI_Drainage_Spans.ts` — 띠 그리기(`drawRouteSpans`)와 자료 만들기 (`routeSpansFromStructures`). 구간형만 띠가 되고, 색은 **레지스트리 표시색**을 그대로 씀. - 그리는 자리 — **계획선 바로 위, 강도 색칠·마커 아래**. 굵기 7px·투명도 0.45 라 계획선을 덮지 않음. 이름은 안 적음(종단에 이미 있고, 지도 글자는 눈금·유역 번호와 겹침). - 목록이 바뀌면 따라옴 — `setStructures`·`setStructureTypes` 가 띠를 다시 넘김. - 저장분에 노선보다 긴 구간이 남아 있어도 잘라서 그리고, 시작=끝인 구간도 한 칸은 보임. ⚠ 프로필 패널이 이미 700줄을 넘겨 있어(843) **매핑 로직은 띠 모듈로 뺐음** — 패널에는 3줄만 남김(827줄). 그 파일의 초과는 이전부터임. 시험 5건 — 부품 존재 · 범위 밖 구간 안전 · **쌓는 순서**(계획선 → 띠 → 강도 색칠) · 종단과 같은 자료에서 뽑는지 · 목록 변화가 따라오는지. ⚠ 실화면 확인은 아직임 — 공용 브라우저가 소단 무한루프(별건, `80db17d4` 로 고침) 로 멈춰 있어 사용자 새로고침 대기 중임. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 9 ++ .../B05_Profile_UI_Drainage_Panel_Types.ts | 4 + B05_Profile/B05_Profile_UI_Drainage_Render.ts | 7 ++ B05_Profile/B05_Profile_UI_Drainage_Spans.ts | 88 +++++++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 8 ++ 5 files changed, 116 insertions(+) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Spans.ts diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a4c7eb77..58827809 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -31,6 +31,7 @@ import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; +import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; import { mountDrainageToggles, @@ -121,6 +122,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let selectedBasin: number | null = null; // 측점 선택 마킹(계획선 위 누가거리). 유역이 없는 구조물 측점도 위치를 보여 준다. let markedChainage: number | null = null; + // 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 밖(종단 패널)이 + // 구조물 목록을 받을 때마다 넣어 준다. 여기서는 그리기만 하고 판정하지 않는다. + let intervalSpans: ReadonlyArray = []; // 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다. let lastNotifiedPipeChainage: number | null = null; // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. @@ -255,6 +259,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeColor, markedChainage, stationIntervalM: MAP_STATION_INTERVAL_M, + intervalSpans, }); updateImageTransform(); } @@ -590,6 +595,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage = chainageM; scheduleDraw(); }, + setIntervalSpans(spans) { + intervalSpans = spans; + scheduleDraw(); + }, addPipe(chainageM, attributes) { // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index f50a2375..8474aa0f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -11,6 +11,7 @@ import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; export interface DrainagePanel { root: HTMLElement; @@ -44,6 +45,9 @@ export interface DrainagePanel { selectPipeAtChainage: (chainageM: number | null) => void; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; + /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). + * 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */ + setIntervalSpans: (spans: ReadonlyArray) => void; dispose: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 847909e3..e5c92004 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -39,6 +39,7 @@ import { type DrainageLayer, } from "./B05_Profile_UI_Drainage_Parts"; import type { PipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import { drawRouteSpans, type RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; export interface DrainageScene { meta: VWorldMeta | null; @@ -67,6 +68,8 @@ export interface DrainageScene { markedChainage: number | null; /** 규칙 측점 간격(m) — 눈금·번호 표기 기준 (2026-09-04 사용자 지시). */ stationIntervalM: number; + /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */ + intervalSpans: ReadonlyArray; } export function drawDrainageScene( @@ -134,6 +137,10 @@ export function drawDrainageScene( return; } const projector = createMetricProjector(meta, view); + // 구간형 구조물 띠 — 계획선 바로 위, 강도 색칠 아래. 종단 레인의 띠와 같은 뜻이다. + if (scene.intervalSpans.length > 0) { + drawRouteSpans(context, scene.strengthSamples, scene.intervalSpans, projector.toScreen); + } // 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다. if (scene.showStrength && scene.strength.length > 0) { drawStrengthLine(context, scene.strengthSamples, scene.strength, scene.maxStrength, (point) => diff --git a/B05_Profile/B05_Profile_UI_Drainage_Spans.ts b/B05_Profile/B05_Profile_UI_Drainage_Spans.ts new file mode 100644 index 00000000..3b86acf9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Spans.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Spans.ts + * 배수유역도(평면)에서 **구간형 구조물이 놓인 자리**를 계획선 위에 띠로 그린다. + * + * 왜 (계획서 3-6) — 산마루측구·도수로·옹벽처럼 **구간**으로 놓이는 시설은 종단에는 띠로 + * 보이는데 평면에는 아무 표시가 없었다. 「노선 위에 임의 구간을 얹는 부품이 없다」가 + * 그동안의 걸림돌이었는데, 계획선 표본(`strengthSamples`)이 **1m 간격이라 배열 인덱스가 + * 곧 누가거리**여서 구간 → 화면 선은 잘라 붙이기만 하면 된다. + * + * 그리는 자리 — **계획선 바로 위, 강도 색칠·마커 아래**. 굵고 반투명해서 계획선을 덮지 + * 않는다. 이름은 안 적는다(종단에 이미 있고, 지도에 글자를 얹으면 눈금·유역 번호와 겹친다). + * ========================================================================== */ + +import type { RoutePoint } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; + +/** 계획선 위에 얹을 구간 하나 — 누가거리 두 값과 색. */ +export interface RouteSpanBand { + startM: number; + endM: number; + /** 구조물 레지스트리의 표시색(`style.color`). */ + color: string; +} + +/** 띠 굵기(px) — 계획선(2px 안팎)보다 확실히 굵되 유역 채움을 가리지 않는 값. */ +const BAND_WIDTH_PX = 7; + +/** + * 구간 띠를 그린다. 표본이 없거나 구간이 비면 아무 것도 하지 않는다. + * + * 인덱스는 **누가거리(m)** 다 — 표본이 1m 간격이라 그렇다(`resampleRoute`). 범위를 벗어난 + * 값은 잘라 쓰고, 한 점짜리 구간(시작=끝)은 짧은 토막으로라도 보이게 한 칸을 준다. + */ +export function drawRouteSpans( + context: CanvasRenderingContext2D, + samples: ReadonlyArray, + spans: ReadonlyArray, + toScreen: (x: number, y: number) => [number, number], +): void { + if (samples.length < 2 || spans.length === 0) return; + const last = samples.length - 1; + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = BAND_WIDTH_PX; + for (const span of spans) { + const from = Math.max(0, Math.min(last, Math.floor(Math.min(span.startM, span.endM)))); + const to = Math.max(from + 1, Math.min(last, Math.ceil(Math.max(span.startM, span.endM)))); + context.beginPath(); + for (let index = from; index <= to; index += 1) { + const [x, y] = toScreen(samples[index].x, samples[index].y); + if (index === from) context.moveTo(x, y); + else context.lineTo(x, y); + } + context.strokeStyle = span.color; + context.globalAlpha = 0.45; + context.stroke(); + context.globalAlpha = 1; + } + context.restore(); +} + +/** + * 구조물 정본 + 타입 레지스트리 → 띠 목록. **종단 레인과 같은 자료**를 본다(계획서 3-6). + * + * · 구간형(`interval`)만 띠가 된다 — 점형 시설은 마커로 이미 보인다. + * · 색은 레지스트리 표시색을 그대로 쓴다(여기서 새로 정하지 않는다). + * · 시작·끝이 없으면 기준 측점으로 대신한다. 그것도 없으면 뺀다. + */ +export function routeSpansFromStructures( + structures: ReadonlyArray, + types: ReadonlyArray, +): RouteSpanBand[] { + const colorOf = new Map(types.map((type) => [type.type_id, type.style?.color])); + const spans: RouteSpanBand[] = []; + for (const item of structures) { + if (item.placement !== "interval") continue; + const start = item.start_m ?? item.chainage_m ?? null; + const end = item.end_m ?? item.chainage_m ?? null; + if (start === null || end === null) continue; + spans.push({ + startM: Math.min(start, end), + endM: Math.max(start, end), + color: colorOf.get(item.type_id) ?? "#8a8a8a", + }); + } + return spans; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index dff62835..08236aa3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -10,6 +10,7 @@ * `saveProfileAlignment()`로 편집 델타만 보낸다. * ========================================================================== */ +import { routeSpansFromStructures } from "./B05_Profile_UI_Drainage_Spans"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; @@ -655,6 +656,11 @@ export function createRouteProfilePanel( ); setCollapsed(readStateRaw("profile-collapsed") === "true"); + /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ + function syncDrainageSpans(): void { + drainagePanel.setIntervalSpans(routeSpansFromStructures(structures, structureTypes)); + } + return { root, render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) { @@ -747,6 +753,7 @@ export function createRouteProfilePanel( /** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */ setStructureTypes(types: StructureType[]) { structureTypes = types; + syncDrainageSpans(); draw(); }, /** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */ @@ -755,6 +762,7 @@ export function createRouteProfilePanel( if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) { selectedStructureId = null; } + syncDrainageSpans(); draw(); }, /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */