① 저장된 횡단이 지금 계획선과 어긋나면(재계산 예약 상태) 유토곡선을 그리지 않고 「횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다」만 띄움. 옛 계획고로 만든 면적이 2초간 보였다가 정본으로 갈아 끼워지던 것을 없앰(2026-09-03 사용자 확정 「새 값만 보여주기」). 용화 실측 — 65측점 중 56개가 낡음, 계획고 차 −9.36~+5.83m, 저장분 성토 42,050㎥ ↔ 정본 14,881㎥. ② 사면이 계산 반폭 끝까지 원지반과 만나지 않아 면적이 잘린 측점에 카드 경고 표기. 판정은 면적을 내는 엔진 한 곳(B06_Section_Engine_Design)에서 `slope_unclosed` 로 내려 화면·수량·도면이 같은 기준을 봄. 계산은 손대지 않음(2026-09-03 사용자 확정 「영원히 못 만나는 지형이 있을 수 있으니 경고로 대체」). 검증 — 공용 브라우저 B06 실측: 2.6초 재계산 안내만 표시(옛 값 미노출), 4.4초 정본 표시, 사면 미교차 경고 13/65측점. pytest 366 passed·17 skipped, typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
354 lines
18 KiB
TypeScript
354 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Profile_Render.ts
|
|
* 종단 패널 본문 재구성 — 캔버스·그래프·구조물 레인·테이블·유토곡선을 한 번에 그린다.
|
|
*
|
|
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
|
* 상태는 여전히 본체가 들고 있고 여기서는 컨텍스트로 받아 **그리기만** 한다 —
|
|
* 그리는 도중 상태를 바꾸는 자리(선택 변경·드래그 쿨다운 해제)는 컨텍스트의 세터를
|
|
* 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
createLongitudinalProfile,
|
|
longitudinalMinimumWidth,
|
|
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
|
import { LONG_PAD, staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
|
|
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
|
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
|
import {
|
|
adjustStation,
|
|
setCurveRadius,
|
|
type AlignmentBase,
|
|
type AlignmentEdits,
|
|
type ProfileAlignment,
|
|
} from "./B05_Profile_UI_Profile_Alignment";
|
|
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
|
|
import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover";
|
|
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, 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[];
|
|
/** 횡단배수 최소 계획고 대상(시설·제원 반영) — 요약줄 경고 표시에 쓴다.
|
|
* 편집 차단 가드는 `_Profile_Panel.applyEdits` 한 곳으로 옮겼다(2026-09-02). */
|
|
minCoverTargets: () => MinCoverPoint[];
|
|
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;
|
|
/** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */
|
|
handleToolPick: (chainageM: number | null) => boolean;
|
|
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
|
|
toolActive: () => boolean;
|
|
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) => {
|
|
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
|
|
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
|
|
if (ctx.toolActive()) {
|
|
const marker = (event.target as HTMLElement).closest(".b06-chart__station");
|
|
const raw = marker?.getAttribute("data-chainage");
|
|
if (raw !== null && raw !== undefined) {
|
|
if (ctx.handleToolPick(Number(raw))) return;
|
|
} else {
|
|
const rect = chartWrap.getBoundingClientRect();
|
|
const chainage = chainageInverter(
|
|
longitudinal,
|
|
width,
|
|
layout.originOffset,
|
|
)(event.clientX - rect.left + chartWrap.scrollLeft);
|
|
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
|
|
}
|
|
}
|
|
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) {
|
|
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,
|
|
),
|
|
// 최소고 가드는 `_Profile_Panel.applyEdits` 한 곳에 있다 — 여기 따로 걸면
|
|
// 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02).
|
|
onStation: (chainage, delta) => {
|
|
if (!base) return;
|
|
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta));
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
// 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기).
|
|
// 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(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,
|
|
// 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 —
|
|
// Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
|
pendingRecalc: staleDesignChainages(detail).length > 0,
|
|
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);
|
|
},
|
|
});
|
|
}
|
|
}
|