- 측점 뜨기(design_pipeline_probe)가 형상 · 이름 얹은 단면도 돌려줌 · 창은 「횡단」 판과 같은 routeCrossCard(서버 미리 뜬 것만 선 그림) - Cross_Card 판 재료 crossSourceOf 로 나눔(창 · 판 한 벌) · 카드 머리 걷기 전 경고 칩을 그래프 오른쪽 위로 - 곡선 판 가둠 = 편집 도구줄 아래 · 열린 횡단 판 왼쪽 · 뜬 종단 판 위 · 판 크기 바뀌면 다시 가둠 - 종단 지반선 · 계획선 pointer-events none · WF_State_Stale = 「다시 확인 필요 (앞 단계 변경)」 · ui-button → ui-btn - 시험 test_505_3_cross_card_text · test_505_3_cross_card_screen(5179) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_Pipeline_Detail.ts
|
|
* 계산 묶음(PLAN 346-1 · 346-2) 출력 → 종단도 · 유토곡선이 읽는 상세 꼴(`SectionDetailResponse`).
|
|
* 노선 초안 동안 종단도가 저장 노선 상세 대신 이 상세를 그림(지반 · 측점 · 횡단 면적이 새 노선 것).
|
|
*
|
|
* uphillSide 측점 상단측(좌 · 우 지반 평균이 높은 쪽) — 서버 단면 유형 기본값(`{쪽}_cut`) 재료.
|
|
* ⚠ ① 짝 — `B05_Profile_Engine_Sections_Core.py` `uphill_side` · 거울 시험
|
|
* `resources/tester/test_346_2_pipeline_cross_page.py`.
|
|
* crossWithUphill cross 단계 입력에 단면 유형 기본값(상단측)을 얹음 — 서버 노선 저장이 단면 유형 ·
|
|
* 측구 방향을 새 지형으로 정하는 것과 같은 규칙(넘기는 칸 = 부르는 쪽 입력).
|
|
* crossWithShapes 얹힌 시설 맥락(포장 · 기울임 · 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457).
|
|
* crossAtSeat 자리 칸(암 경계 · 2단 경사 · 손 상단측 · `seats`)은 측점이 그 자리 그대로일 때만 · 새 자리 =
|
|
* 기본값(요구 275-3 · 판정 `sameSeat` · 서버 노선 저장 · Node 넘김과 같은 규칙).
|
|
* draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 그림 자료 · 카드 이름 = 지금
|
|
* 현황을 새 측점에 다시 얹음 · 캐시 `withDraftShapes` 와 같은 `attachShapes` · 457).
|
|
*
|
|
* 순수 — 화면 · fetch · 세션 없음.
|
|
* ========================================================================== */
|
|
|
|
import type {
|
|
CrossSection,
|
|
SectionDetailResponse,
|
|
SectionStation,
|
|
} from "../../B06_Section/B06_Section_Api_Types";
|
|
import type {
|
|
CrossOutput,
|
|
GroundOutput,
|
|
PipelineStation,
|
|
} from "../../A00_Common/design/logic/design_pipeline_types";
|
|
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
|
import { toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
|
import { stationLabel } from "../../common_util/common_util_station";
|
|
import { sameSeat, type StationSeat } from "../../common_util/common_util_station_key";
|
|
import { attachShapes, type AttachType } from "../../common_util/common_util_structure_attach";
|
|
import { registryDefaults } from "../../common_util/common_util_culvert_sets";
|
|
import {
|
|
planStations,
|
|
SAME_STATION_M,
|
|
type PlanPipe,
|
|
type PlanStructure,
|
|
type PlanType,
|
|
} from "../../common_util/common_util_structure_station";
|
|
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
|
|
|
|
/** 측점 열쇠로 이어 받는 카드 번호(`station_id` · 저장 카드 번호를 이어 씀 · 440) — 그림 자료 · 이름은 안 옮김(457). */
|
|
export type SectionSpecs = Partial<Pick<CrossSection, "station_id">>;
|
|
|
|
/** 지금 현황(follow 뒤 관 · 구조물) + 구조물 표 — 새 측점에 그림 · 이름 · 시설 맥락을 다시 얹는 재료(457). */
|
|
export interface ShapeStatus {
|
|
pipes: ReadonlyArray<PlanPipe>;
|
|
structures: ReadonlyArray<PlanStructure>;
|
|
types: ReadonlyArray<AttachType>;
|
|
}
|
|
|
|
/** cross 단계 입력 꼴(`B06_Section_Cross_Stage` `CrossStageInput`) — 칸은 그 단계 몫. */
|
|
export interface CrossInput {
|
|
standard: unknown;
|
|
byKey: Record<string, Record<string, unknown>>;
|
|
rockBoundaryDefaultM?: number | null;
|
|
/** 얹힌 시설 제원 지문(365-5 · 단계는 안 씀 — 바뀌면 고리가 다시 셈). */
|
|
specsKey?: string;
|
|
}
|
|
|
|
const mean = (values: number[]): number => values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
|
|
/** 상단측 — 좌 · 우 유효 지반 평균이 높은 쪽 · 한쪽이 비거나 차이가 1e-6 안이면 null(서버와 같음). */
|
|
export function uphillSide(station: PipelineStation): "left" | "right" | null {
|
|
const valid = (side: "left" | "right"): number[] =>
|
|
(station.ground?.[side] ?? [])
|
|
.filter((sample) => sample.offsetM > 1e-9 && sample.z !== null && Number.isFinite(sample.z))
|
|
.map((sample) => sample.z as number);
|
|
const left = valid("left");
|
|
const right = valid("right");
|
|
if (!left.length || !right.length) return null;
|
|
const gap = mean(left) - mean(right);
|
|
if (Math.abs(gap) <= 1e-6) return null;
|
|
return gap > 0 ? "left" : "right";
|
|
}
|
|
|
|
/** 입력 조각의 자리 칸 묶음 — 이 자리(저장 측점 · 세션 쓴 때)에서 온 입력 이름. */
|
|
export type SeatedFields = StationSeat & { fields: string[] };
|
|
|
|
/** 자리 칸을 측점 자리로 가름 — 묶음(`seats`)의 자리와 지금 측점 자리가 다르면 그 입력을 뺌(기본값) · 묶음은 걷음. */
|
|
export function crossAtSeat(
|
|
input: CrossInput | null,
|
|
stations: ReadonlyArray<PipelineStation>,
|
|
): CrossInput | null {
|
|
if (!input) return null;
|
|
const byKey: CrossInput["byKey"] = { ...input.byKey };
|
|
for (const station of stations) {
|
|
const { seats, ...rest } = byKey[station.key] ?? {};
|
|
if (!Array.isArray(seats)) continue;
|
|
const here = { x: station.x, y: station.y, left: station.left };
|
|
for (const seat of seats as SeatedFields[])
|
|
if (!sameSeat(seat, here)) for (const name of seat.fields) delete rest[name];
|
|
byKey[station.key] = rest;
|
|
}
|
|
return { ...input, byKey };
|
|
}
|
|
|
|
/** 측점마다 단면 유형 기본값(상단측 깎기 · 모르면 좌) — 입력이 정한 단면 유형은 그대로. */
|
|
export function crossWithUphill(
|
|
input: CrossInput | null,
|
|
stations: ReadonlyArray<PipelineStation>,
|
|
): CrossInput | null {
|
|
if (!input) return null;
|
|
const byKey: CrossInput["byKey"] = {};
|
|
for (const station of stations)
|
|
byKey[station.key] = {
|
|
sectionMode: `${uphillSide(station) ?? "left"}_cut`,
|
|
...input.byKey[station.key],
|
|
};
|
|
return { ...input, byKey };
|
|
}
|
|
|
|
/** 소단 한 측점 제원(세션 `berm` 꼴 · 구조물 목록에서 폄). */
|
|
export type BermAt = (
|
|
chainageM: number,
|
|
) => { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | null;
|
|
|
|
/** 측점마다 소단 = 지금 구조물(초안 따라간 자리)의 소단 구간 · 쪽 깊이는 세션 고침(`bermDepthLeftM` ·
|
|
* `bermDepthRightM` · 측점 열쇠) — 노선 초안 동안 소단 고침도 묶음으로(365-5). 구간 밖 측점은 소단 없음. */
|
|
export function crossWithBerm(
|
|
input: CrossInput | null,
|
|
stations: ReadonlyArray<PipelineStation>,
|
|
bermAt: BermAt | null,
|
|
): CrossInput | null {
|
|
if (!input || !bermAt) return input;
|
|
const byKey: CrossInput["byKey"] = { ...input.byKey };
|
|
for (const station of stations) {
|
|
const { bermDepthLeftM, bermDepthRightM, ...rest } = input.byKey[station.key] ?? {};
|
|
const at = bermAt(station.chainageM);
|
|
byKey[station.key] = {
|
|
...rest,
|
|
berm: at && {
|
|
widthM: at.width_m,
|
|
intervalM: at.interval_m,
|
|
slopeDeg: at.slope_deg,
|
|
depthM: at.depth_m,
|
|
depthLeftM: (bermDepthLeftM as number | undefined) ?? null,
|
|
depthRightM: (bermDepthRightM as number | undefined) ?? null,
|
|
},
|
|
};
|
|
}
|
|
return { ...input, byKey };
|
|
}
|
|
|
|
const kindOf = (station: PipelineStation): SectionStation["kind"] =>
|
|
station.key === "end:BP"
|
|
? "bp"
|
|
: station.key === "end:EP"
|
|
? "ep"
|
|
: station.kind === "grid"
|
|
? "regular"
|
|
: station.kind === "curve"
|
|
? "curve"
|
|
: "irregular";
|
|
|
|
/** 저장 상세 → 묶음 cross 출력(365-4 · 저장 노선 = 다시 셈 창구 한 벌이 고친 설계 그대로) — 측점이 상세와
|
|
* 다르면(갈아끼움 전) null. */
|
|
export function detailCross(
|
|
detail: SectionDetailResponse,
|
|
stations: ReadonlyArray<PipelineStation>,
|
|
): CrossOutput | null {
|
|
const sections = detail.cross_sections;
|
|
const same =
|
|
sections.length === stations.length &&
|
|
stations.every((station, i) => Math.abs(station.chainageM - sections[i].chainage_m) < 1e-3);
|
|
if (!same) return null;
|
|
const byKey: CrossOutput["byKey"] = {};
|
|
stations.forEach((station, i) => {
|
|
const { design, samples } = sections[i];
|
|
if (!design) return;
|
|
byKey[station.key] = {
|
|
key: station.key,
|
|
chainageM: station.chainageM,
|
|
cutAreaM2: design.cut_area_m2,
|
|
fillAreaM2: design.fill_area_m2,
|
|
detail: design,
|
|
...(samples ? { samples } : {}),
|
|
} as CrossOutput["byKey"][string];
|
|
});
|
|
return { byKey };
|
|
}
|
|
|
|
/** 묶음 측점 하나 → 횡단(머리 · 지반 표본 · 설계) — 새 노선 상세 · B05 오른쪽 횡단 판(365-3)이 같은 값. */
|
|
export function stationSection(
|
|
station: PipelineStation,
|
|
cross: CrossOutput | null,
|
|
own: SectionSpecs,
|
|
intervalM: number,
|
|
): CrossSection {
|
|
const center = station.ground?.centerZ ?? null;
|
|
const head: SectionStation = {
|
|
station_id: stationIdOf(station.chainageM),
|
|
chainage_m: station.chainageM,
|
|
label: stationLabel(station.chainageM, intervalM),
|
|
kind: kindOf(station),
|
|
center_z: center,
|
|
azimuth_deg: station.azimuthDeg,
|
|
plan_radius_m: station.planRadiusM,
|
|
curve_outer_side: station.curveOuterSide,
|
|
curve_widening_m: station.curveWideningM,
|
|
center_x: station.x,
|
|
center_y: station.y,
|
|
uphill_side: uphillSide(station),
|
|
frame: { left_xy: station.left },
|
|
};
|
|
const sample = (offset: number, z: number | null) => ({
|
|
offset_m: offset,
|
|
elevation_m: z,
|
|
valid: z !== null && Number.isFinite(z),
|
|
});
|
|
const samples = [
|
|
...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)),
|
|
sample(0, center),
|
|
...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)),
|
|
];
|
|
const entry = cross?.byKey[station.key] as { detail?: unknown; samples?: unknown[] } | undefined;
|
|
const design = entry?.detail as CrossSection["design"] | undefined;
|
|
// 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j)
|
|
return {
|
|
...head,
|
|
...own,
|
|
// 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n)
|
|
samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples,
|
|
...(design ? { design: { ...design } } : {}),
|
|
preview: true,
|
|
};
|
|
}
|
|
|
|
/** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획
|
|
* 제목 · 457) — 옛 측점 그림을 옮기지 않음. */
|
|
export function attachStatus(sections: CrossSection[], status: ShapeStatus): void {
|
|
const { pipes, structures, types } = status;
|
|
attachShapes(sections as unknown as Array<Record<string, unknown>>, pipes, structures, types);
|
|
const planned = planStations(pipes, structures, types, registryDefaults(types));
|
|
for (const section of sections) {
|
|
const hit = planned.find((p) => Math.abs(p.chainage_m - section.chainage_m) < SAME_STATION_M);
|
|
if (hit) section.structure = hit.title;
|
|
else delete section.structure;
|
|
}
|
|
}
|
|
|
|
/** 현황 묶음(입력 칸은 묶음 모양 `unknown` · 457) — 정의 옵션 · 이름이 빈 종류는 빈 목록 · 종류 id. */
|
|
export const shapeStatus = (
|
|
pipes: unknown,
|
|
structures: unknown,
|
|
types: ReadonlyArray<PlanType & { name?: string }>,
|
|
): ShapeStatus =>
|
|
({
|
|
pipes,
|
|
structures,
|
|
types: types.map((type) => ({
|
|
...type,
|
|
name: type.name ?? type.type_id,
|
|
options: type.options ?? [],
|
|
})),
|
|
}) as ShapeStatus;
|
|
|
|
/** 그 측점 그림 자료 → 얹힌 시설 맥락(B06 `Cross_Refresh` 와 같은 규칙 · 서버 `tilt_ranges` · `ford_drop_at` 짝). */
|
|
function shapeContext(section: CrossSection): Record<string, unknown> {
|
|
const drop = Number(section.ford?.overflow_depth_m);
|
|
return {
|
|
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
|
|
tilted: Boolean(section.ford_pavement || section.underdrain),
|
|
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
|
surfaceDropM: drop > 0 ? drop : 0,
|
|
};
|
|
}
|
|
|
|
/** 측점마다 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457). */
|
|
export function crossWithShapes(
|
|
input: CrossInput | null,
|
|
stations: ReadonlyArray<PipelineStation>,
|
|
status: ShapeStatus | null,
|
|
): CrossInput | null {
|
|
if (!input || !status) return input;
|
|
const sections = stations.map((station) => ({ chainage_m: station.chainageM }) as CrossSection);
|
|
attachShapes(
|
|
sections as unknown as Array<Record<string, unknown>>,
|
|
status.pipes,
|
|
status.structures,
|
|
status.types,
|
|
);
|
|
const byKey: CrossInput["byKey"] = { ...input.byKey };
|
|
stations.forEach((station, i) => {
|
|
byKey[station.key] = { ...byKey[station.key], ...shapeContext(sections[i]) };
|
|
});
|
|
return { ...input, byKey };
|
|
}
|
|
|
|
/** cross 단계 입력 = 시설 맥락(새 자리 그림) · 단면 유형 기본값(상단측) · 소단(지금 구조물) 얹음 — 페이지 묶음 cross. */
|
|
export const crossWithContext = (
|
|
input: CrossInput | null,
|
|
stations: ReadonlyArray<PipelineStation>,
|
|
status: ShapeStatus | null,
|
|
bermAt: BermAt | null,
|
|
): CrossInput | null =>
|
|
crossWithBerm(
|
|
crossWithUphill(crossWithShapes(crossAtSeat(input, stations), stations, status), stations),
|
|
stations,
|
|
bermAt,
|
|
);
|
|
|
|
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름)는 그대로 · 측점 · 지반 · 횡단 · 계획선 표본은 새 노선
|
|
* (십자선 대체 높이도 초안 종단 · 413) · 그림 자료 · 카드 이름 = `status`(지금 현황)를 새 측점에 다시 얹음(457). */
|
|
export function draftSectionDetail(
|
|
saved: SectionDetailResponse,
|
|
ground: GroundOutput,
|
|
profile: ProfileAlignment,
|
|
cross: CrossOutput | null,
|
|
specs: ReadonlyMap<string, SectionSpecs>,
|
|
intervalM: number,
|
|
status: ShapeStatus | null = null,
|
|
): SectionDetailResponse {
|
|
const sections: CrossSection[] = ground.stations.map((station) =>
|
|
stationSection(station, cross, specs.get(station.key) ?? {}, intervalM),
|
|
);
|
|
if (status) attachStatus(sections, status);
|
|
const { chainageM, z } = ground.longitudinal;
|
|
return {
|
|
longitudinal: {
|
|
...saved.longitudinal,
|
|
length_m: chainageM[chainageM.length - 1] ?? 0,
|
|
samples: chainageM.map((chainage, index) => ({
|
|
chainage_m: chainage,
|
|
elevation_m: z[index],
|
|
z: z[index],
|
|
valid: z[index] !== null,
|
|
})),
|
|
stations: sections.map(({ samples: _s, design: _d, preview: _p, ...head }) => head),
|
|
profile_alignment: profile,
|
|
design_profiles: [toDesignProfile(profile, saved.longitudinal.design_profiles?.[0])],
|
|
},
|
|
cross_sections: sections,
|
|
balloon_offsets: saved.balloon_offsets,
|
|
};
|
|
}
|