feat(B05): 평면(지도)에도 구간형 구조물을 띠로 — 종단과 같은 자료

사용자 지시(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) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 20:14:09 +09:00
co-authored by Claude Opus 5
parent 80db17d463
commit f659aec8a1
5 changed files with 116 additions and 0 deletions
@@ -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<RouteSpanBand> = [];
// 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다.
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);
@@ -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<RouteSpanBand>) => void;
dispose: () => void;
}
@@ -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<RouteSpanBand>;
}
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) =>
@@ -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<RoutePoint>,
spans: ReadonlyArray<RouteSpanBand>,
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<StructureInstance>,
types: ReadonlyArray<StructureType>,
): 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;
}
@@ -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();
},
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */