- 맞춤(reconcile) 다시 먹일 입력 판정을 design_pipeline_cross.ts staleInputs 로 · cross 는 crossAtSeat 뒤 견줌 - 끌기 cross 차례 · 횡단 보기 진입 cross 차례 사라짐(cross 셈 2 → 0) · 값(누가토량 · 횡단 · 측점 · 칸) 그대로 - design_pipeline_page.ts 699 → 697 줄 - test_349_0o xfail 둘 걷음 · 쓰지 않는 DRIFT 표지 지움 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
14 KiB
TypeScript
279 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* A00_Common/design/design_pipeline_cross.ts
|
|
* 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 사용자 횡단 입력 · 카드 번호를
|
|
* **측점 열쇠**로 모음. 노선이 바뀌어도 같은 열쇠 측점(격자 번호 · 관 id · 구조물 id)이 그 입력을 받음.
|
|
* 열쇠 = 저장 노선을 stations 단계와 같은 함수로 짚은 측점(`buildStations`)과 누가거리로 짝지음 —
|
|
* 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id). 초안 자리로 뜬 시설 측점 = 등록 열쇠(주인 id)로 짝(457).
|
|
*
|
|
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
|
|
* 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) + 손 상단측(단면
|
|
* 유형 · 측구 방향) — 사용자 칸만. 저장분의 자리 칸(`SEAT_KEYS` · 암 경계 · 2단 경사 · 손 상단측)은 저장 측점 자리를
|
|
* 함께 실어(`seat`) 새 자리로 간 측점에서 뺌(`crossAtSeat` · 요구 275-3) · 세션 고침은 끌기 넘김이 이미 가름.
|
|
* 그림 자료 · 카드 이름 · 얹힌 시설 맥락(포장 · 기울임 · 하강) · 단면 유형 · 측구 방향은 새 자리에서 다시 셈
|
|
* (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithShapes` · `draftSectionDetail` · `crossWithUphill` · 457). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 ·
|
|
* 지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에
|
|
* 안 얹혀도 입력에 들어감(365-2 · 365-5 · 417). 부를 때마다 모음.
|
|
* ========================================================================== */
|
|
|
|
import { readState } from "../b_page_state";
|
|
import { savedDesigns } from "../../B06_Section/B06_Section_Section_Canon";
|
|
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
|
import {
|
|
effectiveStandardCross,
|
|
readRockBoundaryDefault,
|
|
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
|
import {
|
|
crossAtSeat,
|
|
type CrossInput,
|
|
type SeatedFields,
|
|
type SectionSpecs,
|
|
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
|
import { SEAT_HINT } from "../../B06_Section/logic/B06_Section_Route_Carry";
|
|
import {
|
|
SOURCE_INPUT,
|
|
type ChangeSource,
|
|
type PipelineInputs,
|
|
type PipelineStation,
|
|
} from "./logic/design_pipeline_types";
|
|
import type { DesignPipeline } from "./logic/design_pipeline";
|
|
import type { StationSeat } from "../../common_util/common_util_station_key";
|
|
|
|
export interface PipelineCrossSource {
|
|
input: CrossInput;
|
|
/** 측점 열쇠 → 카드 번호(457 · 그림 자료는 새 자리에서 다시 얹음). */
|
|
specs: Map<string, SectionSpecs>;
|
|
}
|
|
|
|
/** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */
|
|
const SAME_M = 0.01;
|
|
|
|
/** 짧은 지문(FNV-1a) — 제원 묶음이 같은지만 견줌. */
|
|
function digest(text: string): string {
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < text.length; i += 1) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
return `${text.length}:${hash >>> 0}`;
|
|
}
|
|
|
|
/** 다시 셈 부탁 수(387-4) — 입력 칸에 없는 세션 고침(노견 · 소단 구간 · 조정창 4축 · 단면 유형)을 고친 화면이
|
|
* 올림 · cross 입력 지문에 들어가 같은 입력이어도 묶음 cross 가 한 번 더 돎(다시 셈 창구 한 곳). */
|
|
let forced = 0;
|
|
export const forceCross = (): void => void (forced += 1);
|
|
|
|
const finite = (value: unknown): number | null =>
|
|
typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
|
|
/** 저장분 자리 칸의 입력 이름(`SEAT_KEYS` 짝 · 요구 275-3). */
|
|
const SEAT_INPUTS = [
|
|
"twoStageSlope",
|
|
"rockBoundaryOffsetM",
|
|
"cutSlopeRatio",
|
|
"sectionMode",
|
|
"ditchSide",
|
|
] as const;
|
|
|
|
/** 측점 하나의 넘기는 사용자 입력(cross 단계 `CrossStationInput` 꼴 · 시설 맥락은 새 자리 그림에서 · 457). */
|
|
function carried(section: CrossSection, saved?: CrossSection["design"]): Record<string, unknown> {
|
|
const design = (saved ?? section.design ?? {}) as unknown as Record<string, unknown>;
|
|
const berm = design.berm as
|
|
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | undefined;
|
|
const cutSlope = finite(design.cut_slope_ratio_user);
|
|
return {
|
|
...(typeof design.ground_type === "string" ? { groundType: design.ground_type } : {}),
|
|
...(typeof design.ditch_type === "string" ? { ditchType: design.ditch_type } : {}),
|
|
ditchChoice: typeof design.ditch_choice === "boolean" ? design.ditch_choice : null,
|
|
// 옛 저장분 측구 결과 — 다시 셈 창구와 같은 규칙(자동과 다를 때만 선택 · 들어온 화면 무관 · 365-5)
|
|
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
|
|
twoStageSlope: Boolean(design.two_stage_slope),
|
|
rockBoundaryOffsetM: finite(design.rock_boundary_offset_m),
|
|
cutSlopeRatio: cutSlope !== null && cutSlope > 0 ? cutSlope : null,
|
|
...(typeof design.section_mode === "string" ? { sectionMode: design.section_mode } : {}),
|
|
...(typeof design.ditch_side === "string" ? { ditchSide: design.ditch_side } : {}),
|
|
berm: berm
|
|
? {
|
|
widthM: berm.width_m,
|
|
intervalM: berm.interval_m,
|
|
slopeDeg: berm.slope_deg,
|
|
depthM: berm.depth_m,
|
|
}
|
|
: null,
|
|
// 저장분 사면 쉬프트 · 소단 깊이(417 L4) — 세션 고침이 없어도 저장 뒤 그대로
|
|
...sides(design, "shoulder_extend", "shoulderExtend"),
|
|
...sides(design, "berm_depth", "bermDepth"),
|
|
};
|
|
}
|
|
|
|
/** 저장분 쪽마다 값(`{field}_{left|right}_m`) → 입력 칸(`{name}LeftM` · `{name}RightM`) — 있는 쪽만. */
|
|
function sides(design: Record<string, unknown>, field: string, name: string) {
|
|
const out: Record<string, number> = {};
|
|
for (const [side, tail] of [
|
|
["left", "LeftM"],
|
|
["right", "RightM"],
|
|
] as const) {
|
|
const value = finite(design[`${field}_${side}_m`]);
|
|
if (value !== null) out[`${name}${tail}`] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 세션 칸 하나를 측점 열쇠마다 [열쇠, 값, 쓴 때 자리] — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
|
|
function sessionCells(
|
|
name: "crossdesign" | "rockb" | "cutslope" | "bermdepth" | "shoulderext",
|
|
projectId: string,
|
|
routeId: number,
|
|
): Array<[string, unknown, StationSeat | undefined]> {
|
|
const raw = readState<Record<string, unknown>>(name, projectId, routeId);
|
|
if (!raw || typeof raw !== "object") return [];
|
|
const seats = (raw[SEAT_HINT] ?? {}) as Record<string, StationSeat>;
|
|
return Object.entries(raw)
|
|
.filter(([key]) => key.includes(":") && !key.startsWith("at:"))
|
|
.map(([key, value]) => [key, value, seats[key]]);
|
|
}
|
|
|
|
/** 이 자리에서 온 자리 칸 이름을 입력 조각에 붙임(`crossAtSeat` 가 새 자리면 뺌 · 요구 275-3). */
|
|
function seatOn(entry: Record<string, unknown>, seat: StationSeat | undefined, fields: string[]) {
|
|
if (seat && fields.length) ((entry.seats ??= []) as SeatedFields[]).push({ ...seat, fields });
|
|
}
|
|
|
|
/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름 · 자리 칸은 쓴 때 자리째). */
|
|
function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] {
|
|
const out: CrossInput["byKey"] = {};
|
|
for (const [key, value, seat] of sessionCells("crossdesign", projectId, routeId)) {
|
|
const choice = (value ?? {}) as Record<string, unknown>;
|
|
const entry = (out[key] ??= {});
|
|
if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type;
|
|
if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type;
|
|
if ("ditch_choice" in choice)
|
|
entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null;
|
|
if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope);
|
|
if (typeof choice.section_mode === "string") entry.sectionMode = choice.section_mode;
|
|
if (typeof choice.ditch_side === "string") entry.ditchSide = choice.ditch_side;
|
|
seatOn(
|
|
entry,
|
|
seat,
|
|
["twoStageSlope", "sectionMode", "ditchSide"].filter((name) => name in entry),
|
|
);
|
|
}
|
|
for (const [key, value, seat] of sessionCells("rockb", projectId, routeId))
|
|
if (finite(value) !== null) {
|
|
const entry = (out[key] ??= {});
|
|
entry.rockBoundaryOffsetM = value;
|
|
seatOn(entry, seat, ["rockBoundaryOffsetM"]);
|
|
}
|
|
for (const [key, value, seat] of sessionCells("cutslope", projectId, routeId)) {
|
|
const ratio = finite(value);
|
|
if (ratio === null) continue;
|
|
const entry = (out[key] ??= {});
|
|
entry.cutSlopeRatio = ratio > 0 ? ratio : null;
|
|
seatOn(entry, seat, ["cutSlopeRatio"]);
|
|
}
|
|
// 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5) · 사면 쉬프트(139-6 · 417 M3)
|
|
for (const [state, name] of [
|
|
["bermdepth", "bermDepth"],
|
|
["shoulderext", "shoulderExtend"],
|
|
] as const)
|
|
for (const [key, value] of sessionCells(state, projectId, routeId)) {
|
|
const sides = (value ?? {}) as Record<string, unknown>;
|
|
if (finite(sides.left) !== null) (out[key] ??= {})[`${name}LeftM`] = sides.left;
|
|
if (finite(sides.right) !== null) (out[key] ??= {})[`${name}RightM`] = sides.right;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 초안 자리로 뜬 측점 → 처음 본 등록 열쇠 — 노선 초안 동안 등록 목록이 새 노선 것으로 바뀌어도 그대로(457). */
|
|
const strayKeys = new WeakMap<CrossSection, string>();
|
|
|
|
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 카드 번호 — 표준단면 ·
|
|
* 상세가 없으면 null. `keysOf` = 상세 측점마다 등록 열쇠(B06 측점 열쇠 목록 · 초안 자리 측점 짝 · 457). */
|
|
export function pipelineCrossSource(
|
|
projectId: string,
|
|
detail: SectionDetailResponse | null,
|
|
savedStations: ReadonlyArray<PipelineStation>,
|
|
routeId: number | null = null,
|
|
keysOf?: (routeId: number, detail: SectionDetailResponse) => string[],
|
|
): PipelineCrossSource | null {
|
|
const standard = detail && effectiveStandardCross(projectId);
|
|
if (!detail || !standard) return null;
|
|
const byKey: CrossInput["byKey"] = {};
|
|
const specs = new Map<string, SectionSpecs>();
|
|
/** 저장분을 실은 측점 열쇠 → 저장 측점 자리(자리 칸 판정 · 요구 275-3). */
|
|
const seats = new Map<string, StationSeat>();
|
|
// 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5)
|
|
const stored = routeId === null ? null : savedDesigns(projectId, routeId); // 세션 다시 안 풂(387-7)
|
|
const storedAt = (chainageM: number) =>
|
|
stored?.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design;
|
|
const put = (
|
|
key: string,
|
|
section: CrossSection,
|
|
owner: PipelineStation,
|
|
at = section.chainage_m,
|
|
): void => {
|
|
byKey[key] = carried(section, storedAt(at));
|
|
seats.set(key, { x: owner.x, y: owner.y, left: owner.left }); // 저장 측점 자리
|
|
// 카드 번호 = 저장 카드 번호(같은 측점 열쇠 · 옮긴 관도 · 찾기 · 고름이 같은 id · 440)
|
|
specs.set(key, { station_id: section.station_id });
|
|
};
|
|
const strays: CrossSection[] = [];
|
|
for (const section of detail.cross_sections) {
|
|
let best: PipelineStation | null = null;
|
|
for (const station of savedStations) {
|
|
const gap = Math.abs(station.chainageM - section.chainage_m);
|
|
if (gap < SAME_M && (!best || gap < Math.abs(best.chainageM - section.chainage_m)))
|
|
best = station;
|
|
}
|
|
if (best && !byKey[best.key]) put(best.key, section, best);
|
|
else if (!best && section.kind === "irregular") strays.push(section);
|
|
}
|
|
// 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 등록 열쇠(주인 id)가 같은 저장
|
|
// 시설 측점과 짝 — 차례로 짝짓지 않음(엇갈려 옮긴 관 · 457) · 모르는 열쇠는 버림
|
|
const keys = strays.length && routeId !== null ? (keysOf?.(routeId, detail) ?? null) : null;
|
|
for (const section of strays) {
|
|
const index = detail.cross_sections.indexOf(section);
|
|
const key = strayKeys.get(section) ?? keys?.[index];
|
|
if (!key) continue;
|
|
strayKeys.set(section, key);
|
|
const owner = savedStations.find(
|
|
(station) => station.key === key && (station.kind === "pipe" || station.kind === "structure"),
|
|
);
|
|
if (owner && !byKey[key]) put(key, section, owner, owner.chainageM);
|
|
}
|
|
const session = routeId === null ? {} : sessionCarried(projectId, routeId);
|
|
for (const [key, entry] of Object.entries(session)) byKey[key] = { ...byKey[key], ...entry };
|
|
// 저장분에서 온 자리 칸 = 저장 측점 자리째(세션이 고친 칸은 세션 값과 그 자리)
|
|
for (const [key, seat] of seats) {
|
|
const own = session[key] ?? {};
|
|
seatOn(
|
|
byKey[key],
|
|
seat,
|
|
SEAT_INPUTS.filter((name) => byKey[key][name] !== undefined && !(name in own)),
|
|
);
|
|
}
|
|
return {
|
|
// 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p)
|
|
// 카드 번호 지문(365-5 · 시설 그림이 바뀌면 관 · 구조물 입력이 cross 를 다시 돌림 · 457)
|
|
input: {
|
|
standard,
|
|
byKey,
|
|
rockBoundaryDefaultM: readRockBoundaryDefault(projectId),
|
|
specsKey: `${digest(JSON.stringify([...specs]))}|${forced}`,
|
|
},
|
|
specs,
|
|
};
|
|
}
|
|
|
|
/** 맞춤이 다시 먹일 입력 — 지금 입력이 묶음 입력 · 넘긴 입력(follow 전) 둘 다와 다른 것. cross 는 지금 측점 자리로
|
|
* 자리 칸을 가른 뒤 견줌(`crossAtSeat` — 노선 초안 넘김이 새 자리 자리 칸만 걷은 것 = 같은 값 · 508-1c). */
|
|
export function staleInputs(
|
|
now: PipelineInputs,
|
|
given: Partial<PipelineInputs>,
|
|
pipe: DesignPipeline,
|
|
): Array<[ChangeSource, unknown]> {
|
|
const stations = pipe.output("ground")?.stations ?? [];
|
|
const seen = (key: keyof PipelineInputs, value: unknown): string =>
|
|
JSON.stringify(key === "cross" ? crossAtSeat(value as CrossInput | null, stations) : value);
|
|
return (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
|
|
.filter(([, key]) =>
|
|
[pipe.input(key), given[key]].every((was) => seen(key, was) !== seen(key, now[key])),
|
|
)
|
|
.map(([source, key]) => [source, now[key]]);
|
|
}
|