303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Page_Link_Session.ts
|
|
* 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기
|
|
* (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
|
|
* 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다.
|
|
*
|
|
* 측점별 조작값을 담는 **한 겹 맵**도 여기서 낸다(`createSessionMap`, 2026-09-02) —
|
|
* 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값이 같은 모양(읽기 → 항목
|
|
* 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch";
|
|
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
|
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
|
import {
|
|
computeCardCulvert,
|
|
culvertLinkFor,
|
|
culvertOwnerFor,
|
|
culvertReach,
|
|
culvertWallsStandAt,
|
|
ownerAppliedAdjusts,
|
|
type ExtraWallControl,
|
|
type InletStructureControl,
|
|
type RevetLinkControl,
|
|
type RevetOffsetControl,
|
|
type SpanValues,
|
|
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
|
import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
|
|
import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc";
|
|
import { renameChainageKeys } from "./logic/B06_Section_Station_Swap";
|
|
import { markMovedStates, onAnySwap } from "./B06_Section_Swap_Bus";
|
|
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
|
|
|
/** 세션에 담기는 측점별 조작값 한 겹 맵. */
|
|
export interface SessionMap<V> {
|
|
values: Map<string, V>;
|
|
/** 세션값 읽기 — 맵을 비우고 다시 채운다. */
|
|
load: () => void;
|
|
/** 맵을 통째로 세션에 쓴다 — 빈 맵이면 칸을 지운다. */
|
|
persist: () => void;
|
|
}
|
|
|
|
/**
|
|
* 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고,
|
|
* `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리).
|
|
*/
|
|
/** 만든 조작값 맵 → 세션 열쇠 — 측점 목록을 갈아끼우면 누가거리 열쇠를 새 자리로 옮김(346-2 4파). */
|
|
const alive = new Map<SessionMap<unknown>, () => string | null>();
|
|
|
|
/**
|
|
* 측점이 움직임(이어 받은 측점의 누가거리가 바뀜 · `옛 0.01 m 글 → 새 글`) — 조정창 값(반폭 · 기슭막이 ·
|
|
* 유입 형식 · 집수정 · 추가 벽 · 단별 구간값)을 새 자리로 옮겨 세션에 씀. 빠진 측점 값은 그대로 둠.
|
|
*/
|
|
export function moveSessionMaps(moves: ReadonlyMap<string, string>): void {
|
|
if (!moves.size) return;
|
|
// 같은 세션 칸은 마지막에 만든 맵 하나만(떠난 보기의 옛 맵이 지금 값을 덮지 않게)
|
|
const latest = new Map<string, SessionMap<unknown>>();
|
|
alive.forEach((key, map) => {
|
|
const at = key();
|
|
if (at) latest.set(at, map);
|
|
});
|
|
for (const map of latest.values()) {
|
|
map.load();
|
|
const next = renameChainageKeys(map.values, moves);
|
|
map.values.clear();
|
|
next.forEach((value, key) => map.values.set(key, value));
|
|
map.persist();
|
|
}
|
|
markMovedStates(latest.keys()); // 나머지 누가거리 칸은 `Station_Keys` 가 옮김(415-3)
|
|
}
|
|
|
|
// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 조정창 값을 옮긴 측점 자리로
|
|
onAnySwap((_detail, moves) => moveSessionMaps(moves));
|
|
|
|
export function createSessionMap<V>(
|
|
sessionKey: () => string | null,
|
|
accept: (value: unknown) => V | undefined,
|
|
): SessionMap<V> {
|
|
const values = new Map<string, V>();
|
|
const map: SessionMap<V> = {
|
|
values,
|
|
load(): void {
|
|
values.clear();
|
|
const key = sessionKey();
|
|
if (!key) return;
|
|
try {
|
|
const raw = readByKey(key);
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
Object.entries(parsed).forEach(([entry, value]) => {
|
|
const taken = accept(value);
|
|
if (taken !== undefined) values.set(entry, taken);
|
|
});
|
|
} catch {
|
|
/* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */
|
|
}
|
|
},
|
|
persist(): void {
|
|
const key = sessionKey();
|
|
if (!key) return;
|
|
try {
|
|
// 빈 맵 = 칸 지움(272) — 「{}」 를 남기면 사진첩이 걸음으로 봄
|
|
writeByKey(key, values.size ? JSON.stringify(Object.fromEntries(values)) : null);
|
|
} catch {
|
|
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
|
}
|
|
},
|
|
};
|
|
alive.set(map as SessionMap<unknown>, sessionKey);
|
|
return map;
|
|
}
|
|
|
|
export interface LinkFlagMaps {
|
|
detached: Map<string, boolean>;
|
|
followGrade: Map<string, boolean>;
|
|
load: () => void;
|
|
persist: () => void;
|
|
}
|
|
|
|
export function createLinkFlagSession(sessionKey: () => string | null): LinkFlagMaps {
|
|
const detached = new Map<string, boolean>();
|
|
const followGrade = new Map<string, boolean>();
|
|
return {
|
|
detached,
|
|
followGrade,
|
|
load(): void {
|
|
detached.clear();
|
|
followGrade.clear();
|
|
const key = sessionKey();
|
|
if (!key) return;
|
|
try {
|
|
const raw = readByKey(key);
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw) as {
|
|
detached?: Record<string, boolean>;
|
|
grade?: Record<string, boolean>;
|
|
};
|
|
Object.entries(parsed.detached ?? {}).forEach(([at, value]) => detached.set(at, !!value));
|
|
Object.entries(parsed.grade ?? {}).forEach(([at, value]) => followGrade.set(at, !!value));
|
|
} catch {
|
|
/* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */
|
|
}
|
|
},
|
|
persist(): void {
|
|
const key = sessionKey();
|
|
if (!key) return;
|
|
writeByKey(
|
|
key,
|
|
detached.size || followGrade.size
|
|
? JSON.stringify({
|
|
detached: Object.fromEntries(detached),
|
|
grade: Object.fromEntries(followGrade),
|
|
})
|
|
: null, // 둘 다 비면 칸 지움(272)
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/* ── 확정 · 임시저장 payload — 세션 키(누가거리 · `:역할` · `b` 앞머리)를 측점별로 되접음 ── */
|
|
type Each<V> = Pick<ReadonlyMap<string, V>, "forEach">;
|
|
|
|
/** 측점별 기슭막이 4축 조작값(역할 → 값 · null = 되돌림). */
|
|
export function foldRevetAdjusts(
|
|
shifts: Each<WallAdjust | null>,
|
|
): Map<number, Record<string, WallAdjust | null>> {
|
|
const result = new Map<number, Record<string, WallAdjust | null>>();
|
|
shifts.forEach((adjust, key) => {
|
|
const [chainage, role] = key.split(":");
|
|
const value = Number(chainage);
|
|
if (!Number.isFinite(value) || !role) return;
|
|
const bucket = result.get(value) ?? {};
|
|
bucket[role] = adjust;
|
|
result.set(value, bucket);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/** 측점별 연동 해제 · 종단경사 반영 플래그. */
|
|
export function foldLinkFlags(
|
|
detachedFlags: Each<boolean>,
|
|
gradeFlags: Each<boolean>,
|
|
): Map<number, { detached?: boolean; followGrade?: boolean }> {
|
|
const result = new Map<number, { detached?: boolean; followGrade?: boolean }>();
|
|
const put = (key: string, patch: { detached?: boolean; followGrade?: boolean }): void => {
|
|
const value = Number(key);
|
|
if (!Number.isFinite(value)) return;
|
|
result.set(value, { ...(result.get(value) ?? {}), ...patch });
|
|
};
|
|
detachedFlags.forEach((detached, key) => put(key, { detached }));
|
|
gradeFlags.forEach((followGrade, key) => put(key, { followGrade }));
|
|
return result;
|
|
}
|
|
|
|
/** 측점별 다단 단 수 — 세션에 있는 쪽만 · 없는 쪽은 바탕(캐시 설계) 값 그대로(445 H3 · `mergeCrossPatches`). */
|
|
export function foldExtraCounts(
|
|
counts: Each<number>,
|
|
): Map<number, Partial<{ outlet: number; basin: number }>> {
|
|
const result = new Map<number, Partial<{ outlet: number; basin: number }>>();
|
|
counts.forEach((count, key) => {
|
|
const basin = key.startsWith("b");
|
|
const value = Number(basin ? key.slice(1) : key);
|
|
if (!Number.isFinite(value)) return;
|
|
const bucket = result.get(value) ?? {};
|
|
bucket[basin ? "basin" : "outlet"] = count;
|
|
result.set(value, bucket);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/** 측점별 다단 단별 구간값. */
|
|
export function foldExtraSpans(
|
|
spans: Each<SpanValues>,
|
|
): Map<number, Record<string, StoredWallSpan>> {
|
|
const result = new Map<number, Record<string, StoredWallSpan>>();
|
|
spans.forEach((value, key) => {
|
|
const [chainage, wall] = key.split(":");
|
|
const chainageM = Number(chainage);
|
|
if (!Number.isFinite(chainageM) || !wall) return;
|
|
const bucket = result.get(chainageM) ?? {};
|
|
bucket[wall] = { length_m: value.lengthM, before_m: value.beforeM, after_m: value.afterM };
|
|
result.set(chainageM, bucket);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 연동을 푸는 순간(카드 [연동] · 좌측 [연동] 둘 다 · 139-2) — 그 카드가 그리던 벽마다 4축을 이
|
|
* 측점 값으로 굳힘: 위치(x · d) = 소유 측점 적용값(물려받던 값) · 높이 · 형태 = 그 카드가 그린 값.
|
|
* d 가 차서 이 측점 지형으로 자동 자리 · 관 정수 맞춤을 다시 타지 않음(튐 없음). 연동 중에 부름.
|
|
*/
|
|
export function freezeLinkedWalls(
|
|
section: CrossSection,
|
|
sections: readonly CrossSection[],
|
|
c: {
|
|
revetOffset: RevetOffsetControl;
|
|
inletStructure?: InletStructureControl;
|
|
extraWalls?: ExtraWallControl;
|
|
revetLink: RevetLinkControl;
|
|
},
|
|
): void {
|
|
const link = section.culvert ? undefined : culvertLinkFor(section, sections, () => null);
|
|
const owner =
|
|
link && ownerAppliedAdjusts(link.source, c.revetOffset, c.inletStructure, c.extraWalls);
|
|
if (!link || !owner) return;
|
|
const card = computeCardCulvert(
|
|
section,
|
|
section.samples,
|
|
null,
|
|
c.revetOffset,
|
|
c.inletStructure,
|
|
c.extraWalls,
|
|
link,
|
|
c.revetLink,
|
|
);
|
|
const drawn = card ? culvertCardState(card).wallSpecs : new Map();
|
|
const roles: Array<[RevetKey, WallAdjust]> = [
|
|
["inlet", owner.inlet],
|
|
["outlet", owner.outlet],
|
|
...owner.extras.map((a, i): [RevetKey, WallAdjust] => [`extra${i}` as RevetKey, a]),
|
|
...owner.basinExtras.map((a, i): [RevetKey, WallAdjust] => [`bextra${i}` as RevetKey, a]),
|
|
];
|
|
for (const [role, applied] of roles) {
|
|
const wall = drawn.get(role);
|
|
if (!wall) continue;
|
|
const round3 = (v: number): number => Math.round(v * 1000) / 1000;
|
|
c.revetOffset.update(section.chainage_m, role, {
|
|
x: round3(applied.x),
|
|
d: applied.d == null ? null : round3(applied.d),
|
|
h: wall.height,
|
|
m: wall.form,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 좌측 관 보호공 [연동] · [경사](54-30) — 옛 연동 제어 그대로 읽고 씀. 연동 = 벽이 서는 옆 측점이
|
|
* 있을 때만 · 켬 = 그 측점이 모두 연동 · 끄면 모두 따로. 경사 = 보호공 구간이 있을 때만.
|
|
* 풀 때 굳히기 · 다시 걸 때 지우기는 `setLinked` 가 함(139-2 · 카드 단추와 한 길).
|
|
*/
|
|
export function pipeRevetChoiceBridge(revetLink: RevetLinkControl) {
|
|
const linkCards = (owner: CrossSection, sections: readonly CrossSection[]) =>
|
|
sections.filter((s) => culvertOwnerFor(s, sections) === owner && culvertWallsStandAt(owner, s));
|
|
return {
|
|
read: (owner: CrossSection, sections: readonly CrossSection[]): PipeRevetChoice => {
|
|
const cards = linkCards(owner, sections);
|
|
return {
|
|
linked: cards.length ? cards.every((s) => revetLink.linkedFor(s)) : null,
|
|
grade: culvertReach(owner) ? revetLink.followGradeFor(owner) : null,
|
|
};
|
|
},
|
|
write: (
|
|
owner: CrossSection,
|
|
sections: readonly CrossSection[],
|
|
kind: "link" | "grade",
|
|
on: boolean,
|
|
): void => {
|
|
if (kind === "grade") revetLink.setFollowGrade(owner, on);
|
|
else linkCards(owner, sections).forEach((s) => revetLink.setLinked(s.chainage_m, on));
|
|
},
|
|
};
|
|
}
|