Files
Aislo/B06_Section/logic/B06_Section_Route_Carry.ts
T
eomsangdonandClaude Opus 5.5 de0141b8fc refactor(472-6 6-B 한 벌): 노선 바뀜 넘김 규칙 · 세션 고침 얹기 · [저장] patch 바탕을 순수 TS 로 떼어 냄 — 동작 같음
- logic/B06_Section_Route_Carry.ts = 측점 열쇠 목록 · 측점 열쇠 칸 · 누가거리 칸 넘김(Station_Keys 몸통) · Station_Keys = 세션 겉
- B06_Section_Route_Carry_Patch.ts = 조정창 · 옮긴 고침 · 관 옵션 얹기 · crossPatchesFromCache · 서버용 routeCarryPatches(옛 carryAfterRouteSave 차례) · Section_Adjusts · Section_Store = 겉 · 내줌
- 칸 목록 USER_TOUCHED_KEYS · STRUCTURE_ADJUST_KEYS = logic/B06_Section_Design_Keys.ts · PIPE_MATCH_M = common_util_pipe_match.ts(옛 자리는 내줌)
- 시험 헬퍼 · 글 단언 시험 = 옮긴 자리로(목록 한 벌 자리만 바뀜)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:33:28 +09:00

301 lines
14 KiB
TypeScript

