/* ============================================================================= * 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; } /** 같은 측점으로 보는 누가거리 차(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 { const design = (saved ?? section.design ?? {}) as unknown as Record; 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, field: string, name: string) { const out: Record = {}; 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>(name, projectId, routeId); if (!raw || typeof raw !== "object") return []; const seats = (raw[SEAT_HINT] ?? {}) as Record; 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, 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; 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; 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(); /** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 카드 번호 — 표준단면 · * 상세가 없으면 null. `keysOf` = 상세 측점마다 등록 열쇠(B06 측점 열쇠 목록 · 초안 자리 측점 짝 · 457). */ export function pipelineCrossSource( projectId: string, detail: SectionDetailResponse | null, savedStations: ReadonlyArray, 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(); /** 저장분을 실은 측점 열쇠 → 저장 측점 자리(자리 칸 판정 · 요구 275-3). */ const seats = new Map(); // 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 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, 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]]); }