Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Render.ts
T
eomsangdonandClaude Fable 5 0d5fba4400 feat(B06): 배수관 횡단도 세트(배관·기슭막이/집수정·보호공) 추가
B05에서 배치한 배수관 측점의 B06 횡단도에 배관·기슭막이(또는 집수정)·보호공을
분리 도형으로 그린다. 기존 도형(지반선·면적 밴드·포장·암 경계)은 손대지 않고,
계획선 표시 규칙만 세트와 정합을 맞춰 함께 정비했다.

- 백엔드: pipe_points.json 정본(facility=="pipe")에 레지스트리 기본값을 채워
  chainage ±0.02m로 맞는 횡단에 culvert 키를 얹는다(Engine_Culvert 신규, Router의
  상세조회·재생성 두 경로에서 호출). 횡단 dict는 자유형이라 Pydantic 변경 없음.
- 배관: 유입 invert = min(노견 아래 원지반, 노면고 − 관경 − 토피 0.5), 유출 앵커는
  성토사면이 지반과 만나는 사면 끝(성토사면 길이 5m 한계 — 별표2 Ⅰ.2.차.(3)).
  역경사는 수평 클램프, 길이는 m 단위 올림. 단면은 축 법선 방향 직사각형이고
  외경선(실선)+내경선(점선) 2겹으로 관벽 두께를 보인다(파형강관 2.7T — KS D 3590).
  끝단면은 구조물의 계류측 변을 복사해 자른다.
- 기슭막이: 배면 수직 사다리꼴 + 전면 평행사변형 띠(1:0.3, t=0.45 — 실무 L3=45)의
  합성 단면. 높이는 이음선 상단점이 성토 사면선과 만나는 접점까지, 하한 관경+0.45,
  상한은 형태별 한계(찰 3.0/메 2.0 — 교본 7-3). 근입 0.5m(실무 기초콘크리트 H=0.5).
  돌 해칭은 벽 폴리곤 clipPath 안에만 그려 어떤 높이에서도 벽 밖으로 새지 않는다.
- 집수정: 절토측 유입이거나 원지반이 관 단면의 30% 이상을 막으면 자동 전환.
  I형(벽)·ㄴ형(+바닥)·ㄷ형(+반대측 막음), 기본 ㄴ형·내공 1.0m. 구조물이 원지반
  안쪽에 박히면 절토선을 공사 계획선(보라 실선)으로 긋는다. 라벨은 I형 벽 하단.
- 보호공: 시작 윗점 = 관 하단 꼭짓점, 시작 변은 기슭막이 전면과 같은 경사.
  성토경사를 따라 원지반까지 내려간 뒤, 최소 길이(낙차고×2)를 채울 때까지 원지반을
  따라 더 간다.
- 계획선 표시: 원지반과 차이 없는 구간은 그리지 않되(보라 점선 폐지), 노면·노견·
  측구부터 사면 1차 접점까지는 항상 그린다. 측구는 노견 바깥에 붙으므로 보호 범위를
  측구 폭만큼 넓히고 접점 탐색도 그 바깥에서 시작한다. 배수관 측점은 벽 접점에서 트림.
- B05: 배수관 측점 계획고를 지반고 + 관경 + 토피 0.5m 아래로 내리지 못하게 막고
  경고 토스트를 띄운다.
- 700줄 제한: 오버레이 파일이 919줄이 되어 기하(_Geom 678줄)와 그리기(247줄)로
  분리했다. import 경로는 재수출로 유지해 B05·Cross_View는 건드리지 않았다.

근거 미확정(코드 주석·툴팁에 명시): 토피 0.5m(별표2 교량·암거 복토 50㎝ 교차 참조),
기슭막이 근입 0.5m(실무값), 흄관·VR관 관벽 두께(관경÷12 — KS F 4403 미확보).