/* =============================================================================
* B06_Section_Route_Carry.ts
* 노선이 바뀔 때 횡단 고침 넘김 규칙 **한 벌**(PLAN 344-3 · 417 · 472-6 6-B) — 순수 계산(세션 · 화면 없음).
*
* · 측점 열쇠 목록 = `stationKeysFrom`(일반 grid:k · 관 pipe:<id> · 구조물 structure:<id> · 시종점 end:BP/EP ·
* id 모름 at:<0.01 m> · 규칙 = `common_util_station_key`).
* · 측점 열쇠 칸 다섯 = `carryKeyedStates` — 넘김 = 카드 선택 넷 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이 ·
* 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 고침은 버림(수만 셈).
* · 누가거리 열쇠 칸(`ROUTE_CARRY_STATES`) = `carryChainageStates` — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리 ·
* 구조물 조절 = 관 · 구조물 열쇠만.
* 쓰는 곳 = 브라우저 세션(`B06_Section_Station_Keys` · 끌기 동안 같은 노선 안 목록 갈림) · 서버 Node 입구
* `stations.carry`(노선 바뀐 [저장] · `design_calc_node_carry.ts`).
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS` + `STRUCTURE_ADJUST_KEYS` · 거울 시험 `test_417_route_carry.py`.
* ========================================================================== */
import {
chainageKey,
curveMoves,
dedupeStationKeys,
looseChainage,
stationKey,
type StationKeyRole,
} from "../../common_util/common_util_station_key";
import type { CrossSection } from "../B06_Section_Api_Fetch";
/** 측점 열쇠로 담는 세션 칸. */
export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope";
export const KEYED_STATES: readonly KeyedState[] = [
"crossdesign",
"shoulderext",
"bermdepth",
"rockb",
"cutslope",
];
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"];
/**
* 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸.
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) ·
* 거울 시험 `resources/tester/test_417_route_carry.py`.
*/
export const ROUTE_CARRY_STATES = {
revetx: ["revet_adjust"],
basinadjust: ["basin_adjust"],
inletstruct: ["inlet_structure"],
extrawall: ["extra_wall_counts"],
extraspan: ["extra_spans"],
revetlink: ["revet_link_detached", "revet_follow_grade"],
fordadjust: ["ford_adjust"],
boxadjust: ["box_adjust"],
crossw: ["display_half_width_m"],
"structure-notes": ["structure_note"],
"structure-note-cads": ["structure_note_cad"],
"detail-table": ["detail_table"],
shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"],
bermdepth: ["berm_depth_left_m", "berm_depth_right_m"],
uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 [저장]은 안 보냄(요구 274)
} as const;
/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */
const STRUCTURE_STATES = new Set<string>([
"revetx",
"basinadjust",
"inletstruct",
"extrawall",
"extraspan",
"revetlink",
"fordadjust",
"boxadjust",
]);
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
export const NEAR_M = 0.01;
export const HINT = "@at";
export type Keyed = { key: string; chainageM: number };
export type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
/** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */
export type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole };
/** 관 측점의 관 id — 관 장부(세션 `pipe-sN`) · 찾기만(`near`) · 없으면 지음(`at`). */
export interface PipeIdBook {
near: (chainageM: number) => string | null;
at: (chainageM: number) => string;
}
const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이
* 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */
export function stationKeysFrom(
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
known: ReadonlyArray<Owner>,
pipes: PipeIdBook | null,
): Keyed[] {
const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section, index) => {
const at = section.chainage_m;
if (index === 0) return stationKey("bp", at, intervalM);
if (index === sections.length - 1) return stationKey("ep", at, intervalM);
if (section.kind !== "irregular") return stationKey(section.kind, at, intervalM);
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) return stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
if (looksLikePipe(section)) {
// 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E)
const id = pipes ? (planned ? pipes.near(at) : pipes.at(at)) : null;
return id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM);
}
return section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
});
const chainages = sections.map((section) => section.chainage_m);
return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] }));
}
/** 목록에서 그 자리(0.01 m 안) 가장 가까운 측점. */
export function nearestKeyed(list: ReadonlyArray<Keyed>, chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
const gap = Math.abs(entry.chainageM - chainageM);
if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry;
}
return best;
}
/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */
export function keyIn(list: ReadonlyArray<Keyed> | undefined, chainageM: number): string {
return (list && nearestKeyed(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`;
}
/**
* 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금
* 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖).
*/
export function chainageIn(
list: ReadonlyArray<Keyed> | undefined,
key: string,
hint?: number,
): number | null {
const hit = list?.find((entry) => entry.key === key);
if (hit) return Number(chainageKey(hit.chainageM));
const at = looseChainage(key) ?? hint;
if (at === undefined) return null;
if (!list) return Number(chainageKey(at));
const near = nearestKeyed(list, at);
return near ? Number(chainageKey(near.chainageM)) : null;
}
/** 두 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */
export function curveMovesIn(
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
): Map<string, string> {
const pairs = (list: ReadonlyArray<Keyed> | undefined) =>
(list ?? []).map((e) => [e.key, e.chainageM] as const);
return curveMoves(pairs(from), pairs(to));
}
/** 칸 원본 → [열쇠, 값, 쓴 때 자리]. */
export function rawEntries<V>(raw: Raw): Array<[string, V, number | undefined]> {
const hints = raw[HINT] ?? {};
return Object.entries(raw)
.filter(([key]) => key !== HINT)
.map(([key, value]) => [key, value as V, hints[key]]);
}
/** 칸 원본을 목록 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌. */
export function readKeyedIn<V>(raw: Raw, list: ReadonlyArray<Keyed> | undefined): Map<string, V> {
const out = new Map<string, V>();
for (const [key, value, hint] of rawEntries<V>(raw)) {
const at = chainageIn(list, key, hint);
if (at !== null) out.set(chainageKey(at), value);
}
return out;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
if (name !== "crossdesign" || !value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const picked = Object.fromEntries(
CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
return Object.keys(picked).length ? picked : null;
}
/**
* 측점 열쇠 칸 다섯을 옛 목록 → 새 목록으로(넘김 규칙 = 머리 주석) — 칸마다 새 원본(빈 칸도) · 노선 밖으로 나간
* 고침의 열쇠(측점 단위).
*/
export function carryKeyedStates(
read: (name: KeyedState) => Raw,
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
): { states: Record<KeyedState, Raw>; outside: Set<string> } {
const outside = new Set<string>();
const curves = curveMovesIn(from, to);
const states = {} as Record<KeyedState, Raw>;
for (const name of KEYED_STATES) {
const next: Raw = { [HINT]: {} };
for (const [key, value] of rawEntries<unknown>(read(name))) {
// 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로
const fromAt = looseChainage(key) === null ? null : chainageIn(from, key);
const was = fromAt === null ? key : keyIn(from, fromAt);
const stable = curves.get(was) ?? was;
const toAt = chainageIn(to, stable, looseChainage(stable) ?? undefined);
const carried = carriedValue(name, value);
if (toAt === null || stable.startsWith("at:")) {
if (carried !== null) outside.add(stable);
continue;
}
if (carried === null) continue;
const toKey = keyIn(to, toAt);
next[toKey] = carried;
next[HINT]![toKey] = toAt;
}
states[name] = next;
}
return { states, outside };
}
/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */
export interface CarryFacilities {
/** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */
movedAt?: (chainageM: number) => number | null | undefined;
/** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */
keyAt?: (key: string) => number | null | undefined;
}
/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */
const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
/**
* 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고 · 관 종류 바꿈 자리 `kindreset`)을 옛 목록 → 새 목록
* 측점으로 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 · 못 옮긴 고침은 버림.
* 돌려줌 = 원본이 있던 칸만 새 원본 · 버린 고침의 열쇠(측점 단위).
* 시설 풀이(`facilities`) = 옛 저장 시설 측점 → 새 노선 그 시설 측점(좌표 짝 · 초안으로 안 따라간 고침) · 관 ·
* 구조물 열쇠 → 새 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`).
*/
export function carryChainageStates(
read: (name: string) => Record<string, unknown> | null,
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
facilities: CarryFacilities = {},
): { states: Record<string, Record<string, unknown>>; outside: Set<string> } {
const facilityAt = facilities.keyAt ?? (() => null);
const outside = new Set<string>();
const curves = curveMovesIn(from, to);
const moveAt = (state: string, text: string): string | null => {
const moved = facilities.movedAt?.(Number(text));
if (moved !== null && moved !== undefined) return chainageKey(moved);
const was = keyIn(from, Number(text));
const stable = curves.get(was) ?? was;
const structural = stable.startsWith("pipe:") || stable.startsWith("structure:");
// 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점)
const toAt =
stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural)
? null
: chainageIn(to, stable, (structural ? facilityAt(stable) : null) ?? Number(text));
if (toAt === null) outside.add(stable);
return toAt === null ? null : chainageKey(toAt);
};
const rekey = (state: string, cells: Record<string, unknown>): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cells)) {
const head = CHAINAGE_HEAD.exec(key);
const to = head && moveAt(state, head[2]);
if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value;
}
return next;
};
const states: Record<string, Record<string, unknown>> = {};
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryKeyedStates
const raw = read(state);
if (!raw || typeof raw !== "object") continue;
// 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹
states[state] =
state === "revetlink"
? Object.fromEntries(
Object.entries(raw).map(([part, cells]) => [
part,
cells && typeof cells === "object"
? rekey(state, cells as Record<string, unknown>)
: cells,
]),
)
: rekey(state, raw);
}
// 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7)
const resets = read("kindreset");
if (resets) states.kindreset = rekey("revetx", resets);
return { states, outside };
}