Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_8

This commit is contained in:
63 changed files with 4221 additions and 261 deletions
+16 -22
View File
@@ -8,9 +8,11 @@
* 계산은 브라우저가 하고(`common_util_cross_design.ts`), 선택값은 여기 담긴다.
*
* 담는 것은 **사용자가 고른 것만**이다 — 계산 결과(면적·설계선)는 담지 않는다.
* 세션 열쇠 = 측점 열쇠(344-3 · `B06_Section_Station_Keys`) · 부르는 쪽에는 누가거리로 내줌.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { chainageKey } from "@util/common_util_station_key";
import { readKeyed, writeKeyed } from "./B06_Section_Station_Keys";
/** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */
export interface CrossDesignChoice {
@@ -25,14 +27,9 @@ export interface CrossDesignChoice {
ditch_choice?: boolean | null;
}
type ChoiceMap = Record<string, CrossDesignChoice>;
/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
function readAll(projectId: string, routeId: number): ChoiceMap {
return readState<ChoiceMap>("crossdesign", projectId, routeId) ?? {};
}
/** 누가거리(0.01 m 글) → 선택값 — 지금 측점에 있는 것만. */
const readAll = (projectId: string, routeId: number) =>
readKeyed<CrossDesignChoice>("crossdesign", projectId, routeId);
/** 이 측점의 선택값(없으면 null). */
export function readCrossDesignChoice(
@@ -40,7 +37,7 @@ export function readCrossDesignChoice(
routeId: number,
chainageM: number,
): CrossDesignChoice | null {
return readAll(projectId, routeId)[keyOf(chainageM)] ?? null;
return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? null;
}
/** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */
@@ -50,9 +47,8 @@ export function writeCrossDesignChoice(
chainageM: number,
choice: CrossDesignChoice,
): void {
const all = readAll(projectId, routeId);
all[keyOf(chainageM)] = choice;
writeState("crossdesign", all, projectId, routeId);
const at = chainageKey(chainageM);
writeKeyed("crossdesign", projectId, routeId, new Map([[at, choice]]), [at]);
}
/** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */
@@ -62,7 +58,7 @@ export function crossDesignChoices(
): Map<number, CrossDesignChoice> {
const choices = new Map<number, CrossDesignChoice>();
if (!projectId || routeId === null) return choices;
for (const [key, value] of Object.entries(readAll(projectId, routeId))) {
for (const [key, value] of readAll(projectId, routeId)) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) choices.set(chainage, value);
}
@@ -80,12 +76,11 @@ type SideState = "shoulderext" | "bermdepth";
function readSides(name: SideState, projectId: string | null, routeId: number | null) {
const out: ShoulderExtends = new Map();
if (!projectId || routeId === null) return out;
const raw = readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
for (const [key, value] of readKeyed<Partial<Record<ShoulderSide, number>>>(
name,
projectId,
routeId,
);
for (const [key, value] of Object.entries(raw ?? {})) {
)) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) out.set(chainage, value);
}
@@ -100,11 +95,10 @@ function writeSide(
side: ShoulderSide,
valueM: number,
): void {
const all =
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(name, projectId, routeId) ??
{};
all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) };
writeState(name, all, projectId, routeId);
const at = chainageKey(chainageM);
const own = readKeyed<Partial<Record<ShoulderSide, number>>>(name, projectId, routeId).get(at);
const next = { ...(own ?? {}), [side]: Math.max(valueM, 0) };
writeKeyed(name, projectId, routeId, new Map([[at, next]]), [at]);
}
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
+10 -6
View File
@@ -28,7 +28,12 @@ import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import {
bermSpecAt,
readBermSpans,
readCutSlopeSession,
readRockBoundarySession,
} from "./B06_Section_UI_Page_Persist";
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
import type { BermSpec } from "@util/common_util_cross_berm";
import {
@@ -48,7 +53,7 @@ import {
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import { readState } from "../A00_Common/b_page_state";
import { useStationKeys } from "./B06_Section_Station_Keys";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
@@ -211,6 +216,7 @@ export async function refreshStaleDesigns(
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3)
const sections = input.detail.cross_sections;
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
const updated = refreshLocally(input) ?? (await refreshFromServer(input));
@@ -301,8 +307,7 @@ function bermDepthReader(projectId: string, routeId: number) {
*/
function readCutSlopeRatios(projectId: string, routeId: number): Map<number, number> {
const out = new Map<number, number>();
const raw = readState<Record<string, number>>("cutslope", projectId, routeId);
if (!raw) return out;
const raw = readCutSlopeSession(projectId, routeId);
for (const [chainage, ratio] of Object.entries(raw)) {
const at = Number(chainage);
if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio);
@@ -516,8 +521,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
berms: bermPayload(projectId, routeId, detail),
// 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만
// 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07).
cutSlopeRatios:
readState<Record<string, number>>("cutslope", projectId, routeId) ?? undefined,
cutSlopeRatios: readCutSlopeSession(projectId, routeId),
},
);
if (shouldApply && !shouldApply()) return [];
+136
View File
@@ -0,0 +1,136 @@
/* =============================================================================
* B06_Section_Cross_Stage.ts
* 계산 묶음 `cross` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — profile · ground · input.cross →
* 측점 열쇠마다 횡단 설계(`CrossOutput.byKey`).
*
* · 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝 · Cross_Refresh 와 같은 엔진).
* · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로.
* · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있으면 셈 없이 재사용.
* · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음.
* input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김).
* ========================================================================== */
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import type {
CrossOutput,
CrossStationResult,
PipelineStation,
StageContext,
} from "../A00_Common/design/design_pipeline_types";
import { planElevationAt } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */
export type CrossStationInput = Partial<
Omit<CrossDesignOptions, "standard" | "planRadiusM" | "curveOuterSide" | "curveWideningM">
>;
/** `input.cross` — 표준단면 + 측점 열쇠 → 그 측점 입력. */
export interface CrossStageInput {
standard: StandardCrossSectionSpec;
byKey: Record<string, CrossStationInput>;
}
interface Sample {
offset_m: number;
elevation_m: number | null;
valid: boolean;
}
/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */
export function groundSamples(station: PipelineStation): Sample[] {
const ground = station.ground;
if (!ground) return [];
const point = (offset: number, z: number | null): Sample => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const right = ground.right.map((s) => point(-s.offsetM, s.z));
const left = ground.left.map((s) => point(s.offsetM, s.z));
const out = [...right, ...left];
if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9))
out.push(point(0, ground.centerZ));
return out.sort((a, b) => a.offset_m - b.offset_m);
}
/** 측점 하나의 엔진 입력. */
function optionsFor(
station: PipelineStation,
input: CrossStationInput | undefined,
standard: StandardCrossSectionSpec,
): CrossDesignOptions {
return {
groundType: "ripping_rock",
sectionMode: "left_cut",
ditchType: "standard",
...input,
standard,
planRadiusM: station.planRadiusM,
curveOuterSide: station.curveOuterSide,
curveWideningM: station.curveWideningM,
};
}
const inRange = (station: PipelineStation, ctx: StageContext<"cross">): boolean => {
const range = ctx.range;
if (!range) return true;
if (range.keys.includes(station.key)) return true;
return station.chainageM >= range.fromM - 1e-6 && station.chainageM <= range.toM + 1e-6;
};
/**
* `cross` 단계 만들기 — 측점 지문 기억은 이 단계 함수가 사는 동안. `counts()` = 마지막 계산의 다시 셈 · 재사용 수.
*/
export function createCrossStage() {
const memo = new Map<string, { fingerprint: string; result: CrossStationResult }>();
let last = { computed: 0, reused: 0 };
function stage(ctx: StageContext<"cross">): CrossOutput {
const profile = ctx.out.profile;
const ground = ctx.out.ground;
const input = ctx.inputs.cross as CrossStageInput | null;
if (!profile || !ground || !input?.standard) return ctx.prev ?? { byKey: {} };
const standardKey = JSON.stringify(input.standard);
const byKey: Record<string, CrossStationResult> = {};
last = { computed: 0, reused: 0 };
for (const station of ground.stations) {
const kept = ctx.prev?.byKey[station.key];
if (kept && !inRange(station, ctx)) {
byKey[station.key] = { ...kept, chainageM: station.chainageM };
last.reused += 1;
continue;
}
const samples = groundSamples(station);
const elevation = planElevationAt(profile, station.chainageM);
const options = optionsFor(station, input.byKey[station.key], input.standard);
const fingerprint = `${standardKey}|${elevation}|${JSON.stringify(samples)}|${JSON.stringify({ ...options, standard: null })}`;
const hit = memo.get(station.key);
if (hit?.fingerprint === fingerprint) {
byKey[station.key] = { ...hit.result, chainageM: station.chainageM };
last.reused += 1;
continue;
}
let design;
try {
design = computeCrossDesign(samples, elevation, options);
} catch {
continue; // 표본 부족 측점은 건너뜀(Cross_Refresh · 서버 엔진과 같은 태도)
}
const result: CrossStationResult = {
key: station.key,
chainageM: station.chainageM,
cutAreaM2: design.cut_area_m2,
fillAreaM2: design.fill_area_m2,
detail: design,
};
memo.set(station.key, { fingerprint, result });
byKey[station.key] = result;
last.computed += 1;
}
for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점
return { byKey };
}
return { stage, counts: () => last };
}
+20
View File
@@ -186,6 +186,18 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
)
def _prune_cross_files(project_root: Path, longitudinal_file_path: str) -> None:
"""측점을 고친 뒤 옛 cross_*.json 을 지움 — GET 은 안 지우므로 쓰는 쪽(저장 · 확정)이 맡음."""
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
path = project_root / longitudinal_file_path
try:
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
except (OSError, ValueError, AttributeError):
return # 측점을 못 읽으면 안 지움(오삭제 막음)
prune_stale_cross_files(path.parent.parent / "cross_sections", stations)
async def _prebuild_corridor(project_id: UUID, route_id: int, project_root: Path) -> None:
"""3D 길 모형을 서버 Node 가 지어 남긴다(342-2) — 정본이 다시 선 [저장] · [확정] 끝.
@@ -261,6 +273,10 @@ async def save_sections(
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
# 측점 맞춤이 지운 자리의 옛 횡단 파일 정리(쓰는 쪽 몫)
await asyncio.to_thread(
_prune_cross_files, project_root, str(existing["longitudinal_file_path"])
)
rowless = missing + await asyncio.to_thread(
_rowless_station_chainages,
project_root,
@@ -341,6 +357,10 @@ async def confirm_sections(
known = await get_cross_section_chainages(connection, route_id)
marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter()))
project_root = Path(resolve_stored_project_path(stored_path))
# 측점 맞춤이 지운 자리의 옛 횡단 파일 정리(쓰는 쪽 몫)
await asyncio.to_thread(
_prune_cross_files, project_root, str(existing["longitudinal_file_path"])
)
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
# 조회 때마다 프리뷰가 다시 계산돼 3D·수량이 확정 결과가 아니게 된다(2026-08-24).
missing = missing + await asyncio.to_thread(
@@ -30,6 +30,7 @@ import {
structureStationMarks,
type PlannedStation,
} from "../common_util/common_util_structure_station";
import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys";
import {
previewStructureSections,
type CrossSection,
@@ -188,6 +189,7 @@ export async function withDraftStations(
if (inputs) {
const [pipes, structures, types] = inputs;
const planned = planStations(pipes, structures, types, registryDefaults(types));
useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3)
const planted = planned.map((station) => station.chainage_m);
// 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고
// 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴
@@ -246,4 +248,5 @@ export async function withDraftStations(
...[...live, ...fresh].sort(byChainage),
);
parked.set(detail, stale);
useStationKeys(projectId, routeId, detail); // 세션 횡단 초안 = 이 측점 목록의 열쇠(344-3)
}
+273
View File
@@ -0,0 +1,273 @@
/* =============================================================================
* B06_Section_Station_Keys.ts
* 세션 횡단 초안의 **측점 열쇠**(PLAN 344-3) — 노선이 바뀌어 누가거리가 밀려도 같은 측점의 고침이 남게.
*
* 열쇠 규칙 = `common_util_station_key`(일반 grid:k · 관 pipe:<id> · 구조물 structure:<id> · 시종점 end:BP/EP ·
* id 모름 at:<0.01 m>). 담는 칸 다섯(crossdesign · shoulderext · bermdepth · rockb · cutslope)은 측점 열쇠로
* 쓰고, 부르는 쪽에는 지금처럼 **누가거리**로 내줌(읽기 · 쓰기 창구가 그대로).
*
* · 지금 측점 목록 = 상세가 초안 측점을 얹을 때마다(`useStationKeys` · Section_Drafts) — 관 id 는 관 세션
* 장부 · 구조물 id 는 측점 계획(초안) · 구조물 목록(횡단 보기)에서.
* · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음.
* · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀.
* · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 ·
* 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) · 단면 유형 · 측구 방향은 새 지형이
* 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」 한 줄.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { showToast } from "@ui/ui_template_elements";
import { chainageKey, looseChainage, stationKey } from "@util/common_util_station_key";
import { inferStationInterval } from "@util/common_util_svg";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
/** 측점 열쇠로 담는 세션 칸. */
export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope";
export const KEYED_STATES: readonly KeyedState[] = [
"crossdesign",
"shoulderext",
"bermdepth",
"rockb",
"cutslope",
];
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 · 버리는 칸(노견 확장 · 소단 깊이 = 그 측점 기하 손질). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"];
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
const NEAR_M = 0.01;
const HINT = "@at";
type Keyed = { key: string; chainageM: number };
type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
const lists = new Map<string, Keyed[]>();
/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */
const owners = new Map<string, Array<{ id: string; kind: "pipe" | "structure"; at: number }>>();
let current: { projectId: string; routeId: number } | null = null;
const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`;
/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */
export function useStationOwners(
projectId: string,
planned: ReadonlyArray<{ chainage_m: number; owners: string[] }>,
): void {
owners.set(
projectId,
planned.flatMap((station) =>
station.owners.slice(0, 1).map((id) => ({
id,
kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const),
at: station.chainage_m,
})),
),
);
}
/** 구조물 목록(횡단 보기) — 한 자리 구조물의 id. 관 자리는 관 장부가 정함. */
export function useStructureIds(
projectId: string,
structures: ReadonlyArray<{ structure_id?: string | null; chainage_m?: number | null }>,
): void {
const known = (owners.get(projectId) ?? []).filter((owner) => owner.kind === "pipe");
for (const item of structures) {
if (item.structure_id && typeof item.chainage_m === "number")
known.push({ id: item.structure_id, kind: "structure", at: item.chainage_m });
}
owners.set(projectId, known);
}
const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:`). */
export function stationKeysOf(
projectId: string | null,
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : [];
const seen = new Set<string>();
return sections.map((section) => {
const at = section.chainage_m;
let key: string;
if (section.kind === "irregular") {
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind);
else if (looksLikePipe(section))
key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId));
else
key = section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
} else key = stationKey(section.kind, at, intervalM);
if (seen.has(key)) key = `at:${chainageKey(at)}`;
seen.add(key);
return { key, chainageM: at };
});
}
/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */
export function useStationKeys(
projectId: string,
routeId: number,
detail: Pick<SectionDetailResponse, "cross_sections" | "longitudinal">,
): void {
const interval =
(
detail.longitudinal?.profile_alignment as
{ policy?: { station_interval_m?: number } } | undefined
)?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections);
lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval));
current = { projectId, routeId };
}
/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */
export const currentStationScope = () => current;
function nearest(list: Keyed[], chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
const gap = Math.abs(entry.chainageM - chainageM);
if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry;
}
return best;
}
/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */
export function keyAt(projectId: string, routeId: number, chainageM: number): string {
const list = lists.get(scopeOf(projectId, routeId));
return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`;
}
/**
* 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금
* 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖).
*/
export function chainageOfKey(
projectId: string,
routeId: number,
key: string,
hint?: number,
): number | null {
const list = lists.get(scopeOf(projectId, routeId));
const hit = list?.find((entry) => entry.key === key);
if (hit) return Number(chainageKey(hit.chainageM));
const at = looseChainage(key) ?? hint;
if (at === undefined) return null;
if (!list) return Number(chainageKey(at));
const near = nearest(list, at);
return near ? Number(chainageKey(near.chainageM)) : null;
}
function readRaw(name: KeyedState, projectId: string, routeId: number): Raw {
const raw = readState<Raw>(name, projectId, routeId);
return raw && typeof raw === "object" ? raw : {};
}
function entries<V>(raw: Raw): Array<[string, V, number | undefined]> {
const hints = raw[HINT] ?? {};
return Object.entries(raw)
.filter(([key]) => key !== HINT)
.map(([key, value]) => [key, value as V, hints[key]]);
}
/**
* 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음).
* 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때).
*/
export function readKeyed<V>(name: KeyedState, projectId: string, routeId: number): Map<string, V> {
const raw = readRaw(name, projectId, routeId);
const out = new Map<string, V>();
let moved = false;
for (const [key, value, hint] of entries<V>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at === null) continue;
out.set(chainageKey(at), value);
if (looseChainage(key) !== null && !keyAt(projectId, routeId, at).startsWith("at:"))
moved = true;
}
if (moved) writeKeyed(name, projectId, routeId, out);
return out;
}
/**
* 누가거리(0.01 m 글) → 값 묶음을 측점 열쇠로 씀. 지금 측점이 아닌 옛 고침(노선 밖 · 아직 못 읽는 것)은 그대로 둠.
* `only` 를 주면 그 누가거리들만 바꾸고 나머지 지금 측점 고침도 그대로.
*/
export function writeKeyed<V>(
name: KeyedState,
projectId: string,
routeId: number,
byChainage: ReadonlyMap<string, V>,
only?: ReadonlyArray<string>,
): void {
const raw = readRaw(name, projectId, routeId);
const next: Raw = { [HINT]: {} };
const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at))));
for (const [key, value, hint] of entries<unknown>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue;
next[key] = value;
if (hint !== undefined) next[HINT]![key] = hint;
}
for (const [at, value] of byChainage) {
if (only !== undefined && !replaced.has(chainageKey(Number(at)))) continue;
const key = keyAt(projectId, routeId, Number(at));
next[key] = value;
next[HINT]![key] = Number(chainageKey(Number(at)));
}
writeState(name, next, projectId, routeId);
}
/**
* 노선이 바뀜(새 측점 목록을 `useStationKeys` 로 세운 뒤) — 옛 노선 고침을 측점 열쇠로 새 노선에 넘김.
* 넘김 = 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사 · 단면 유형 · 측구
* 방향은 새 지형이 정함 · 노견 확장 · 소단 깊이는 안 넘김. 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위).
*/
export function carryStationDrafts(
projectId: string,
fromRouteId: number,
toRouteId: number,
): number {
const outside = new Set<string>();
for (const name of KEYED_STATES) {
const raw = readRaw(name, projectId, fromRouteId);
const next: Raw = { [HINT]: {} };
for (const [key, value] of entries<unknown>(raw)) {
// 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로
const fromAt =
looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key);
const stable = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt);
const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined);
const carried = carriedValue(name, value);
if (toAt === null || stable.startsWith("at:")) {
if (carried !== null) outside.add(stable);
continue;
}
if (carried === null) continue;
const toKey = keyAt(projectId, toRouteId, toAt);
next[toKey] = carried;
next[HINT]![toKey] = toAt;
}
writeState(name, next, projectId, toRouteId);
}
if (outside.size)
showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info");
return outside.size;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
if (name !== "crossdesign" || !value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const picked = Object.fromEntries(
CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
return Object.keys(picked).length ? picked : null;
}
@@ -27,6 +27,7 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
import { useStructureIds } from "./B06_Section_Station_Keys";
export interface DesignSyncContext {
projectId: string | null;
@@ -194,6 +195,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
const currentRouteId = ctx.routeId();
if (!projectId || currentRouteId === null) return;
useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3)
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
+34 -12
View File
@@ -49,6 +49,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { catalogRow } from "../common_util/common_util_structure_catalog";
import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys";
/**
* 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체.
@@ -65,8 +66,35 @@ export function readRockBoundarySession(
projectId: string,
routeId: number,
): Record<string, number> {
const stored = readState<Record<string, number>>("rockb", projectId, routeId);
return stored && typeof stored === "object" ? stored : {};
// 세션 열쇠 = 측점 열쇠(344-3) — 부르는 쪽(다시 셈 · 서버 폴백)에는 누가거리(0.01 m 글)로
return Object.fromEntries(readKeyed<number>("rockb", projectId, routeId));
}
/** 측점별 암 절토 경사 세션값 — 누가거리(0.01 m 글) → 경사비(0 = 표준으로 되돌림 · 344-3). */
export function readCutSlopeSession(projectId: string, routeId: number): Record<string, number> {
return Object.fromEntries(readKeyed<number>("cutslope", projectId, routeId));
}
/** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */
function keyedScope(storageKey: string): { projectId: string; routeId: number } | null {
const [projectId, route] = storageKey.split(":").slice(-2);
const routeId = Number(route);
return projectId && Number.isInteger(routeId) ? { projectId, routeId } : null;
}
/** 저장소 값 읽기 — 측점 열쇠 칸이면 누가거리로 옮겨 · 아니면 옛 꼴 그대로. */
function loadKeyed(name: KeyedState, storageKey: string): Array<[string, number]> {
const scope = keyedScope(storageKey);
if (scope) return [...readKeyed<number>(name, scope.projectId, scope.routeId)];
const raw = readByKey(storageKey);
return raw ? Object.entries(JSON.parse(raw) as Record<string, number>) : [];
}
/** 저장소 값 쓰기 — 측점 열쇠로(노선 밖 옛 고침은 그대로 둠). */
function persistKeyed(name: KeyedState, storageKey: string, values: Map<string, number>): void {
const scope = keyedScope(storageKey);
if (scope) writeKeyed(name, scope.projectId, scope.routeId, values);
else writeByKey(storageKey, JSON.stringify(Object.fromEntries(values)));
}
/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */
@@ -215,7 +243,7 @@ export function createRockBoundaryStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets)));
persistKeyed("rockb", storageKey, offsets);
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
@@ -228,10 +256,7 @@ export function createRockBoundaryStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
const raw = readByKey(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, offset]) => {
loadKeyed("rockb", storageKey).forEach(([chainage, offset]) => {
if (Number.isFinite(offset)) offsets.set(chainage, offset);
});
} catch {
@@ -318,7 +343,7 @@ export function createCutSlopeStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios)));
persistKeyed("cutslope", storageKey, ratios);
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
@@ -331,10 +356,7 @@ export function createCutSlopeStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
const raw = readByKey(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, ratio]) => {
loadKeyed("cutslope", storageKey).forEach(([chainage, ratio]) => {
// 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다.
if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio);
});