검증: tsc, ruff, pytest 148 passed(신규 test_b06_culvert_set.py 6건), 헤드 브라우저 —
4+4.3·13+15.7 세트 표시, 보호공 시작점과 관 하단 꼭짓점 간격 0.6px 이내·시작 변
기울기 실측 0.223(= 0.3·H/(H+0.5) 이론값과 일치)·최소 길이 5.0m 확보, 계획선
세그먼트 연결 틈 0개(8·9·10·12·4+4.3). ⚠ 13+15.7 성토사면 5.03m로 법정 5m를
0.03m 초과 — 툴팁에 표기하고 접점 보간 여부는 사용자 판단 대기.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:43:51 +09:00

373 lines
18 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Render.ts
* 종단 패널 본문 재구성 — 캔버스·그래프·구조물 레인·테이블·유토곡선을 한 번에 그린다.
*
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
* 상태는 여전히 본체가 들고 있고 여기서는 컨텍스트로 받아 **그리기만** 한다 —
* 그리는 도중 상태를 바꾸는 자리(선택 변경·드래그 쿨다운 해제)는 컨텍스트의 세터를
* 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "../B06_Section/B06_Section_UI_Longitudinal";
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import { MIN_PIPE_COVER_M } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import {
adjustStation,
planElevationAt,
setCurveRadius,
shiftSegment,
type AlignmentBase,
type AlignmentEdits,
type ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
import { createProfileTable } from "./B05_Profile_UI_Profile_Table";
import {
CELL_GAP_PX,
chainageInverter,
chainageMapper,
computeProfileLayout,
irregularGraphStations,
maxChainageOf,
STATION_SPACING_PX,
TABLE_ROW_COUNT,
} from "./B05_Profile_UI_Profile_Layout";
import {
irregularStationId,
isPipeStation,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel";
/** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */
export interface ProfileRenderContext {
body: HTMLElement;
callbacks: RouteProfilePanelCallbacks | undefined;
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
tableOverlay: {
isOpen: () => boolean;
contentHeight: () => number;
setTable: (table: HTMLElement | null) => void;
};
store: {
edits: () => AlignmentEdits;
resetStation: (chainageM: number) => void;
};
detail: () => SectionDetailResponse | null;
alignment: () => ProfileAlignment | null;
base: () => AlignmentBase | null;
stationInterval: () => number | undefined;
irregularStations: () => IrregularStation[];
structures: () => StructureInstance[];
structureTypes: () => StructureType[];
selectedStationId: () => string | null;
setSelectedStationId: (value: string | null) => void;
selectedStructureId: () => string | null;
setSelectedStructureId: (value: string | null) => void;
stationDisplay: () => { station: number; cumulative: number };
/** 본문 크기 기록 — 다음 리사이즈에서 재구성이 필요한지 판단하는 값. */
setLastSize: (width: number, height: number) => void;
renderBalance: () => void;
applyHeightCascade: (allowGrow: boolean) => { chartHeight: number };
/** 전체 재구성이 한 번 돌면 배치가 확정된 것 — 자동 확대를 다시 허용한다. */
clearMainDragCooldown: () => void;
selectStation: (stationId: string | null) => void;
applyEdits: (next: AlignmentEdits) => void;
stationIdAtStructure: (structureId: string | null) => string | null;
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
redraw: () => void;
}
/** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */
export function renderProfile(ctx: ProfileRenderContext): void {
const { body, callbacks, massHaul, tableOverlay, store } = ctx;
const detail = ctx.detail();
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
const alignment = ctx.alignment();
const base = ctx.base();
const stationInterval = ctx.stationInterval();
const irregularStations = ctx.irregularStations();
const structures = ctx.structures();
const structureTypes = ctx.structureTypes();
const selectedStationId = ctx.selectedStationId();
const selectedStructureId = ctx.selectedStructureId();
const stationDisplay = ctx.stationDisplay();
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
ctx.setLastSize(body.clientWidth, body.clientHeight);
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
const scrollLeft = body.scrollLeft;
ctx.renderBalance();
const longitudinal = detail.longitudinal;
const availableWidth = Math.max(1, body.clientWidth - 15);
// 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면
// 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다.
const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m;
const layout =
alignment && stationIntervalM
? computeProfileLayout(longitudinal, stationIntervalM, availableWidth)
: {
width: Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)),
originOffset: 0,
cellWidth: STATION_SPACING_PX - CELL_GAP_PX,
};
const { width, originOffset } = layout;
const canvas = document.createElement("div");
canvas.className = "b05-profile__canvas";
canvas.style.width = `${width}px`;
const x = chainageMapper(longitudinal, width, originOffset);
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const { chartHeight } = ctx.applyHeightCascade(true);
// 전체 재구성이 한 번 돌면 배치가 확정된 것 — 다음 캐스케이드부터 grow를 다시 허용한다.
ctx.clearMainDragCooldown();
const tableHeight = tableOverlay.contentHeight();
const table =
alignment && tableOverlay.isOpen() && tableHeight > 0
? createProfileTable({
alignment,
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
width,
height: tableHeight,
// 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다).
cellWidth: layout.cellWidth,
// 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다.
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
x,
// 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다.
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
selectedStationId,
stationDisplay,
onCurveRadiusChange: (curve, radius) =>
ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)),
})
: null;
const chartWrap = document.createElement("div");
chartWrap.className = "b05-profile__chart";
chartWrap.style.height = `${chartHeight}px`;
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
chartWrap.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
// 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지
// 리셋되는 해제 신호가 여기서만 나갈 수 있다(2026-08-18 사용자 보고).
if (ctx.selectedStructureId() !== null) {
ctx.setSelectedStructureId(null);
callbacks?.onStructureSelect?.(null);
ctx.redraw();
}
});
const designProfiles = alignment
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
: (longitudinal.design_profiles ?? []);
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
const graphData = normalizedLongitudinal(longitudinal);
// 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바
// 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이
// 그대로 남는다(2026-08-02 사용자 보고).
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
const graphLongitudinal = {
...graphData,
stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m),
};
let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
chartWrap.append(
createLongitudinalProfile(
graphLongitudinal,
selectedStationId,
1,
undefined,
ctx.selectStation,
stationInterval,
width,
chartHeight,
width,
designProfiles,
originOffset,
(axis) => {
yAxis = axis;
},
stationDisplay.station,
// 구조물 측점선을 끌어 옮긴다 — 배관이면 관 지점 정본을 거쳐 세부유역까지 다시 나뉜다.
(stationId, toChainage) => {
const target = irregularStations.find(
(entry) => irregularStationId(entry.id) === stationId,
);
if (target) callbacks?.onStructureMove?.(target.chainage_m, toChainage, target);
},
// 계획고 편집 ▼ 버튼(바닥 2px + 높이 17px)과 측점 라벨이 겹치지 않게 X축·라벨을
// 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다
// (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
15,
),
);
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
mountStructureMenu(chartWrap, {
stations: irregularStations,
x,
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
maxChainageM: maxChainageOf(longitudinal),
onRemove: (station) => callbacks?.onStructureRemove?.(station),
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
// 사이드 「구조물 배치」와 같은 전체 목록(A군 포함) — 선택 = 즉시 추가가 아니라
// 폼 자동 지정이므로 필수 옵션·관리 주체로 거를 이유가 없어졌다(2026-08-18 일원화).
structureTypes: structureTypes.map((type) => ({
type_id: type.type_id,
group: type.group,
name: type.name,
})),
onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId),
});
// 구조물 알약 레인 — 그래프 아래 별도 줄(2026-08-17 사용자 지시 1·3). 자동 배수관도
// 세로 점선이 아니라 같은 알약으로 여기에 놓인다.
const structureLane = buildStructureLane({
structures,
types: structureTypes,
x,
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
maxChainageM: maxChainageOf(longitudinal),
widthPx: width,
// 그래프 sticky Y축과 같은 폭으로 레인 축을 이어 붙인다(2026-08-17 지시 2).
axisWidthPx: LONG_PAD.left,
// 벌룬·툴팁 위치 표기는 측점번호+잔여거리(2026-08-17 사용자 확정).
stationIntervalM: stationInterval ?? 20,
selectedId: selectedStructureId,
onSelect: (structureId) => {
ctx.setSelectedStructureId(structureId);
// 같은 자리 측점 세로선도 함께 켜고 끈다 — 알약과 세로선은 한 구조물이다.
ctx.setSelectedStationId(ctx.stationIdAtStructure(structureId));
callbacks?.onStructureSelect?.(structureId);
ctx.redraw();
},
onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage),
});
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
// 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight));
if (alignment) {
// 배수관 측점 계획고 하향 차단(2026-08-19): 관은 지반선상 매설이라(교본 8장)
// 계획고가 지반고 + 관경 + 최소 토피 아래로 내려가면 관이 노면을 뚫는다.
// 내림(▼)만 막고 올림(▲)·다른 측점은 그대로 둔다 — 판단은 사용자 몫, 한계만 지킨다.
const groundAt = (chainageM: number): number | null => {
if (!base || !base.chainage.length) return null;
const { chainage: xs, ground: ys } = base;
if (chainageM <= xs[0]) return ys[0];
if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1];
for (let i = 1; i < xs.length; i += 1) {
if (chainageM > xs[i]) continue;
const span = xs[i] - xs[i - 1];
if (span <= 0) return ys[i];
return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span);
}
return ys[ys.length - 1];
};
const blocksPipeLowering = (chainageM: number, delta: number): boolean => {
if (delta >= 0) return false;
const pipe = irregularStations.find(
(entry) => isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < 0.02,
);
if (!pipe) return false;
const ground = groundAt(chainageM);
if (ground === null) return false;
const diameterM = (pipe.diameterMm ?? 1000) / 1000;
const minPlan = ground + diameterM + MIN_PIPE_COVER_M;
if (planElevationAt(alignment, chainageM) + delta >= minPlan - 1e-6) return false;
showToast(
`배수관 측점 — 관 매설 최소 높이(지반 + 관경 ${diameterM.toFixed(1)}m + 토피 ${MIN_PIPE_COVER_M.toFixed(1)}m)에 도달해 더 내릴 수 없습니다.`,
"warning",
);
return true;
};
chartWrap.append(
createEditOverlay({
alignment,
width,
x,
step: alignment.policy.edit_step_m,
// 비정규 측점도 규칙 측점처럼 ▲/▼ 버튼으로 계획고 조정(임의 chainage 변화점 승격).
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
onStation: (chainage, delta) => {
if (!base || blocksPipeLowering(chainage, delta)) return;
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta));
},
onSegment: (segment, delta) =>
base && ctx.applyEdits(shiftSegment(base, store.edits(), segment, delta)),
onResetStation: (chainage) => store.resetStation(chainage),
// 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2).
onResetSegment: (segment) => {
store.resetStation(segment.from_m);
store.resetStation(segment.to_m);
},
}),
);
}
// 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기).
// 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6).
canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`;
canvas.append(chartWrap, structureLane);
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
// 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다.
if (table) table.style.width = `${width}px`;
tableOverlay.setTable(table);
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
if (alignment && designProfiles[0]) {
massHaul.draw({
stationSource: graphLongitudinal,
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
crossSections: detail.cross_sections,
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
padLeft: LONG_PAD.left + originOffset,
padRight: LONG_PAD.right + originOffset,
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
axisX: LONG_PAD.left,
// 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다
// (2026-08-05 사용자 보고: 버튼과 커브 겹침).
padTop: 40,
},
stationInterval: stationIntervalM ?? 1,
widthPx: width,
selectedStationId,
onSelectStation: ctx.selectStation,
onClearSelection: () => {
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
},
});
}
}