Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-19 14:31:35 +09:00
26 changed files with 570 additions and 411 deletions
+14 -5
View File
@@ -28,7 +28,6 @@ import {
type StandardCrossSection,
} from "../../B06_Section/B06_Section_Api_Fetch";
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import {
createCutSlopeStore,
createRockBoundaryStore,
@@ -298,7 +297,6 @@ export async function mountCrossView(
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
},
});
setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4)
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
@@ -540,9 +538,9 @@ export async function mountCrossView(
patchSources: () => ({
rockOffsets: rockStore.offsets,
cutSlopeRatios: cutSlopeStore.ratios,
stationWidths: stationControls.widths,
inletStructures: stationControls.inletStructures,
basinAdjustments: stationControls.basinAdjustments,
stationWidths: stationControls.widths(),
inletStructures: stationControls.inletStructures(),
basinAdjustments: stationControls.basinAdjustments(),
revetAdjusts: stationControls.revetAdjustsByChainage(),
extraCounts: stationControls.extraCountsByChainage(),
extraSpans: stationControls.extraSpansByChainage(),
@@ -582,6 +580,17 @@ export async function mountCrossView(
host.onDispose(shop.subscribe("structures", onShop));
host.onDispose(shop.subscribe("pipes", onShop));
host.onDispose(followSwaps(projectId, () => currentRouteId, host, refreshDetailForStructures));
// ↶ ↷ 가 조정창 칸을 되씀 — 상세 가게 얹기(고치기 전 칸 + 지금 칸)로 캐시 · 카드 다시 · 면적 다시 셈(472-5 5-D)
host.onDispose(
stationControls.onRestore(() => {
if (!projectId || currentRouteId === null) return;
if (host.visible()) reloadPanel(); // 폼 [연동] · [경사] · 단 수도 되돌린 칸으로
const again = host.visible()
? refreshDetailForStructures()
: loadSectionDetail(projectId, currentRouteId);
void again.then(() => reconcileStaleDesigns({ force: true }));
}),
);
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer);
@@ -118,13 +118,6 @@ const STATION_ADJUST_STATES: readonly StateName[] = [
"boxadjust",
];
let reloadStationAdjusts: (() => void) | null = null;
/** 횡단 보기가 메모리 사본을 세션에서 다시 읽는 창구(한 페이지 하나 · 나중 것이 씀). */
export function setStationAdjustReload(reload: () => void): void {
reloadStationAdjusts = reload;
}
const atChainage = (key: string, chainageM: number): boolean => {
const head = /^b?(-?\d+(?:\.\d+)?)/.exec(key);
return head !== null && Math.abs(Number(head[1]) - chainageM) < PIPE_MATCH_M;
@@ -168,7 +161,6 @@ export function forgetStationAdjusts(
if (hit !== undefined) delete marks[hit];
if (next !== first) marks[chainageM.toFixed(2)] = first;
writeState("kindreset", Object.keys(marks).length ? marks : null, projectId);
reloadStationAdjusts?.();
}
/** [저장] 때 옛 조정값을 정본에서 비울 측점 — 종류를 바꾼 관 자리(누가거리). */
+24 -1
View File
@@ -10,10 +10,13 @@
* 흙막이 n 단(h · m · 구간). 예전에는 B06 보기가 열릴 때 폼이 되보내야만 얹혀 B05 만 연 면적이 달랐음.
* 형상(관 세트 · 흙막이 세트 · 도수로 · 세월교 · BOX 제원)은 형상 얹기(`attachShapes`)가 이미 함.
* 같은 규칙 = `B06_Section_UI_Page_Pipe_Options`(폼을 고친 그 자리 즉시 반영 몫).
*
* 조정창 값 주인 = 세션 칸(472-5 5-D) — 얹기 = 「조정창이 처음 고치기 전 설계 칸 + 지금 세션 칸」. ↶ ↷ 가 칸을
* 지워도 캐시 설계(그림 · [저장] 바탕)에 옛 값이 안 남음. 상세가 새로 서면(받음 · [저장] · 떠남) 새 객체라 빔.
* ========================================================================== */
import { readState } from "../A00_Common/b_page_state";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { readBermDepths, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import {
layCarriedEdits,
@@ -26,11 +29,31 @@ import {
// 얹기 몸통 = `B06_Section_Route_Carry_Patch` 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6) — 여기는 세션 겉.
export { layPipeOptions, type OptionPipe };
/** 조정창이 처음 고치기 전 설계 칸(측점 객체마다 · 칸 이름 → 값 · 다시 셈이 설계 객체를 갈아도 측점은 그대로). */
const beforeAdjust = new WeakMap<CrossSection, Record<string, unknown>>();
/** 조정창이 캐시 설계 칸을 고치기 직전 — 그 칸의 처음 값을 한 번 떠 둠. */
export function noteAdjustBase(section: CrossSection, keys: readonly string[]): void {
const design = section.design as unknown as Record<string, unknown> | null | undefined;
if (!design) return;
const base = beforeAdjust.get(section) ?? {};
for (const key of keys) if (!(key in base)) base[key] = structuredClone(design[key]);
beforeAdjust.set(section, base);
}
export function withDraftAdjusts(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): void {
for (const section of detail.cross_sections) {
const base = beforeAdjust.get(section);
const design = section.design as unknown as Record<string, unknown> | null | undefined;
if (!base || !design) continue;
for (const [key, value] of Object.entries(base))
if (value === undefined) delete design[key];
else design[key] = structuredClone(value);
}
layDraftAdjusts(detail, (name) => readState<Cells>(name as never, projectId, routeId) ?? {});
}
+91
View File
@@ -0,0 +1,91 @@
/* =============================================================================
* B06_Section_Session_Cells.ts
* 조정창 값 창구(472-5 5-D) — 측점별 조작값(반폭 · 기슭막이 4축 · 유입 형식 · 집수정 · 단 수 · 단별 구간 · 연동 ·
* 세월교 · BOX)의 주인은 **세션 칸 하나**. 메모리 사본 없음 — 읽을 때마다 칸을 풀고 쓸 때마다 칸에 씀(창구 =
* 등록부 `b_page_state` 열쇠 · 가게도 밑은 이것). 되돌리기 · 다시하기 · 측점 갈아끼움 이동(`Station_Keys`)이 칸을
* 되써도 다음 조작이 옛 값으로 덮지 않음(N5).
* 가벼운 자리 — 등록부만 끎(세월교 · BOX 제어 · 시험 로더가 화면 줄기를 안 끌게).
* ========================================================================== */
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
/** 세션 칸 한 겹(열쇠 = 누가거리 0.01 m 글 · `:역할` · `b` 앞머리). */
export interface SessionCells<V> {
get(key: string): V | undefined;
has(key: string): boolean;
set(key: string, value: V): void;
delete(key: string): void;
clear(): void;
forEach(fn: (value: V, key: string) => void): void;
keys(): string[];
/** 지금 칸 값(새 Map) — [저장] patch 재료. */
entries(): Map<string, V>;
}
const isObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object";
function cellOf(key: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(readByKey(key) ?? "null");
return isObject(parsed) ? parsed : {};
} catch {
return {}; // 손상된 세션 값 = 빈 칸(정본 · 기본값으로)
}
}
/**
* 세션 칸 창구를 만듦. `accept` = 항목마다 검증 · 정규화(`undefined` = 버림 · 손상값 · 옛 꼴 정리 자리) ·
* `part` = 칸 안 한 겹(연동 칸 `{ detached, grade }`). 빈 맵을 쓰면 칸 지움(272 · 연동은 두 겹 다 빌 때).
*/
export function sessionCells<V>(
sessionKey: () => string | null,
accept: (value: unknown) => V | undefined,
part?: string,
): SessionCells<V> {
const read = (): Map<string, V> => {
const values = new Map<string, V>();
const key = sessionKey();
if (!key) return values;
const whole = cellOf(key);
const cells = part ? whole[part] : whole;
if (!isObject(cells)) return values;
for (const [entry, value] of Object.entries(cells)) {
const taken = accept(value);
if (taken !== undefined) values.set(entry, taken);
}
return values;
};
const write = (values: Map<string, V>): void => {
const key = sessionKey();
if (!key) return;
const cells = Object.fromEntries(values);
const whole = part ? { ...cellOf(key), [part]: cells } : cells;
const empty = part
? Object.values(whole).every((v) => !isObject(v) || !Object.keys(v).length)
: !values.size;
try {
writeByKey(key, empty ? null : JSON.stringify(whole));
} catch {
/* 세션 저장 실패(용량 초과 등) — 그 조작만 안 남음. */
}
};
const edit = (change: (values: Map<string, V>) => void): void => {
const values = read();
change(values);
write(values);
};
return {
get: (key) => read().get(key),
has: (key) => read().has(key),
set: (key, value) => edit((values) => values.set(key, value)),
delete: (key) => {
const values = read();
if (values.delete(key)) write(values);
},
clear: () => write(new Map()),
forEach: (fn) => read().forEach(fn),
keys: () => [...read().keys()],
entries: read,
};
}
+5 -6
View File
@@ -330,11 +330,10 @@ export function routeCarryInputs(projectId: string, fromRouteId: number): Record
}
/**
* 측점이 움직임(갈아끼움 · 옛 0.01 m 글 → 새 글) — 조정창 맵이 안 선 누가거리 칸(B05 만 연 페이지 · 새로고침 ·
* 세월 · BOX · 연동)을 세션에서 바로 새 자리로(415-3). 칸 = `ROUTE_CARRY_STATES`(측점 열쇠 칸 빼고) · 조정창
* 맵이 이번에 옮긴 칸(`owned`)은 그대로.
* 측점이 움직임(갈아끼움 · 옛 0.01 m 글 → 새 글) — 누가거리 칸(조정창 값 · 메모 · 상세 표 등)을 세션에서 바로
* 새 자리로(415-3 · 조정창 값 주인 = 세션 칸 · 472-5 5-D). 칸 = `ROUTE_CARRY_STATES`(측점 열쇠 칸 빼고).
*/
function moveIdleStates(moves: ReadonlyMap<string, string>, owned: ReadonlySet<string>): void {
function moveIdleStates(moves: ReadonlyMap<string, string>): void {
if (!moves.size || !current) return;
const { projectId, routeId } = current;
const move = (cells: Record<string, unknown>) =>
@@ -344,7 +343,7 @@ function moveIdleStates(moves: ReadonlyMap<string, string>, owned: ReadonlySet<s
if ((KEYED_STATES as readonly string[]).includes(state)) continue;
const name = state as StateName;
const key = stateKey(name, projectId, routeId);
if (!key || owned.has(key)) continue;
if (!key) continue;
const raw = readState<Record<string, unknown>>(name, projectId, routeId);
if (!raw || typeof raw !== "object") continue;
// 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹
@@ -363,4 +362,4 @@ function moveIdleStates(moves: ReadonlyMap<string, string>, owned: ReadonlySet<s
if (resets) writeState("kindreset", move(resets), projectId);
}
afterAnySwap((_detail, moves, owned) => moveIdleStates(moves, owned));
afterAnySwap((_detail, moves) => moveIdleStates(moves));
+5 -26
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* B06_Section_Swap_Bus.ts
* 상세 갈아끼움 알림판(PLAN 346-2) — 측점 수가 바뀌어 `Section_Store` 가 상세 객체를 갈아끼우면 여기로 알림.
* 가져오는 것 없음 — 세션 조작값 맵(`_UI_Page_Link_Session`) · 암 경계 저장소(`_UI_Page_Persist`)가 모듈을 읽을 때
* 가져오는 것 없음 — 측점 열쇠(`Station_Keys`) · 암 경계 저장소(`_UI_Page_Persist`)가 모듈을 읽을 때
* 붙어도 `Section_Store` 와 돌고 도는 가져오기가 안 생김.
* ========================================================================== */
@@ -21,37 +21,16 @@ export function onAnySwap(fn: DetailListener): () => void {
return () => anyListeners.delete(fn);
}
/** 알림 한 번 동안 조정창 맵이 옮긴 세션 칸(열쇠 글) — 뒤 차례(`afterAnySwap`)는 그 칸을 다시 안 옮김(415-3). */
const moved = new Set<string>();
const afterListeners = new Set<
(
detail: SectionDetailResponse,
moves: ReadonlyMap<string, string>,
owned: ReadonlySet<string>,
) => void
>();
const afterListeners = new Set<DetailListener>();
/** 조정창 맵이 세션 칸을 옮겼다고 적음(`_UI_Page_Link_Session`). */
export function markMovedStates(keys: Iterable<string>): void {
for (const key of keys) moved.add(key);
}
/** 갈아끼움 알림이 다 돈 뒤 부름 — `owned` = 그 사이 조정창 맵이 옮긴 세션 칸. */
export function afterAnySwap(
fn: (
detail: SectionDetailResponse,
moves: ReadonlyMap<string, string>,
owned: ReadonlySet<string>,
) => void,
): () => void {
/** 갈아끼움 알림이 다 돈 뒤 부름(세션 누가거리 칸 옮김 · `Station_Keys`). */
export function afterAnySwap(fn: DetailListener): () => void {
afterListeners.add(fn);
return () => afterListeners.delete(fn);
}
/** 갈아끼운 쪽(`Section_Store`)이 부름. */
export function emitSwap(detail: SectionDetailResponse, moves: ReadonlyMap<string, string>): void {
moved.clear();
anyListeners.forEach((fn) => fn(detail, moves));
const owned = new Set(moved);
afterListeners.forEach((fn) => fn(detail, moves, owned));
afterListeners.forEach((fn) => fn(detail, moves));
}
@@ -3,7 +3,7 @@
* 세월교 측벽·BOX암거 구체 조작값 제어 — 측점 제어기
* (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
*
* 데이터 흐름은 집수정과 같다: 세션 사본 + 캐시 `design.ford_adjust`에 함께 실어
* 데이터 흐름은 집수정과 같다: 세션 칸(값 주인 · 472-5 5-D) + 캐시 `design.ford_adjust`에 함께 실어
* 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량·날개벽은 폼이 관 초안(가게 pipes)
* 한 벌로 냄 — 여기는 캐시만 고침(444 · 조정 예약 culvertopt 에 또 담으면 옮긴 관 옛 자리에 남음).
* ========================================================================== */
@@ -21,7 +21,7 @@ import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box";
import type { BoxAdjust, BoxPick, BoxSideAdjust } from "./B06_Section_UI_Cross_Box";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { sessionCells } from "./B06_Section_Session_Cells";
/** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */
type WingPatch = Partial<{
@@ -116,38 +116,18 @@ export interface FordControls {
control: FordControl;
/** 확정 payload용 — 측점별 조작값. */
byChainage: () => Map<number, FordAdjust>;
/** 노선이 바뀔 때 세션 값을 다시 읽는다. */
load: () => void;
}
export function createFordControls(deps: FordControlDeps): FordControls {
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
/* ── 세월교 측벽 조작(2026-08-25 사용자) ───────────────────────────────
* 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 사본 + 캐시 design에 함께
* 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 칸 + 캐시 design에 함께
* 실어 3D가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본이 원천이라
* 배수관 구간값과 같은 저장기(`pipe_points` 되쓰기)로 보낸다. */
const fordAdjustments = new Map<string, FordAdjust>();
const fordAdjustments = sessionCells(deps.sessionKey, (value) =>
fordAdjustOf(value as Partial<FordAdjust> | null),
);
const fordSelections = new Map<string, FordPick | null>();
const fordSessionKey = (): string | null => deps.sessionKey();
function loadFordAdjustments(): void {
fordAdjustments.clear();
const key = fordSessionKey();
if (!key) return;
try {
const parsed = JSON.parse(readByKey(key) ?? "{}") as Record<string, FordAdjust>;
Object.entries(parsed).forEach(([chainage, value]) =>
fordAdjustments.set(chainage, fordAdjustOf(value)),
);
} catch {
/* 손상된 세션 값은 기본값으로 대체. */
}
}
function persistFordAdjustments(): void {
const key = fordSessionKey();
if (key) writeByKey(key, JSON.stringify(Object.fromEntries(fordAdjustments)));
}
// 옛 저장(측벽마다 높이 · 대각)은 읽을 때 바닥판 내림 하나로 옮김(139-3 · `fordDropOf`)
const fordAdjustAt = (chainageM: number): FordAdjust => {
@@ -165,7 +145,6 @@ export function createFordControls(deps: FordControlDeps): FordControls {
const writeFord = (chainageM: number, next: FordAdjust): void => {
fordAdjustments.set(chainageM.toFixed(2), next);
persistFordAdjustments();
patchCachedDesign(chainageM, { ford_adjust: next });
deps.refreshCard(chainageM);
};
@@ -234,7 +213,6 @@ export function createFordControls(deps: FordControlDeps): FordControls {
});
return result;
},
load: loadFordAdjustments,
};
}
@@ -313,40 +291,24 @@ export function applyBoxFormOptions(
}
/**
* BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 사본 + 캐시 `design.box_adjust`).
* BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 칸 + 캐시 `design.box_adjust`).
* 좌·우 끝을 따로 잡으며 길이는 바깥으로만, 표고는 양방향으로 움직인다.
*/
export function createBoxControls(deps: FordControlDeps): {
control: BoxControl;
byChainage: () => Map<number, BoxAdjust>;
load: () => void;
} {
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
const adjustments = new Map<string, BoxAdjust>();
const adjustments = sessionCells(deps.sessionKey, (value): BoxAdjust | undefined => {
if (!value || typeof value !== "object") return undefined;
const sides = value as Partial<BoxAdjust>;
return {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right },
};
});
const selections = new Map<string, BoxPick | null>();
function load(): void {
adjustments.clear();
const key = deps.sessionKey();
if (!key) return;
try {
const parsed = JSON.parse(readByKey(key) ?? "{}") as Record<string, BoxAdjust>;
Object.entries(parsed).forEach(([chainage, value]) =>
adjustments.set(chainage, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.right },
}),
);
} catch {
/* 손상된 세션 값은 기본값으로 대체. */
}
}
function persist(): void {
const key = deps.sessionKey();
if (key) writeByKey(key, JSON.stringify(Object.fromEntries(adjustments)));
}
const adjustAt = (chainageM: number): BoxAdjust => {
const stored = sectionAt(chainageM)?.design?.box_adjust;
return (
@@ -359,7 +321,6 @@ export function createBoxControls(deps: FordControlDeps): {
const write = (chainageM: number, next: BoxAdjust): void => {
adjustments.set(chainageM.toFixed(2), next);
persist();
patchCachedDesign(chainageM, { box_adjust: next });
deps.refreshCard(chainageM);
};
@@ -430,7 +391,6 @@ export function createBoxControls(deps: FordControlDeps): {
});
return result;
},
load,
};
}
+61 -126
View File
@@ -1,15 +1,11 @@
/* =============================================================================
* B06_Section_UI_Page_Link_Session.ts
* 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기
* (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
* 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다.
*
* 측점별 조작값을 담는 **한 겹 맵**도 여기서 낸다(`createSessionMap`, 2026-09-02) —
* 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값이 같은 모양(읽기 → 항목
* 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다.
* 연동 기슭막이 거들기 — 측점 제어기(`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리(2026-08-25).
* 저장 묶음 접기(세션 칸 → 측점별 patch 재료) · 연동 풀 때 굳히기 · 좌측 관 보호공 [연동] · [경사] 다리.
* 조작값 칸 창구 = `B06_Section_Session_Cells`(메모리 맵 없음 · 472-5 5-D).
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
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 {
@@ -23,137 +19,76 @@ import {
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;
}
/* ── 확정 · 임시저장 payload — 세션 키(누가거리 · `:역할` · `b` 앞머리)를 측점별로 되접음 ── */
type Each<V> = { forEach(fn: (value: V, key: string) => void): 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);
/** 측점별 기슭막이 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);
});
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)
return result;
}
// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 조정창 값을 옮긴 측점 자리로
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 {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
},
/** 측점별 연동 해제 · 종단경사 반영 플래그. */
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 });
};
alive.set(map as SessionMap<unknown>, sessionKey);
return map;
detachedFlags.forEach((detached, key) => put(key, { detached }));
gradeFlags.forEach((followGrade, key) => put(key, { followGrade }));
return result;
}
export interface LinkFlagMaps {
detached: Map<string, boolean>;
followGrade: Map<string, boolean>;
load: () => void;
persist: () => void;
/** 측점별 다단 단 수 — 세션에 있는 쪽만 · 없는 쪽은 바탕(캐시 설계) 값 그대로(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 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)
);
},
};
/** 측점별 다단 단별 구간값. */
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;
}
/**
@@ -3,7 +3,7 @@
* 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값.
*
* `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02).
* 세션 맵(`extraSpans`)과 캐시(design)를 함께 만지는 자리라 한 덩어리로 옮겼다.
* 세션 칸(`extraSpans` · 값 주인 · 472-5 5-D)과 캐시(design)를 함께 만지는 자리라 한 덩어리로 옮겼다.
* 기준벽 · 집수정 구간값은 폼 칸(관 초안)이 정본 — 여기는 읽기만(433-19 · 444).
* ========================================================================== */
@@ -14,6 +14,7 @@ import {
type StructureSpanControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import * as CulvertConst from "./logic/B06_Section_UI_Cross_Culvert_Const";
import type { SessionCells } from "./B06_Section_Session_Cells";
/** 구간값 제어가 페이지·상위 제어기에서 받아 쓰는 창구. */
export interface SpanControlDeps {
@@ -21,9 +22,8 @@ export interface SpanControlDeps {
refreshCard: (chainageM: number) => void;
/** 연동 구조물의 소유 측점 — 값은 소유 측점 하나에만 담는다. */
ownerOf: (section: CrossSection) => CrossSection | null;
/** 세션 맵(누가거리:벽키 → 구간값)과 저장. */
extraSpans: Map<string, SpanValues>;
persistExtraSpans: () => void;
/** 세션 칸(누가거리:벽키 → 구간값) — 읽고 쓰면 곧 칸. */
extraSpans: SessionCells<SpanValues>;
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
}
@@ -34,7 +34,7 @@ export function createSpanControl(deps: SpanControlDeps): {
tierSpansOf: (owner: CrossSection) => Record<string, StoredWallSpan>;
spanKeyOf: (chainageM: number, wall: string) => string;
} {
const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf } = deps;
const { extraSpans, patchCachedDesign, ownerOf } = deps;
const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`;
const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => {
@@ -74,7 +74,6 @@ export function createSpanControl(deps: SpanControlDeps): {
const current = extraSpans.get(mapKey) ?? storedTierSpan(owner, key);
const next = CulvertConst.applySpanPatch(current, patch);
extraSpans.set(mapKey, next);
persistExtraSpans();
// 캐시(design)에도 얹는다 — 링크 판정·3D가 순수 함수로 이 값을 읽는다.
patchCachedDesign(owner.chainage_m, { extra_spans: tierSpansOf(owner) });
// 연장이 바뀌면 링크되는 옆 측점이 달라진다 — 옛 연장·새 연장을 합친 구간만.
@@ -27,10 +27,16 @@ import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cr
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
import {
createLinkFlagSession,
createSessionMap,
foldExtraCounts,
foldExtraSpans,
foldLinkFlags,
foldRevetAdjusts,
freezeLinkedWalls,
} from "./B06_Section_UI_Page_Link_Session";
import { sessionCells } from "./B06_Section_Session_Cells";
import { noteAdjustBase } from "./B06_Section_Section_Adjusts";
import { readByKey } from "../A00_Common/b_page_state";
import { onPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
import { createSpanControl } from "./B06_Section_UI_Page_Span_Control";
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
@@ -71,7 +77,7 @@ export interface StationControlDeps {
onWallAdjust?: () => void;
}
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
/** 반폭·기슭막이·유입 구조물 제어 묶음. 값 = 세션 칸(읽을 때마다 · 472-5 5-D) · `load` = 경로가 바뀔 때 고름 풂. */
export interface StationControls {
stationWidth: StationWidthControl;
revetOffset: RevetOffsetControl;
@@ -86,9 +92,10 @@ export interface StationControls {
/** 확정 payload용 — 측점별 세월교·BOX암거 조작값. */
fordAdjustsByChainage: () => Map<number, FordAdjust>;
boxAdjustsByChainage: () => Map<number, BoxAdjust>;
widths: Map<string, number>;
inletStructures: Map<string, InletStructureChoice>;
basinAdjustments: Map<string, BasinAdjust>;
/** 확정 payload용 — 지금 세션 칸 값(새 Map). */
widths: () => Map<string, number>;
inletStructures: () => Map<string, InletStructureChoice>;
basinAdjustments: () => Map<string, BasinAdjust>;
/** 확정 payload용 — 측점별 기슭막이 4축 조작값(역할 → 값 · null = 되돌림). */
revetAdjustsByChainage: () => Map<number, Record<string, WallAdjust | null>>;
/** 확정 payload용 — 측점별 다단 단 수(세션에 있는 쪽만). */
@@ -102,6 +109,8 @@ export interface StationControls {
/** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */
queueCulvertOptions: (chainageM: number, patch: Record<string, number | string>) => void;
load: () => void;
/** ↶ ↷ 가 조정창 칸을 바꿨으면 부름(472-5 5-D · 캐시 · 카드 · 면적 다시) — 돌려준 함수로 끊음. */
onRestore: (fn: () => void) => () => void;
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
}
@@ -117,10 +126,13 @@ export function createStationControls(deps: StationControlDeps): StationControls
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01);
/** design이 있는 측점에만 얹는다 — 없는 측점은 아직 설계 전이라 실을 자리가 없다. */
/** design이 있는 측점에만 얹는다 — 없는 측점은 아직 설계 전이라 실을 자리가 없다. 고치기 전 칸을 떠 둠
* (↶ ↷ 뒤 얹기 = 그 값 + 지금 세션 칸 · 472-5 5-D). */
const patchCachedDesign = (chainageM: number, patch: Partial<CrossDesign>): void => {
const design = sectionAt(chainageM)?.design;
if (design) Object.assign(design, patch);
const section = sectionAt(chainageM);
if (!section?.design) return;
noteAdjustBase(section, Object.keys(patch));
Object.assign(section.design, patch);
};
/* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ──────────────────────
@@ -128,14 +140,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
* cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다.
* 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */
// 0 = 되돌림(빈 전체 폭 · 저장분 개별 폭을 지움 · 445 M2)
const widthSession = createSessionMap<number>(
const stationWidths = sessionCells<number>(
() => deps.sessionKey("crossw"),
(value) =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined,
);
const stationWidths = widthSession.values;
const loadStationWidths = widthSession.load;
const persistStationWidths = widthSession.persist;
const widthKey = (chainageM: number): string => chainageM.toFixed(2);
const clampStationWidth = (value: number): number => Math.max(Math.round(value), 2);
@@ -151,7 +160,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
const width = clampStationWidth(widthM);
const apply = (): void => {
stationWidths.set(widthKey(chainageM), width);
persistStationWidths();
deps.refreshCard(chainageM);
};
// 보유 샘플 안이면 표시만 바꾸면 된다 — 캐시(계산 20m)를 그대로 쓴다.
@@ -171,7 +179,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
// null = 되돌림(저장분 그 벽을 지움 · 다시 열어도 안 살아남 · 445 M2)
const revetSession = createSessionMap<WallAdjust | null>(
const revetShifts = sessionCells<WallAdjust | null>(
() => deps.sessionKey("revetx"),
(value) => {
if (value === null) return null;
@@ -184,18 +192,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
},
);
const revetShifts = revetSession.values;
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). `shape` = 「관」이 물넘이 콘크리트(307). */
let revetSelection: { at: string; key: RevetPick; shape?: boolean } | null = null;
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
function loadRevetShifts(): void {
revetSession.load();
revetSelection = null;
}
const persistRevetShifts = revetSession.persist;
/** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */
const round1 = (value: number): number => Math.round(value * 10) / 10;
const clampMove = (value: number): number => Math.min(Math.max(round1(value), -10), 10);
@@ -205,7 +206,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
Math.abs(value.x) < 1e-9 && value.d == null && value.h == null && value.m == null;
const storeAdjust = (key: string, value: WallAdjust): void => {
revetShifts.set(key, isDefaultAdjust(value) ? null : value);
persistRevetShifts();
// 같은 값을 캐시(design.revet_adjust)에도 실어 3D가 바로 따라오게 한다.
const [chainage, role] = key.split(":");
const at = Number(chainage);
@@ -215,7 +215,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
const bucket = { ...(design.revet_adjust ?? {}) };
if (isDefaultAdjust(value)) delete bucket[role];
else bucket[role] = { ...value };
design.revet_adjust = bucket;
patchCachedDesign(at, { revet_adjust: bucket });
deps.onWallAdjust?.();
};
const axisEq = (a: number | null, b: number | null): boolean =>
@@ -316,7 +316,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
};
/* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ────────────────
* auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */
const basinSession = createSessionMap<BasinAdjust>(
const basinAdjustments = sessionCells<BasinAdjust>(
() => deps.sessionKey("basinadjust"),
(value) => {
if (!value || typeof value !== "object") return undefined;
@@ -327,30 +327,21 @@ export function createStationControls(deps: StationControlDeps): StationControls
return adjust;
},
);
const structSession = createSessionMap<InletStructureChoice>(
const inletStructures = sessionCells<InletStructureChoice>(
() => deps.sessionKey("inletstruct"),
(value) =>
typeof value === "string" && ["auto", "revet", "I", "L", "U"].includes(value)
? (value as InletStructureChoice)
: undefined,
);
const inletStructures = structSession.values;
const basinAdjustments = basinSession.values;
const loadBasinAdjustments = basinSession.load;
const persistBasinAdjustments = basinSession.persist;
const loadInletStructures = structSession.load;
const persistInletStructures = structSession.persist;
/* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ──
* 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */
// 0 = 되돌림(저장분 단 수를 지움 · 445 M2)
const extraSession = createSessionMap<number>(
const extraCounts = sessionCells<number>(
() => deps.sessionKey("extrawall"),
(value) => (Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined),
);
const extraCounts = extraSession.values;
const loadExtraCounts = extraSession.load;
const persistExtraCounts = extraSession.persist;
const pendingEqualize = new Set<string>();
@@ -390,9 +381,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey));
extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`));
}
persistRevetShifts();
persistExtraSpans();
persistExtraCounts();
patchCachedDesign(chainageM, { extra_wall_counts: countsWith(chainageM, side, count) });
refreshWithLinked(chainageM);
deps.onExtraCount?.();
@@ -408,9 +396,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey));
extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`));
}
persistRevetShifts();
persistExtraSpans();
persistExtraCounts();
// 캐시 patch 도 세운 만큼으로 — 세션 키를 지우면 `countFor` 가 옛 patch 수로 돌아감(54-1)
patchCachedDesign(chainageM, {
extra_wall_counts: countsWith(chainageM, side, Math.max(built, 0)),
@@ -431,7 +416,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
set: (chainageM, value) => {
inletStructures.set(chainageM.toFixed(2), value);
persistInletStructures();
patchCachedDesign(chainageM, { inlet_structure: value });
refreshWithLinked(chainageM);
},
@@ -445,13 +429,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
lateralM: Math.max(0, clampMove(next.lateralM)),
slopeM: Math.max(0, clampMove(next.slopeM)),
});
persistBasinAdjustments();
patchCachedDesign(chainageM, { basin_adjust: basinAdjustments.get(key) });
refreshWithLinked(chainageM);
},
resetAdjust: (chainageM) => {
basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST });
persistBasinAdjustments();
patchCachedDesign(chainageM, { basin_adjust: { ...DEFAULT_BASIN_ADJUST } });
refreshWithLinked(chainageM);
},
@@ -467,12 +449,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
() => deps.sessionKey("culvertopt"),
deps.onSaveError,
);
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
const linkDetached = linkSession.detached;
const followGrades = linkSession.followGrade;
// 연동 칸 = { detached: {누가거리: …}, grade: {…} } 두 겹
const flag = (value: unknown): boolean => !!value;
const linkDetached = sessionCells(() => deps.sessionKey("revetlink"), flag, "detached");
const followGrades = sessionCells(() => deps.sessionKey("revetlink"), flag, "grade");
const chainageKey = (chainageM: number): string => chainageM.toFixed(2);
const loadLinkFlags = linkSession.load;
const persistLinkFlags = linkSession.persist;
/** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */
const ownerOf = (section: CrossSection): CrossSection | null => {
@@ -485,7 +466,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
* 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져
* 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다
* (4축 조작값과 같은 경로). 키는 `누가거리:벽키`(예 `234.10:extra0`). */
const extraSpanSession = createSessionMap<SpanValues>(
const extraSpans = sessionCells<SpanValues>(
() => deps.sessionKey("extraspan"),
(value) => {
const span = value as SpanValues | null;
@@ -494,16 +475,12 @@ export function createStationControls(deps: StationControlDeps): StationControls
: undefined;
},
);
const extraSpans = extraSpanSession.values;
const loadExtraSpans = extraSpanSession.load;
const persistExtraSpans = extraSpanSession.persist;
const spanControl = createSpanControl({
detail: () => deps.detail(),
refreshCard: (chainageM) => deps.refreshCard(chainageM),
ownerOf,
extraSpans,
persistExtraSpans,
patchCachedDesign,
});
const structureSpanControl = spanControl.control;
@@ -528,10 +505,9 @@ export function createStationControls(deps: StationControlDeps): StationControls
for (const role of Object.keys(design?.revet_adjust ?? {})) roles.push(`${prefix}${role}`);
// 저장분 굳힌 값도 되돌림 표시(null · 445 M2)
for (const key of roles) if (key.slice(prefix.length) !== "own") revetShifts.set(key, null);
persistRevetShifts();
if (!design?.revet_adjust) return;
const own = design.revet_adjust.own;
design.revet_adjust = own ? { own } : {};
patchCachedDesign(chainageM, { revet_adjust: own ? { own } : {} });
};
const revetLinkControl: RevetLinkControl = {
@@ -555,7 +531,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
revetLink: revetLinkControl,
});
linkDetached.set(chainageKey(chainageM), !linked);
persistLinkFlags();
patchCachedDesign(chainageM, { revet_link_detached: !linked });
deps.refreshCard(chainageM);
},
@@ -578,7 +553,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
}
deps.refreshCard(other.chainage_m);
}
persistLinkFlags();
},
};
@@ -610,78 +584,47 @@ export function createStationControls(deps: StationControlDeps): StationControls
box: bodyControls.box.control,
fordAdjustsByChainage: bodyControls.ford.byChainage,
boxAdjustsByChainage: bodyControls.box.byChainage,
widths: stationWidths,
inletStructures,
basinAdjustments,
// 확정·임시저장 payload용 — 세션 키(누가거리:역할)를 측점별로 되접는다.
revetAdjustsByChainage: () => {
const result = new Map<number, Record<string, WallAdjust | null>>();
revetShifts.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;
},
// 확정 payload용 — 측점별 연동 해제·종단경사 반영 플래그.
linkFlagsByChainage: () => {
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 });
};
linkDetached.forEach((detached, key) => put(key, { detached }));
followGrades.forEach((followGrade, key) => put(key, { followGrade }));
return result;
},
extraCountsByChainage: () => {
// 세션에 있는 쪽만 — 없는 쪽은 바탕(캐시 설계) 값 그대로(445 H3 · `mergeCrossPatches`)
const result = new Map<number, Partial<{ outlet: number; basin: number }>>();
extraCounts.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;
},
extraSpansByChainage: () => {
const result = new Map<number, Record<string, StoredWallSpan>>();
extraSpans.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;
},
widths: stationWidths.entries,
inletStructures: inletStructures.entries,
basinAdjustments: basinAdjustments.entries,
// 확정·임시저장 payload용 — 세션 키를 측점별로 되접음(`_Link_Session`)
revetAdjustsByChainage: () => foldRevetAdjusts(revetShifts),
linkFlagsByChainage: () => foldLinkFlags(linkDetached, followGrades),
extraCountsByChainage: () => foldExtraCounts(extraCounts),
extraSpansByChainage: () => foldExtraSpans(extraSpans),
flushCulvertOptions: () => culvertOptions.flush(),
queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch),
load: () => {
loadStationWidths();
loadRevetShifts();
loadInletStructures();
loadBasinAdjustments();
loadExtraCounts();
loadExtraSpans();
loadLinkFlags();
bodyControls.ford.load();
bodyControls.box.load();
load: () => void (revetSelection = null),
onRestore: (fn) => {
const kinds = [
"crossw",
"revetx",
"inletstruct",
"basinadjust",
"extrawall",
"extraspan",
"revetlink",
"fordadjust",
"boxadjust",
] as const;
const print = (): string =>
JSON.stringify(
kinds.map((kind) => {
const key = deps.sessionKey(kind);
return key ? readByKey(key) : null;
}),
);
let seen = print(); // 마지막 걸음(찍음 · 되돌림) 때 칸 — 되돌림이 이 칸을 바꿨나만 가름
return onPageHistory({
restore: () => void (print() !== seen && fn()),
change: () => void (seen = print()),
});
},
applyGlobalWidth: (requested, chainages) => {
stationWidths.clear();
// 빈 전체 폭 = 0(되돌림 — 저장분 개별 폭도 지움 · 445 M2)
const width = requested === undefined ? 0 : clampStationWidth(requested);
for (const chainageM of chainages) stationWidths.set(widthKey(chainageM), width);
persistStationWidths();
},
};
}
+2 -2
View File
@@ -30,7 +30,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
```
사용자 조작(끌기 · 단추 · 칸)
▼
① 화면 메모리 — 편집기 · 배수판 · 보기 구조물 · B06 조정 맵 · 시설 줄
① 화면 메모리 — 편집기 · 배수판 · 보기 구조물 · 시설 줄
② 상세 가게 — Section_Store cache(페이지 수명) · 정본 사본 canon(세션)
③ 설계 가게 design_store — stations · profile · footprint 는 메모리 우선(멈춘 뒤 1 s 세션)
④ 세션 sessionStorage — aislo:{통}:{이름}:{프로젝트}[:{노선}] · draft · result · pref(localStorage)
@@ -47,7 +47,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 층 | 위치(파일:줄) | 역할 · 비우는 때 |
|---|---|---|
| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `B06_Section_UI_Page_Link_Session.ts:47` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 |
| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 |
| ② 상세 가게 | `B06_Section_Section_Store.ts:68` cache · `:69` pending · `:117` loadSectionDetail · `:431` peekSectionDetail | 상세 횡단(살아 있는 객체) · 떠남 `forgetSectionMemory`(`:424`) · 정본 바뀜 `invalidateSectionDetail`(`:403`) |
| ② 정본 사본 · 상세 칸 | `B06_Section_Section_Canon.ts:36` keepCanon · `:59` sessionDetail · `:72` savedDesigns · `:84` forgetCanon · `:107-143` 칸 셋(저장 정본 · 초안 전 hold · 노선 초안 · 472-5) | 서버 정본 설계 · 자리 · 표본 · invalidate 때 비움 · 칸 셋 = 떠남 · invalidate 때 비움 |
| ③ 설계 가게 | `design_store.ts:226` useDesignStore · `:151` 메모리 칸 · `:160` 1 s | 읽고 쓰는 창구 · 쓰면 듣는 쪽 부름 |
@@ -21,7 +21,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 소단 구간(구조물) | 구조물 → 시설 줄 `syncBerm` 이 세션 `berm`(같으면 안 씀 · 쓰면 cross 한 번 · B05 만 열어도) | 횡단 다시 셈 · 사본 없으면 저장분 `design.berm` | — | 서버가 `structures.json` 에서(`load_berm_spans`) | 6 | `design_facility_lane.ts:151-166` · `B06_Section_Cross_Refresh.ts:362-368 · 521-522` · `B06_Section_Engine_Structures_Wall.py:124` |
| 소단 깊이 ▲▼ | 세션 `bermdepth` | 횡단 다시 셈 | — | 횡단 패치 | 5 | `B06_Section_UI_Cross_Design_Pick.ts:408` · `B06_Section_UI_Page_Design_Sync.ts:78-83` · `B06_Section_Cross_Refresh.ts:513-519` |
| 암선 · 암 경사 | 메모리 맵 + 세션 `rockb` · `cutslope` | 횡단 다시 셈(암 측점 2단 무릎 · 면적) | — | 횡단 패치 · 파이썬 설계선 | 5 | `B06_Section_UI_Cross_Line_Cell.ts:161 · 166` · `B06_Section_UI_Cross_CutSlope.ts:94 · 114` · `B06_Section_Cross_Refresh.ts:229-243 · 306` |
| 조정창(흙막이 · 집수정 · 유입 · 경간 · 세월교 · BOX · 연동) | 세션 맵(`revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust`) · 빈 맵 = 칸 지움 · 관 옵션 = `culvertopt` | 카드 다시 · 구조물 면적 · 누가토량 | — | 횡단 패치 · Node 구조물 면적 · 관 옵션 = 관 옵션 차례 `pipe_points` PUT | 6 | `B06_Section_UI_Page_Station_Controls.ts:121-220 · 294-582` · `B06_Section_UI_Page_Link_Session.ts:97-105 · 143-155` · `B06_Section_Section_Adjusts.ts:42-103` · `logic/B06_Section_UI_Page_Patches.ts:41-136` |
| 조정창(흙막이 · 집수정 · 유입 · 경간 · 세월교 · BOX · 연동) | 세션 칸(`revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` · 값 주인 · 메모리 사본 없음 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells`) · 빈 칸 = 칸 지움 · 관 옵션 = `culvertopt` | 카드 다시 · 구조물 면적 · 누가토량 · ↶ ↷ 가 칸을 바꾸면 상세 얹기(고치기 전 칸 + 지금 칸) · 카드 · 면적 다시 | — | 횡단 패치 · Node 구조물 면적 · 관 옵션 = 관 옵션 차례 `pipe_points` PUT | 6 | `B06_Section_UI_Page_Station_Controls.ts` · `B06_Section_Session_Cells.ts` · `B06_Section_Section_Adjusts.ts` noteAdjustBase · withDraftAdjusts · `design_view_cross.ts` onRestore · `logic/B06_Section_UI_Page_Patches.ts:41-136` |
| 반폭 ◀▶ | 측점 = 세션 `crossw` · 전역 = `cross-display` | 표시만 · 횡단 전체 강제 다시 셈(헛일) | — | 측점 반폭만 패치 · `cross-display` 안 감 | 3 | `B06_Section_UI_Cross_View.ts:664-670` · `design_view_cross.ts:251-263 · 448-457` · `B06_Section_Display_Width.ts:20-23` |
| 표준횡단 · [전체 반영] | 세션 `std-cross`([폐기]에도 안 지움) | 전 측점 다시 셈(지문에 표준 포함) | — | `standard_cross_section` 으로 · 서버 설계선 다시 | 3 | `B06_Section_UI_Standard_Panel.ts:303-325 · 450-458` · `B06_Section_Api_Fetch.ts:101-111` · `B06_Section_Router_Confirm.py:157-162` |
+2 -2
View File
@@ -21,7 +21,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 다시 셈 입력 물러섬 = 세션 고름 → 저장 정본 설계 → config 기본값 · 고름 단추는 메모리 상세 설계를 안 덮음 | `B06_Section_Cross_Refresh.ts:402-408` · `B06_Section_UI_Page_Design_Sync.ts:59-104` | `test_272_choice_undo` |
| 소단 사본 쓰는 자리 = 시설 줄 하나(B05 만 열어도) · 같으면 안 씀 · 사본 없으면 저장분 `design.berm` | `design_facility_lane.ts:151-166` · `B06_Section_Cross_Refresh.ts:362-368` | `test_272_berm_source` |
| 두 보기가 보는 파생값은 페이지 한 벌이 숨은 동안도 셈 · 보기는 그리기만 · 접힘 = 그리기만 건너뜀 | `design_facility_lane.ts:1-12` · `B05_Profile_UI_Profile_Render.ts:211-226` | `test_272_hidden_summary` · `test_271_facility_lane` |
| 되돌리기 걸음 = 사용자 조작으로 값이 실제로 바뀐 때만 · 빈 칸(「""」「{}」「null」) = 칸 없음 · 빈 맵 persist = 칸 지움 | `B05_Profile_UI_Profile_History.ts:50 · 235-236` · `B06_Section_UI_Page_Link_Session.ts:97-105 · 153` | `test_272_history_empty_cells` |
| 되돌리기 걸음 = 사용자 조작으로 값이 실제로 바뀐 때만 · 빈 칸(「""」「{}」「null」) = 칸 없음 · 빈 칸 쓰기 = 칸 지움 | `B05_Profile_UI_Profile_History.ts:50 · 235-236` · `B06_Section_Session_Cells.ts` | `test_272_history_empty_cells` |
| 시설 줄 측점 = 종류 표 · 구조물 · 관 알림이 다 온 뒤만 심음 · 그 전 종단도 = 저장 측점 · 저장 기준 그대로(모름 ≠ 빈 목록) | `design_facility_lane.ts` known · `design_profile.ts` planted · `design_profile_base.ts` listedBase · `design_view_profile_sections.ts` stationsReady · `B05_Profile_UI_Profile_Render.ts` deriveProfile | `test_473_lane_ready` |
| 짝 없는 새 측점(구조물 · 추가 측점 · 새 관) = 계획선 꼭짓점 안 세움 · 짝 있는 옮긴 측점 = 꼭짓점 따라감 | `common_util_structure_station.ts:356` · `B05_Profile_Router_Confirm.py:306-400` | `test_427_1_follow_seat` · `test_459_route_seat_mirror` |
| 구조물 측점 다시 뜸([저장] 상단측 차례 · 측점 맞춤) = 같은 자리 옛 줄의 상단측 출처(`user` 면 값도) · 포장 제안 표기 들고 감 · 새 자리 포장 표기 = 지금 계획선 | `B05_Profile_Router_Confirm.py:426-465` | `test_472_station_user_fields` |
@@ -53,7 +53,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
| `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 |
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 |
| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정 메모리 맵 · B05 상단측 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore | 관 · 구조물 = `test_472_5_shop_owner` · 나머지 추정 |
| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · B05 상단측 맵 · 암선 · 암 경사 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore | 관 · 구조물 = `test_472_5_shop_owner` · 조정창 = `test_472_5d_adjust_owner` · 나머지 추정 |
| 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 |
| `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 |
| 「[]」 — 되돌리기 = 값 · 떠남 확인 = 빈 칸 | `B05_Profile_UI_Profile_History.ts:50` · `design_leave.ts:41-42` | 코드 확인 |
@@ -24,7 +24,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common
| `uphill` | `:131` | 상단측 | uphill(노선 바뀌면 cross 뒤) |
| `crossdesign` · `shoulderext` · `bermdepth` | `:196` · `:198` · `:200` | 카드 단추 · 설계선 쉬프트 · 소단 깊이 | cross |
| `rockb` · `cutslope` · `crossw` | `:158` · `:162` · `:184` | 암선 · 암 경사 · 측점 반폭 | cross |
| `revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` | `:185-192` | 조정창 맵(빈 맵 = 칸 지움) | cross |
| `revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` | `:185-192` | 조정창 칸(값 주인 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells` · 메모리 사본 없음 · 빈 칸 = 칸 지움) | cross |
| `structure-notes` · `structure-note-cads` · `detail-table` | `:121` · `:123` · `:125` | [상세] 모달 | cross |
| `std-cross` | `:175` | 표준횡단 패널([폐기] · [저장]에도 안 지움 · [초기화]만) | cross(`standard_cross_section`) |
| `berm` | `:166` | 시설 줄 `syncBerm` 만(구조물 파생) | 안 감 — 서버가 `structures.json` 에서 |
+1 -1
View File
@@ -20,7 +20,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 계획고 편집(▲▼ · 직선화 · 자유 · 쉬프트 · 시종점) | 종단 파일 `profile_alignment.edits` | ★ 가게 `alignmentDraft`(세션 `alignment-draft`) · 가게 `alignmentSaved`(서버 저장분 · 메모리 · `B05_Profile_UI_Profile_Edit.ts:127 · 235`)(노선 · 초안 노선 0) · 묶음 입력 · 가게 `profile` → `design-profile` · 상세 `longitudinal` | TS `logic/B05_Profile_UI_Profile_Alignment.ts:401` ↔ 파이썬 `B05_Profile_Engine_Grade_Alignment.py:494` · `test_426_profile_straight` · `test_432_1_curve_law` | 커밋마다 세션 · [저장] = 서버 다시 셈 |
| 종단 R | 같은 `edits.curve_radii` | 위와 같음 · 칸은 L · 저장은 R | TS `logic/B05_Profile_Profile_CurveLaw.ts` ↔ `B05_Profile_Engine_Grade_CurveLaw.py` · `test_432_1_curve_law` | 위와 같음 · R = L/Δ 로 적어 Δ 바뀌면 L 흐름 |
| 계획선 꼭짓점(시설 측점) | 종단 파일 base PVI | 묶음 planline · 종단도 | TS `common_util_structure_station.ts:356` followMovedNodes ↔ 파이썬 `B05_Profile_Router_Confirm.py:306` `_follow_moved_stations` · `:220` `_seat_born_nodes` · `test_427_1_follow_seat` · `test_459_route_seat_mirror` | 짝 있는 옮긴 측점 = 꼭짓점 따라감 · 짝 없는 새 측점 = 안 세움 |
| 횡단 입력(카드 · 조정) | DB `cross_sections.design` 패치 칸 | 세션 칸(`crossdesign` · 조정 맵 등) · B06 조정 메모리 맵(`B06_Section_UI_Page_Link_Session.ts:47`) | 입력 도구(셈 없음) | 조작마다 세션 · [저장] 횡단 패치 |
| 횡단 입력(카드 · 조정) | DB `cross_sections.design` 패치 칸 | ★ 세션 칸(`crossdesign` · 조정창 칸 등 · 조정 칸 창구 = b_page_state 열쇠 `B06_Section_Session_Cells` · 메모리 사본 없음 · 472-5 5-D) · cache 설계 칸(조작 즉시 얹음 · 얹기 = 고치기 전 칸 + 지금 세션 칸) | 입력 도구(셈 없음) | 조작마다 세션 · [저장] 횡단 패치 |
| 횡단 설계 결과(설계선) | DB `cross_sections.design` | ★ 상세 가게 — cache(살아 있는 객체 · 제자리 고침) · 칸 셋(`B06_Section_Section_Canon.ts:93-143` 저장 정본 상세 · 초안 전 hold 상세 · 노선 초안 상세 = `pipelineDetail` · 472-5) · 세션 `section-detail`(서버 사본만) · canon 설계 사본 · 묶음 cross — 계산 묶음 페이지는 지문(노선 · 표본)만 | TS `common_util_cross_design.ts:267` ↔ 파이썬 `B06_Section_Engine_Design.py:195` · `test_b06_cross_design_mirror` | 다시 셈마다 cache 제자리 · 입력 물러섬 = 저장 정본(`B06_Section_Cross_Refresh.ts:402-408`) · 페이지 수명 · [저장]·[확정]·[초기화] 뒤 비움 |
| 측점 면적 | DB 면적 — 표준 측점 = 파이썬 · 구조물 측점 = Node | cache 면적 · 묶음 cross | TS `common_util_cross_design_areas.ts` ↔ 파이썬 `B06_Section_Engine_Areas.py` · 구조물 = `B06_Section_Structure_Layouts.ts`(Node 한 벌) | [저장] 때 서버가 브라우저 면적 버리고 다시 셈 |
| 누가토량 | 종단 파일 `mass_haul` | 그릴 때 셈 · 배지 · `_derived/haul` · `B05_Profile_Api_HaulPlan.ts:28` kept | TS `common_util_mass_haul.ts:426` 한 벌(브라우저 · Node) | 그릴 때(접혀도 셈) · [저장] |
+1 -4
View File
@@ -1,9 +1,6 @@
/* 272 시험 묶음 진입점(468 · 466-C) — 사진첩 빈 칸 · 세션 맵 빈 쓰기 · 숨은 종단도 파생값 · 배지 자릿수(진짜 모듈 · vite ssr). */
export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History";
export {
createSessionMap,
moveSessionMaps,
} from "../../B06_Section/B06_Section_UI_Page_Link_Session";
export { sessionCells } from "../../B06_Section/B06_Section_Session_Cells";
export { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
export { createRouteMassHaulPanel } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
export { createMassHaulBadge } from "../../common_util/common_util_mass_haul_badge";
+23 -20
View File
@@ -101,38 +101,41 @@ const touch = () => win.dispatchEvent(new Event("keydown"));
localStorage.setItem("frd_current_project_id", "p1");
}
/* ── H4 · 세션 맵 — 빈 맵 쓰기 = 칸 지움 · 값 있으면 씀 · 빈 맵 이동은 칸을 안 만듦 ── */
/* ── H4 · 세션 칸 창구(472-5 5-D · 메모리 맵 없음) — 빈 쓰기 = 칸 지움 · 값 있으면 씀 · 연동 두 겹 ── */
{
const width = m.createSessionMap(
const width = m.sessionCells(
() => key("crossw"),
(v) => (typeof v === "number" ? v : undefined),
);
width.persist();
width.clear();
const emptyWrite = S.getItem(key("crossw"));
width.values.set("100.00", 6);
width.persist();
width.set("100.00", 6);
const filledWrite = S.getItem(key("crossw"));
width.values.clear();
width.persist();
width.delete("100.00");
const clearedWrite = S.getItem(key("crossw"));
const others = SIX.filter((n) => n !== "crossw").map((n) =>
m.createSessionMap(
() => key(n),
(v) => v,
),
const detached = m.sessionCells(
() => key("revetlink"),
(v) => !!v,
"detached",
);
m.moveSessionMaps(new Map([["100.00", "120.00"]]));
const moved = SIX.map((n) => S.getItem(key(n)));
// 값 있는 맵 이동 = 새 자리로 옮겨 씀
S.setItem(key("extrawall"), JSON.stringify({ "100.00": 2 }));
m.moveSessionMaps(new Map([["100.00", "120.00"]]));
const grade = m.sessionCells(
() => key("revetlink"),
(v) => !!v,
"grade",
);
detached.set("100.00", true);
grade.set("100.00", false);
const linkBoth = S.getItem(key("revetlink"));
detached.clear();
const linkGrade = S.getItem(key("revetlink"));
grade.clear();
out.h4 = {
emptyWrite,
filledWrite,
clearedWrite,
moved,
movedFilled: S.getItem(key("extrawall")),
made: others.length,
linkBoth,
linkGrade,
linkCleared: S.getItem(key("revetlink")),
};
S.clear();
localStorage.setItem("frd_current_project_id", "p1");
+1 -3
View File
@@ -522,8 +522,6 @@ const out = {};
put("aislo:draft:basinadjust:P:7", { "100.00": { d: 1 } });
put("aislo:draft:inletstruct:P:7", { "100.00": "basin" });
put("aislo:draft:crossw:P:7", { "100.00": 5 });
let reloads = 0;
mod.setStationAdjustReload(() => (reloads += 1));
mod.forgetStationAdjusts("P", 100, "pipe", "box_culvert");
out.forget = {
culvertopt: get("aislo:draft:culvertopt:P:7"),
@@ -541,7 +539,7 @@ const out = {};
kindreset: get("aislo:draft:kindreset:P"),
changed: mod.kindChangedChainages("P"),
preview_calls: previewCalls,
reloads,
reload_hook: typeof mod.setStationAdjustReload,
};
mod.forgetStationAdjusts("P", 100, "box_culvert", "ford_bridge");
out.forget.second = get("aislo:draft:kindreset:P");
@@ -140,14 +140,8 @@ const NEW = (dy) => [
m.writeCrossDesignChoice(P, R, 20, choice("soil"));
m.writeCrossDesignChoice(P, R, 40, choice("soil"));
m.writeCrossDesignChoice(P, R, 45.054, choice("blasting_rock"));
const widths = m.createSessionMap(
() => "aislo:test:crossw",
(v) => v,
);
widths.values.set("45.05", 12);
widths.values.set("45.05:outlet", 3);
widths.values.set("20.00", 8);
widths.persist(); // 조정창은 바꿀 때마다 세션에 씀(제어기와 같은 차례)
// 조정창 칸 = 세션 칸이 주인(472-5 5-D) — 갈아끼움 이동은 세션 칸을 바로 옮김(`Station_Keys`)
m.writeState("crossw", { 45.05: 12, "45.05:outlet": 3, "20.00": 8 }, P, R);
const heard = [];
// 관 · 구조물 자리 장부 — 새 목록에서 관은 47 m · 구조물 id 는 측점 계획이 줌
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P);
@@ -181,7 +175,7 @@ const NEW = (dy) => [
rawKeys: Object.keys(m.readState("crossdesign", P, R) ?? {})
.filter((k) => k !== "@at")
.sort(),
widths: Object.fromEntries(widths.values),
widths: m.readState("crossw", P, R),
redraws,
};
// 같은 목록을 다시 내면 갈아끼우지 않음
@@ -17,10 +17,6 @@ export {
writeCrossDesignChoice,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export { useStructureIds } from "../../B06_Section/B06_Section_Station_Keys";
export {
createSessionMap,
moveSessionMaps,
} from "../../B06_Section/B06_Section_UI_Page_Link_Session";
export { useDesignStore } from "../../A00_Common/design/design_store";
export { readState, writeState } from "../../A00_Common/b_page_state";
export { createProfileSections } from "../../A00_Common/design/design_view_profile_sections";
+4 -3
View File
@@ -70,6 +70,7 @@ const load = makeLoader(
B06("B06_Section_Api_Culvert_Options"),
B06("B06_Section_UI_Page_Station_Controls"),
B06("B06_Section_UI_Page_Link_Session"),
B06("B06_Section_Session_Cells"), // 조정창 칸 창구(472-5 5-D · 메모리 맵 없음)
B06("B06_Section_UI_Cross_Culvert_Types"),
B06("logic/B06_Section_Station_Swap"),
B06("logic/B06_Section_UI_Page_Patches"),
@@ -143,9 +144,9 @@ function savePatches(controls) {
const overlay = buildCrossPatches({
rockOffsets: new Map(),
cutSlopeRatios: new Map(),
stationWidths: controls.widths,
inletStructures: controls.inletStructures,
basinAdjustments: controls.basinAdjustments,
stationWidths: controls.widths(),
inletStructures: controls.inletStructures(),
basinAdjustments: controls.basinAdjustments(),
revetAdjusts: controls.revetAdjustsByChainage(),
extraCounts: controls.extraCountsByChainage(),
extraSpans: controls.extraSpansByChainage(),
+105
View File
@@ -0,0 +1,105 @@
/* 472-5 5-D 헬퍼 — node helper_472_5d_adjust.mjs <묶음.mjs> · 표준출력 JSON.
* 조정창(반폭 · 흙막이 4축 · 유입 형식 · 연동 · 단 수 · 세월교)을 고침 → ↶ → 다른 측점을 고침 → ↶ ↷ —
* 세션 칸 · 캐시 설계 칸 · 조정창 읽기 · 저장 묶음이 되돌린 값을 따르는가(옛 코드 = 메모리 맵이 옛 값을 쥠 · N5). */
import { pathToFileURL } from "node:url";
const storage = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.window = { sessionStorage: storage(), localStorage: storage() }; // 손길 듣기 없음 = 열 때 규칙 꺼짐
globalThis.sessionStorage = window.sessionStorage;
globalThis.localStorage = window.localStorage;
globalThis.document = any;
const m = await import(pathToFileURL(process.argv[2]).href);
const P = "p472fived";
const R = 7;
localStorage.setItem("frd_current_project_id", P);
const NAMES = ["crossw", "revetx", "inletstruct", "revetlink", "extrawall", "fordadjust"];
const SAVED_OUTLET = { x: 2, d: null, h: null, m: null };
const sections = [100, 120, 140].map((chainage_m) => ({
chainage_m,
station_id: `s${chainage_m}`,
design: chainage_m === 100 ? { revet_adjust: { outlet: { ...SAVED_OUTLET } } } : {},
}));
const detail = { cross_sections: sections };
const c = m.createStationControls({
sessionKey: (kind) => m.stateKey(kind, P, R),
refreshCard: () => undefined,
detail: () => detail,
crossHalfWidth: () => undefined,
sampledHalfWidth: () => 20,
ensureSampledWidth: async () => true,
projectId: () => P,
});
c.load();
const cells = () => Object.fromEntries(NAMES.map((n) => [n, m.readState(n, P, R)]));
const designs = () => sections.map((s) => JSON.parse(JSON.stringify(s.design)));
const reads = () => ({
inlet: c.revetOffset.adjustFor(sections[0], "inlet"),
outlet: c.revetOffset.adjustFor(sections[0], "outlet"),
width100: c.stationWidth.widthFor(sections[0]) ?? null,
struct100: c.inletStructure.valueFor(sections[0]),
linked120: c.revetLink.linkedFor(sections[1]),
extra120: c.extraWalls.countFor(sections[1], "outlet"),
ford140: c.ford.adjustFor(140).inlet.lateralM,
});
const out = {};
const history = m.createProfileHistory(() => undefined, 100);
// 조작 A — 측점 100 · 120 · 140
c.revetOffset.update(100, "inlet", { x: 3 });
c.stationWidth.request(100, 6);
c.inletStructure.set(100, "L");
c.revetLink.setLinked(120, false);
c.extraWalls.setCount(120, 2, "outlet");
c.ford.update(140, "inlet", { lateralM: 2 });
history.record();
out.afterA = cells();
out.readsA = reads();
history.undo(); // ↶ = 세션 칸을 조작 전으로 되씀(가게 · 조정창을 안 거침)
out.afterUndo = cells();
m.withDraftAdjusts(detail, P, R); // 상세 가게가 읽을 때 얹기(↶ 훅 · 보기 전환 · 다시 그림 길)
out.designsUndo = designs();
out.readsUndo = reads();
// 조작 B — 다른 측점 · 다른 역할(옛 코드 = 메모리 맵이 A 값을 쥐고 통째로 다시 씀)
c.revetOffset.update(100, "outlet", { x: 1 });
c.stationWidth.request(120, 8);
c.inletStructure.set(120, "I");
c.revetLink.setLinked(140, false);
c.extraWalls.setCount(140, 1, "outlet");
c.ford.update(100, "outlet", { lateralM: 1 });
out.afterB = cells();
out.payloadB = {
revet: Object.fromEntries(c.revetAdjustsByChainage()),
link: Object.fromEntries(c.linkFlagsByChainage()),
extra: Object.fromEntries(c.extraCountsByChainage()),
ford: [...c.fordAdjustsByChainage().keys()],
};
history.record();
history.undo();
out.afterUndo2 = cells();
history.redo(); // ↷ = B 그대로
out.afterRedo = cells();
m.withDraftAdjusts(detail, P, R);
out.designsRedo = designs();
out.readsRedo = reads();
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,5 @@
/* 472-5 5-D 헬퍼 — B06 조정창 값 주인 = 세션 칸 하나(vite ssr) · test_472_5d_adjust_owner.py 가 부름. */
export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History";
export { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
export { withDraftAdjusts } from "../../B06_Section/B06_Section_Section_Adjusts";
export { readState, stateKey } from "../../A00_Common/b_page_state";
@@ -99,6 +99,8 @@ def test_세션_맵_빈_쓰기는_칸_지움(out):
assert h4["emptyWrite"] is None
assert h4["filledWrite"] == '{"100.00":6}'
assert h4["clearedWrite"] is None
# 빈 맵 이동은 칸을 안 만듦 · 값 있는 맵은 새 자리로 옮겨 씀
assert h4["moved"] == [None] * 6
assert h4["movedFilled"] == '{"120.00":2}'
# 472-5 5-D — 메모리 맵 · 맵 이동(moveSessionMaps)을 걷음 · 측점 이동은 세션 칸을 바로(`Station_Keys`) ·
# 연동 두 겹은 둘 다 빌 때 칸 지움
assert h4["linkBoth"] == '{"detached":{"100.00":true},"grade":{"100.00":false}}'
assert h4["linkGrade"] == '{"detached":{},"grade":{"100.00":false}}'
assert h4["linkCleared"] is None
+3 -2
View File
@@ -210,7 +210,8 @@ def test_forget_session_adjusts_at_station(result: dict) -> None:
assert forget["fordadjust"] == {} and forget["boxadjust"] == {} # 100.30 = 같은 관(0.51 안)
assert forget["basinadjust"] == {} and forget["inletstruct"] == {}
assert forget["crossw"] == {"100.00": 5} # 구조물 아닌 값(표시 반폭)은 그대로
assert forget["reloads"] == 1 # 횡단 보기 메모리 사본 다시 읽음
# 472-5 5-D — 조정창 메모리 사본 없음(세션 칸이 주인) = 다시 읽을 창구도 없음(옛 단언 reloads == 1)
assert forget["reload_hook"] == "undefined"
assert forget["preview_calls"] == [] # 415 M4 — 미리 뜬 횡단 사본 없음
@@ -247,7 +248,7 @@ def test_wiring_sources() -> None:
store = _src("B06_Section", "B06_Section_Section_Store.ts")
assert "crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt)" in store
view = _src("A00_Common", "design", "design_view_cross.ts")
assert "setStationAdjustReload(() => stationControls.load())" in view
assert "setStationAdjustReload" not in view # 472-5 5-D — 다시 읽을 메모리 사본 없음
def test_structure_keys_mirror() -> None:
@@ -0,0 +1,127 @@
"""PLAN 472-5 5-D (요구 273) — B06 조정창 값 주인 = 세션 칸 하나 · 메모리 맵 걷음(N5).
못박는 것
- 조정창(반폭 · 흙막이 4축 · 유입 형식 · 연동 · 단 수 · 세월교)을 고친 뒤 ↶ 하고 다른 측점을 고쳐도 되돌린 값이
되살아나지 않음 — 옛 코드는 메모리 맵이 옛 값을 쥐고 다음 조작이 칸을 통째로 다시 써 ↶ 를 덮었음(옛 코드 실패).
- ↶ 뒤 조정창 읽기 · 캐시 설계 칸(상세 가게 얹기 뒤) = 조작 전 값(저장분은 그대로) · ↷ = 다시 그 값.
- 저장 묶음(횡단 patch 재료)도 세션 칸 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_472_5d_adjust_owner.py -q
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
SAVED_OUTLET = {"x": 2, "d": None, "h": None, "m": None}
@pytest.fixture(scope="module")
def out(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("s4725d")
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_472_5d_adjust_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
check=True,
capture_output=True,
timeout=300,
)
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_472_5d_adjust_entry.js", bundle)
proc = subprocess.run( # noqa: S603
["node", str(HERE / "helper_472_5d_adjust.mjs"), str(bundle)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_조작_A_는_세션_칸에(out) -> None:
a = out["afterA"]
assert a["revetx"]["100.00:inlet"]["x"] == 3
assert a["crossw"] == {"100.00": 6}
assert a["inletstruct"] == {"100.00": "L"}
assert a["revetlink"]["detached"] == {"120.00": True}
assert a["extrawall"] == {"120.00": 2}
assert a["fordadjust"]["140.00"]["inlet"]["lateralM"] == 2
def test_되돌리면_칸이_비고_조정창도_조작_전(out) -> None:
assert out["afterUndo"] == {name: None for name in out["afterUndo"]}
r = out["readsUndo"]
assert r["inlet"]["x"] == 0
assert r["outlet"] == SAVED_OUTLET # 저장분 그대로
assert r["width100"] is None
assert r["struct100"] == "auto"
assert r["linked120"] is True
assert r["extra120"] == 0
assert r["ford140"] == 0
def test_되돌린_뒤_캐시_설계_칸도_조작_전(out) -> None:
"""옛 코드 = 조정창이 캐시(section.design)에 실은 값이 ↶ 뒤에도 남아 그림 · [저장] 바탕으로 나감."""
d100, d120, d140 = out["designsUndo"]
assert d100 == {"revet_adjust": {"outlet": SAVED_OUTLET}}
assert d120 == {}
assert d140 == {}
def test_되돌린_뒤_다른_조작이_되돌림을_안_덮음(out) -> None:
"""N5 — 옛 코드는 메모리 맵이 A 값을 쥐고 B 를 쓸 때 통째로 다시 씀(옛 코드 실패)."""
b = out["afterB"]
assert set(b["revetx"]) == {"100.00:outlet"}
assert b["revetx"]["100.00:outlet"]["x"] == 1
assert b["crossw"] == {"120.00": 8}
assert b["inletstruct"] == {"120.00": "I"}
assert b["revetlink"]["detached"] == {"140.00": True}
assert b["extrawall"] == {"140.00": 1}
assert set(b["fordadjust"]) == {"100.00"}
p = out["payloadB"]
assert set(p["revet"]) == {"100"} and set(p["revet"]["100"]) == {"outlet"}
assert set(p["link"]) == {"140"}
assert p["extra"] == {"140": {"outlet": 1}}
assert p["ford"] == [100]
def test_다시하기_뒤_값_같음(out) -> None:
assert out["afterUndo2"] == {name: None for name in out["afterUndo2"]}
assert out["afterRedo"] == out["afterB"]
d100, d120, d140 = out["designsRedo"]
assert d100["revet_adjust"] == {"outlet": {"x": 1, "d": None, "h": None, "m": None}}
assert "inlet_structure" not in d100
assert d120["inlet_structure"] == "I"
assert d140["revet_link_detached"] is True
assert d140["extra_wall_counts"] == {"outlet": 1, "basin": 0}
assert d100["ford_adjust"]["outlet"]["lateralM"] == 1
r = out["readsRedo"]
assert r["outlet"]["x"] == 1 and r["inlet"]["x"] == 0
assert r["width100"] is None and r["ford140"] == 0