refactor(B06): 측점 제어기 세션 맵 통합·구간값 제어 분리 (843→643줄)
- `createSessionMap` 을 `_Page_Link_Session.ts` 에 추가 — 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값 6쌍이 같은 모양(읽기 → 항목 검증 → 담기 / 통째 직렬화)이라 제어기마다 두던 load/persist 짝을 하나로 모음. 항목 검증과 구형식 호환(숫자 x·d=0)은 `accept` 로 넘어감. - `B06_Section_UI_Page_Span_Control.ts`(130줄) 신설 — 다단 단별 구간값과 유입·유출· 집수정 구간값 제어를 세션·캐시·정본 큐와 함께 한 덩어리로 이동. - 화면 검증: B06 카드 렌더 후 콘솔 오류 0, 조정창 DOM(구간 69·단 23) 그대로. 표시 반폭 − 조작으로 세션 `b06:crossw` 에 13 기록, ⤢ 로 12 복귀 후 키 제거. - `tsc --noEmit` 0, prettier 적용, pytest 359 passed.
This commit is contained in:
@@ -3,8 +3,60 @@
|
||||
* 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기
|
||||
* (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
|
||||
* 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다.
|
||||
*
|
||||
* 측점별 조작값을 담는 **한 겹 맵**도 여기서 낸다(`createSessionMap`, 2026-09-02) —
|
||||
* 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값이 같은 모양(읽기 → 항목
|
||||
* 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 세션에 담기는 측점별 조작값 한 겹 맵. */
|
||||
export interface SessionMap<V> {
|
||||
values: Map<string, V>;
|
||||
/** 세션값 읽기 — 맵을 비우고 다시 채운다. */
|
||||
load: () => void;
|
||||
/** 맵을 통째로 세션에 쓴다. */
|
||||
persist: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고,
|
||||
* `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리).
|
||||
*/
|
||||
export function createSessionMap<V>(
|
||||
sessionKey: () => string | null,
|
||||
accept: (value: unknown) => V | undefined,
|
||||
): SessionMap<V> {
|
||||
const values = new Map<string, V>();
|
||||
return {
|
||||
values,
|
||||
load(): void {
|
||||
values.clear();
|
||||
const key = sessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(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 {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(values)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinkFlagMaps {
|
||||
detached: Map<string, boolean>;
|
||||
followGrade: Map<string, boolean>;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Span_Control.ts
|
||||
* 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값.
|
||||
*
|
||||
* `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02).
|
||||
* 세션 맵(`extraSpans`)과 캐시(design)·정본 큐(`culvertOptions`)를 함께 만지는
|
||||
* 자리라 한 덩어리로 옮겼다. 판정 규칙은 종전대로 `_Cross_Culvert_Const` 몫이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
tierSpanOf,
|
||||
type SpanValues,
|
||||
type StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
/** 구간값 제어가 페이지·상위 제어기에서 받아 쓰는 창구. */
|
||||
export interface SpanControlDeps {
|
||||
detail: () => { cross_sections: CrossSection[] } | null;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 연동 구조물의 소유 측점 — 값은 소유 측점 하나에만 담는다. */
|
||||
ownerOf: (section: CrossSection) => CrossSection | null;
|
||||
/** 세션 맵(누가거리:벽키 → 구간값)과 저장. */
|
||||
extraSpans: Map<string, SpanValues>;
|
||||
persistExtraSpans: () => void;
|
||||
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
|
||||
/** 정본 반영 큐(관 옵션). */
|
||||
culvertOptions: { queue: (chainageM: number, values: Record<string, number>) => void };
|
||||
}
|
||||
|
||||
/** 구간값 제어기와 단별 구간값 조회를 만든다. */
|
||||
export function createSpanControl(deps: SpanControlDeps): {
|
||||
control: StructureSpanControl;
|
||||
/** 이 측점의 단별 구간값 전부(세션 우선) — 상위 제어기가 payload에 실을 때 쓴다. */
|
||||
tierSpansOf: (owner: CrossSection) => Record<string, StoredWallSpan>;
|
||||
spanKeyOf: (chainageM: number, wall: string) => string;
|
||||
} {
|
||||
const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf, culvertOptions } = deps;
|
||||
const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`;
|
||||
|
||||
const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => {
|
||||
const span = tierSpanOf(owner, wall);
|
||||
return {
|
||||
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
||||
beforeM: Math.round(span.beforeM * 10) / 10,
|
||||
afterM: Math.round(span.afterM * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
/** 이 측점의 단별 구간값 전부(세션 우선) — 캐시·payload에 실을 모양으로. */
|
||||
const tierSpansOf = (owner: CrossSection): Record<string, StoredWallSpan> => {
|
||||
const prefix = `${owner.chainage_m.toFixed(2)}:`;
|
||||
const result: Record<string, StoredWallSpan> = { ...(owner.design?.extra_spans ?? {}) };
|
||||
extraSpans.forEach((value, key) => {
|
||||
if (!key.startsWith(prefix)) return;
|
||||
result[key.slice(prefix.length)] = {
|
||||
length_m: value.lengthM,
|
||||
before_m: value.beforeM,
|
||||
after_m: value.afterM,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const control: StructureSpanControl = {
|
||||
ownerOf,
|
||||
tierValuesFor: (section, key) => {
|
||||
const owner = ownerOf(section) ?? section;
|
||||
return extraSpans.get(spanKeyOf(owner.chainage_m, key)) ?? storedTierSpan(owner, key);
|
||||
},
|
||||
updateTier: (section, key, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner) return;
|
||||
const mapKey = spanKeyOf(owner.chainage_m, key);
|
||||
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) });
|
||||
// 연장이 바뀌면 링크되는 옆 측점이 달라진다 — 옛 연장·새 연장을 합친 구간만.
|
||||
const reach = Math.max(current.beforeM, current.afterM, next.beforeM, next.afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
valuesFor: (section, role) => {
|
||||
const owner = ownerOf(section);
|
||||
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
|
||||
},
|
||||
update: (section, role, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner?.culvert) return;
|
||||
const current = CulvertConst.spanValuesOf(owner, role);
|
||||
if (!current) return;
|
||||
// 길이↔전/후 산식은 다단과 공용(`applySpanPatch`).
|
||||
const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch);
|
||||
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
|
||||
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
|
||||
if (role === "basin") {
|
||||
spec.basin_length_m = lengthM;
|
||||
spec.basin_before_m = beforeM;
|
||||
spec.basin_after_m = afterM;
|
||||
} else {
|
||||
spec.revet_length_m = lengthM;
|
||||
spec.revet_before_m = beforeM;
|
||||
spec.revet_after_m = afterM;
|
||||
}
|
||||
culvertOptions.queue(owner.chainage_m, {
|
||||
[keys.length]: lengthM,
|
||||
[keys.before]: beforeM,
|
||||
[keys.after]: afterM,
|
||||
});
|
||||
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
|
||||
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
|
||||
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
|
||||
// 2번만 반영).
|
||||
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { control, tierSpansOf, spanKeyOf };
|
||||
}
|
||||
@@ -23,16 +23,11 @@ import type {
|
||||
StationWidthControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
culvertOwnerFor,
|
||||
culvertReach,
|
||||
tierSpanOf,
|
||||
wallStandsAt,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
||||
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session";
|
||||
import { createLinkFlagSession, createSessionMap } from "./B06_Section_UI_Page_Link_Session";
|
||||
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";
|
||||
import type { BoxAdjust } from "./B06_Section_UI_Cross_Box";
|
||||
@@ -127,40 +122,16 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때
|
||||
* cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다.
|
||||
* 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */
|
||||
const stationWidths = new Map<string, number>();
|
||||
const widthSession = createSessionMap<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 widthSessionKey = (): string | null => deps.sessionKey("crossw");
|
||||
|
||||
function loadStationWidths(): void {
|
||||
stationWidths.clear();
|
||||
const key = widthSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, width]) => {
|
||||
if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistStationWidths(): void {
|
||||
const key = widthSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 반폭 하한 2m. **상한은 두지 않는다**(2026-08-23 개편) — 계산 반폭(20m)을
|
||||
* 넘는 값은 `deps.ensureSampledWidth`가 백엔드 재생성으로 샘플을 넓힌 뒤 적용된다.
|
||||
*/
|
||||
const clampStationWidth = (value: number): number => Math.max(Math.round(value), 2);
|
||||
|
||||
const stationWidthControl: StationWidthControl = {
|
||||
@@ -193,45 +164,29 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로
|
||||
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
|
||||
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
|
||||
const revetShifts = new Map<string, WallAdjust>();
|
||||
const revetSession = createSessionMap<WallAdjust>(
|
||||
() => deps.sessionKey("revetx"),
|
||||
(value) => {
|
||||
// 구 형식(숫자 = x 이동량)도 읽어 준다.
|
||||
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
// 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로
|
||||
// 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다.
|
||||
const partial = value as Partial<WallAdjust>;
|
||||
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
|
||||
},
|
||||
);
|
||||
const revetShifts = revetSession.values;
|
||||
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
|
||||
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */
|
||||
let revetSelection: { at: string; key: RevetKey } | null = null;
|
||||
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
|
||||
const revetSessionKey = (): string | null => deps.sessionKey("revetx");
|
||||
|
||||
function loadRevetShifts(): void {
|
||||
revetShifts.clear();
|
||||
revetSession.load();
|
||||
revetSelection = null;
|
||||
const key = revetSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number | Partial<WallAdjust>>;
|
||||
Object.entries(parsed).forEach(([entry, value]) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
revetShifts.set(entry, { ...ZERO_ADJUST, x: value });
|
||||
} else if (value && typeof value === "object") {
|
||||
// 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로
|
||||
// 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다.
|
||||
revetShifts.set(entry, { ...ZERO_ADJUST, ...value, d: value.d ? value.d : null });
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 자동 자리로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistRevetShifts(): void {
|
||||
const key = revetSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(revetShifts)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
const persistRevetShifts = revetSession.persist;
|
||||
|
||||
/** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
@@ -340,92 +295,37 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
};
|
||||
/* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ────────────────
|
||||
* auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */
|
||||
const inletStructures = new Map<string, InletStructureChoice>();
|
||||
const basinAdjustments = new Map<string, BasinAdjust>();
|
||||
const structSessionKey = (): string | null => deps.sessionKey("inletstruct");
|
||||
const basinSessionKey = (): string | null => deps.sessionKey("basinadjust");
|
||||
|
||||
function loadBasinAdjustments(): void {
|
||||
basinAdjustments.clear();
|
||||
const key = basinSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
|
||||
string,
|
||||
BasinAdjust
|
||||
>;
|
||||
Object.entries(parsed).forEach(([chainage, value]) =>
|
||||
basinAdjustments.set(chainage, { ...DEFAULT_BASIN_ADJUST, ...value }),
|
||||
);
|
||||
} catch {
|
||||
/* 손상된 세션 값은 기본값으로 대체. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistBasinAdjustments(): void {
|
||||
const key = basinSessionKey();
|
||||
if (key)
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(basinAdjustments)));
|
||||
}
|
||||
|
||||
function loadInletStructures(): void {
|
||||
inletStructures.clear();
|
||||
const key = structSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, InletStructureChoice>;
|
||||
Object.entries(parsed).forEach(([chainage, value]) => {
|
||||
if (["auto", "revet", "I", "L", "U"].includes(value)) inletStructures.set(chainage, value);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — auto(규칙)로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistInletStructures(): void {
|
||||
const key = structSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(inletStructures)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
const basinSession = createSessionMap<BasinAdjust>(
|
||||
() => deps.sessionKey("basinadjust"),
|
||||
(value) =>
|
||||
value && typeof value === "object"
|
||||
? { ...DEFAULT_BASIN_ADJUST, ...(value as Partial<BasinAdjust>) }
|
||||
: undefined,
|
||||
);
|
||||
const structSession = createSessionMap<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}` 키로. */
|
||||
const extraCounts = new Map<string, number>();
|
||||
const extraSessionKey = (): string | null => deps.sessionKey("extrawall");
|
||||
const extraSession = createSessionMap<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;
|
||||
|
||||
function loadExtraCounts(): void {
|
||||
extraCounts.clear();
|
||||
const key = extraSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, count]) => {
|
||||
if (Number.isInteger(count) && count > 0) extraCounts.set(chainage, count);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 추가 벽 없음으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistExtraCounts(): void {
|
||||
const key = extraSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraCounts)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
/** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */
|
||||
const pendingEqualize = new Set<string>();
|
||||
|
||||
// 다단은 유출 성토부(outlet)·집수정 계류측(basin) 두 갈래라 키에 쪽을 담는다.
|
||||
@@ -551,130 +451,30 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져
|
||||
* 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다
|
||||
* (4축 조작값과 같은 경로). 키는 `누가거리:벽키`(예 `234.10:extra0`). */
|
||||
const extraSpans = new Map<string, SpanValues>();
|
||||
const extraSpanSessionKey = (): string | null => deps.sessionKey("extraspan");
|
||||
const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`;
|
||||
const extraSpanSession = createSessionMap<SpanValues>(
|
||||
() => deps.sessionKey("extraspan"),
|
||||
(value) => {
|
||||
const span = value as SpanValues | null;
|
||||
return span && Number.isFinite(span.beforeM) && Number.isFinite(span.afterM)
|
||||
? span
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
const extraSpans = extraSpanSession.values;
|
||||
const loadExtraSpans = extraSpanSession.load;
|
||||
const persistExtraSpans = extraSpanSession.persist;
|
||||
|
||||
function loadExtraSpans(): void {
|
||||
extraSpans.clear();
|
||||
const key = extraSpanSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, SpanValues>;
|
||||
Object.entries(parsed).forEach(([mapKey, value]) => {
|
||||
if (value && Number.isFinite(value.beforeM) && Number.isFinite(value.afterM)) {
|
||||
extraSpans.set(mapKey, value);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 기본 구간값으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistExtraSpans(): void {
|
||||
const key = extraSpanSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraSpans)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션에 없을 때의 단별 구간값 — 정본(`design.extra_spans`) → **소유 벽 연장 상속**
|
||||
* 순서다(2026-08-30 사용자: 손대기 전에는 기준벽과 같이 옆 측점에 서야 한다).
|
||||
* 상속 규칙은 그리기·링크 판정이 쓰는 `tierSpanOf`와 같은 함수 하나로 맞춘다.
|
||||
*/
|
||||
const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => {
|
||||
const span = tierSpanOf(owner, wall);
|
||||
return {
|
||||
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
||||
beforeM: Math.round(span.beforeM * 10) / 10,
|
||||
afterM: Math.round(span.afterM * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
/** 이 측점의 단별 구간값 전부(세션 우선) — 캐시·payload에 실을 모양으로. */
|
||||
const tierSpansOf = (owner: CrossSection): Record<string, StoredWallSpan> => {
|
||||
const prefix = `${owner.chainage_m.toFixed(2)}:`;
|
||||
const result: Record<string, StoredWallSpan> = { ...(owner.design?.extra_spans ?? {}) };
|
||||
extraSpans.forEach((value, key) => {
|
||||
if (!key.startsWith(prefix)) return;
|
||||
result[key.slice(prefix.length)] = {
|
||||
length_m: value.lengthM,
|
||||
before_m: value.beforeM,
|
||||
after_m: value.afterM,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const structureSpanControl: StructureSpanControl = {
|
||||
const spanControl = createSpanControl({
|
||||
detail: () => deps.detail(),
|
||||
refreshCard: (chainageM) => deps.refreshCard(chainageM),
|
||||
ownerOf,
|
||||
tierValuesFor: (section, key) => {
|
||||
const owner = ownerOf(section) ?? section;
|
||||
return extraSpans.get(spanKeyOf(owner.chainage_m, key)) ?? storedTierSpan(owner, key);
|
||||
},
|
||||
updateTier: (section, key, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner) return;
|
||||
const mapKey = spanKeyOf(owner.chainage_m, key);
|
||||
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) });
|
||||
// 연장이 바뀌면 링크되는 옆 측점이 달라진다 — 옛 연장·새 연장을 합친 구간만.
|
||||
const reach = Math.max(current.beforeM, current.afterM, next.beforeM, next.afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
valuesFor: (section, role) => {
|
||||
const owner = ownerOf(section);
|
||||
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
|
||||
},
|
||||
update: (section, role, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner?.culvert) return;
|
||||
const current = CulvertConst.spanValuesOf(owner, role);
|
||||
if (!current) return;
|
||||
// 길이↔전/후 산식은 다단과 공용(`applySpanPatch`).
|
||||
const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch);
|
||||
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
|
||||
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
|
||||
if (role === "basin") {
|
||||
spec.basin_length_m = lengthM;
|
||||
spec.basin_before_m = beforeM;
|
||||
spec.basin_after_m = afterM;
|
||||
} else {
|
||||
spec.revet_length_m = lengthM;
|
||||
spec.revet_before_m = beforeM;
|
||||
spec.revet_after_m = afterM;
|
||||
}
|
||||
culvertOptions.queue(owner.chainage_m, {
|
||||
[keys.length]: lengthM,
|
||||
[keys.before]: beforeM,
|
||||
[keys.after]: afterM,
|
||||
});
|
||||
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
|
||||
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
|
||||
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
|
||||
// 2번만 반영).
|
||||
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
extraSpans,
|
||||
persistExtraSpans,
|
||||
patchCachedDesign,
|
||||
culvertOptions,
|
||||
});
|
||||
const structureSpanControl = spanControl.control;
|
||||
const spanKeyOf = spanControl.spanKeyOf;
|
||||
|
||||
/** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */
|
||||
const followGradeOf = (section: CrossSection): boolean => {
|
||||
|
||||
Reference in New Issue
Block a user