Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import type { StageContext } from "../A00_Common/design/design_pipeline_types";
|
||||
import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage";
|
||||
|
||||
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
|
||||
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
|
||||
@@ -24,6 +26,84 @@ const TIMEOUT_MS = 20000;
|
||||
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
|
||||
const KEPT_PLANS = 8;
|
||||
|
||||
/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입 · 묶음 단계가 함께 씀. */
|
||||
const kept = new Map<string, HaulPlanPayload>();
|
||||
|
||||
/**
|
||||
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
||||
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
|
||||
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
|
||||
*/
|
||||
function keyOf(result: unknown): string {
|
||||
const points = (result as { points?: unknown[] })?.points;
|
||||
if (!Array.isArray(points) || !points.length) return "empty";
|
||||
return JSON.stringify(points);
|
||||
}
|
||||
|
||||
const keptKey = (projectId: string, route: number, key: string): string =>
|
||||
`${projectId}:${route}|${key}`;
|
||||
|
||||
function keep(at: string, plan: HaulPlanPayload): void {
|
||||
kept.delete(at);
|
||||
kept.set(at, plan);
|
||||
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
|
||||
}
|
||||
|
||||
/** 서버에 배분을 물음 — 받으면 기억 · 못 받으면(실패 · 끊김) undefined. `signal` 이 끊으면 함께 끊음. */
|
||||
async function postHaulPlan(
|
||||
projectId: string,
|
||||
route: number,
|
||||
result: unknown,
|
||||
key: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HaulPlanPayload | undefined> {
|
||||
if (signal?.aborted) return undefined;
|
||||
const controller = new AbortController();
|
||||
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const relay = () => controller.abort();
|
||||
signal?.addEventListener("abort", relay);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) return undefined;
|
||||
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
|
||||
const plan = payload.haul_plan ?? null;
|
||||
keep(keptKey(projectId, route, key), plan);
|
||||
return plan;
|
||||
} catch {
|
||||
return undefined; // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
|
||||
} finally {
|
||||
window.clearTimeout(abort);
|
||||
signal?.removeEventListener("abort", relay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산 묶음 `haul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — masshaul 의 횡단 기준 결과를 서버
|
||||
* Node 로 배분 · 같은 열쇠면 안 물음 · 새 조작이 오면 `ctx.signal` 로 끊김(옛 번호 결과는 묶음이 버림).
|
||||
* 못 받으면 앞 배분(`ctx.prev`) 그대로.
|
||||
*/
|
||||
export function createHaulStage(projectId: string, routeId: () => number | undefined) {
|
||||
return async (ctx: StageContext<"haul">): Promise<HaulPlanPayload> => {
|
||||
const result = (ctx.out.masshaul as MassHaulStageOutput | null)?.haulInput;
|
||||
const route = routeId();
|
||||
if (!result || !route) return (ctx.prev as HaulPlanPayload) ?? null;
|
||||
const key = keyOf(result);
|
||||
const at = keptKey(projectId, route, key);
|
||||
if (kept.has(at)) return kept.get(at) ?? null;
|
||||
const plan = await postHaulPlan(projectId, route, result, key, ctx.signal);
|
||||
return plan === undefined ? ((ctx.prev as HaulPlanPayload) ?? null) : plan;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HaulPlanPrefetch {
|
||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
|
||||
schedule: (result: unknown) => void;
|
||||
@@ -50,19 +130,6 @@ export function createHaulPlanPrefetch(
|
||||
let sentKey: string | null = null;
|
||||
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
|
||||
let applying = false;
|
||||
/** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */
|
||||
const kept = new Map<string, HaulPlanPayload>();
|
||||
|
||||
/**
|
||||
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
||||
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
|
||||
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
|
||||
*/
|
||||
function keyOf(result: unknown): string {
|
||||
const points = (result as { points?: unknown[] })?.points;
|
||||
if (!Array.isArray(points) || !points.length) return "empty";
|
||||
return JSON.stringify(points);
|
||||
}
|
||||
|
||||
/** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */
|
||||
function forget(seq: number): void {
|
||||
@@ -72,41 +139,19 @@ export function createHaulPlanPrefetch(
|
||||
async function send(result: unknown, seq: number, key: string): Promise<void> {
|
||||
const route = routeId();
|
||||
if (!route) return forget(seq);
|
||||
const controller = new AbortController();
|
||||
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const got = await postHaulPlan(projectId, route, result, key);
|
||||
if (got === undefined) return forget(seq);
|
||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||
if (seq !== sequence) return;
|
||||
plan = got;
|
||||
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
|
||||
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
|
||||
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
|
||||
applying = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) return forget(seq);
|
||||
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
|
||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||
if (seq !== sequence) return;
|
||||
plan = payload.haul_plan ?? null;
|
||||
kept.delete(key);
|
||||
kept.set(key, plan);
|
||||
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
|
||||
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
|
||||
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
|
||||
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
|
||||
applying = true;
|
||||
try {
|
||||
onReady();
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
} catch {
|
||||
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
|
||||
forget(seq);
|
||||
onReady();
|
||||
} finally {
|
||||
window.clearTimeout(abort);
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +166,8 @@ export function createHaulPlanPrefetch(
|
||||
const seq = sequence;
|
||||
window.clearTimeout(timer);
|
||||
// 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다.
|
||||
const known = kept.get(key);
|
||||
const route = routeId();
|
||||
const known = route ? kept.get(keptKey(projectId, route, key)) : undefined;
|
||||
if (known !== undefined) return void (plan = known);
|
||||
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_MassHaul_Stage.ts
|
||||
* 계산 묶음 `masshaul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — cross(측점 열쇠마다 면적) ·
|
||||
* profile → 유토곡선 계열(`computeMassHaulSeries` · 화면 유토곡선 · B06 과 같은 계산).
|
||||
*
|
||||
* 출력 = `MassHaulStageOutput`(계열 + 흙 배분에 보낼 횡단 기준 결과) · 순수 계산(DOM · fetch · 세션 없음).
|
||||
* 토량환산계수 · 자연 사토 기울기는 부르는 쪽이 넘김(`context`) — 없으면 null.
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeMassHaulSeries, type MassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import type { EarthworkConversion, MassHaulSection } from "@util/common_util_mass_haul_types";
|
||||
import type { StageContext } from "../../A00_Common/design/design_pipeline_types";
|
||||
|
||||
export interface MassHaulStageContext {
|
||||
conversion: EarthworkConversion;
|
||||
naturalSpoilMinSlope?: number;
|
||||
}
|
||||
|
||||
export interface MassHaulStageOutput {
|
||||
series: MassHaulSeries[];
|
||||
/** 흙 배분(haul 단계)에 보낼 결과 — 횡단 기준(없으면 첫 계열) · 계열이 없으면 null. */
|
||||
haulInput: MassHaulSeries["result"] | null;
|
||||
}
|
||||
|
||||
/** `masshaul` 단계 만들기 — `context()` 는 부를 때마다 읽음(설정이 바뀌면 다음 계산부터). */
|
||||
export function createMassHaulStage(context: () => MassHaulStageContext | null) {
|
||||
return (ctx: StageContext<"masshaul">): MassHaulStageOutput | null => {
|
||||
const cross = ctx.out.cross;
|
||||
const settings = context();
|
||||
if (!cross || !settings) return null;
|
||||
const sections: MassHaulSection[] = Object.values(cross.byKey)
|
||||
.sort((a, b) => a.chainageM - b.chainageM)
|
||||
.map((entry) => ({
|
||||
station_id: entry.key,
|
||||
chainage_m: entry.chainageM,
|
||||
design: entry.detail as MassHaulSection["design"],
|
||||
}));
|
||||
const samples = ctx.out.profile?.samples ?? [];
|
||||
const longitudinal = {
|
||||
length_m:
|
||||
samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0,
|
||||
design_profiles: samples.length ? [{ samples }] : [],
|
||||
};
|
||||
const series = computeMassHaulSeries(
|
||||
longitudinal,
|
||||
sections,
|
||||
settings.conversion,
|
||||
settings.naturalSpoilMinSlope,
|
||||
);
|
||||
const picked = series.find((entry) => entry.basis === "cross") ?? series[0];
|
||||
return { series, haulInput: picked?.result ?? null };
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름.
|
||||
|
||||
일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:<id>`` · 구조물 = ``structure:<id>`` ·
|
||||
시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``.
|
||||
|
||||
⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``.
|
||||
한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
_LOOSE = re.compile(r"^-?\d+(\.\d+)?$")
|
||||
|
||||
|
||||
def _half_up(value: float) -> int:
|
||||
"""JS ``Math.round`` 와 같은 반올림(0.5 는 위로)."""
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def chainage_key(chainage_m: float) -> str:
|
||||
"""누가거리 0.01 m — 옛 초안 열쇠(``toFixed(2)``)와 같은 자릿수."""
|
||||
return f"{_half_up(chainage_m * 100) / 100:.2f}"
|
||||
|
||||
|
||||
def station_key(
|
||||
kind: str,
|
||||
chainage_m: float,
|
||||
interval_m: float | None,
|
||||
station_id: str | None = None,
|
||||
id_kind: str = "structure",
|
||||
) -> str:
|
||||
"""측점 열쇠 — irregular 는 id 가 있으면 pipe: · structure:(``id_kind``) · 없으면 at:."""
|
||||
if kind == "bp":
|
||||
return "end:BP"
|
||||
if kind == "ep":
|
||||
return "end:EP"
|
||||
if kind in ("pipe", "structure"):
|
||||
return f"{kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}"
|
||||
if kind == "irregular":
|
||||
return f"{id_kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}"
|
||||
if interval_m and interval_m > 0:
|
||||
return f"grid:{_half_up(chainage_m / interval_m)}"
|
||||
return f"at:{chainage_key(chainage_m)}"
|
||||
|
||||
|
||||
def loose_chainage(key: str) -> float | None:
|
||||
"""옛 초안 열쇠(누가거리 글) · ``at:`` 열쇠의 누가거리 — 측점 열쇠면 None."""
|
||||
text = key[3:] if key.startswith("at:") else key
|
||||
return float(text) if _LOOSE.match(text) else None
|
||||
@@ -0,0 +1,43 @@
|
||||
/* =============================================================================
|
||||
* common_util_station_key.ts
|
||||
* 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름.
|
||||
* 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:<id>` · 구조물 = `structure:<id>` ·
|
||||
* 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫).
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — `common_util_station_key.py` `station_key` ↔ `stationKey`. 한쪽을 고치면 다른 쪽도
|
||||
* 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) 그대로 받음. */
|
||||
export type StationKeyKind = "bp" | "ep" | "regular" | "irregular" | "pipe" | "structure";
|
||||
|
||||
/** 누가거리 0.01 m — 옛 초안 열쇠(`toFixed(2)`)와 같은 자릿수. */
|
||||
export const chainageKey = (chainageM: number): string =>
|
||||
(Math.round(chainageM * 100) / 100).toFixed(2);
|
||||
|
||||
/**
|
||||
* 측점 열쇠. irregular 는 id 가 있으면 `pipe:` · `structure:`(종류는 `idKind`) · 없으면 `at:`.
|
||||
* 간격이 없거나 0 이하면 일반 측점도 `at:`.
|
||||
*/
|
||||
export function stationKey(
|
||||
kind: StationKeyKind,
|
||||
chainageM: number,
|
||||
intervalM: number | null,
|
||||
id: string | null = null,
|
||||
idKind: "pipe" | "structure" = "structure",
|
||||
): string {
|
||||
if (kind === "bp") return "end:BP";
|
||||
if (kind === "ep") return "end:EP";
|
||||
if (kind === "pipe" || kind === "structure")
|
||||
return id ? `${kind}:${id}` : `at:${chainageKey(chainageM)}`;
|
||||
if (kind === "irregular") return id ? `${idKind}:${id}` : `at:${chainageKey(chainageM)}`;
|
||||
if (intervalM && intervalM > 0) return `grid:${Math.round(chainageM / intervalM)}`;
|
||||
return `at:${chainageKey(chainageM)}`;
|
||||
}
|
||||
|
||||
/** 옛 초안 열쇠(누가거리 글 `"85.05"`) · `at:` 열쇠의 누가거리 — 측점 열쇠면 null. */
|
||||
export function looseChainage(key: string): number | null {
|
||||
const text = key.startsWith("at:") ? key.slice(3) : key;
|
||||
if (!/^-?\d+(\.\d+)?$/.test(text)) return null;
|
||||
return Number(text);
|
||||
}
|
||||
@@ -65,6 +65,8 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
|
||||
}
|
||||
},
|
||||
},
|
||||
// 측점 열쇠 세우기(344-3) — 이 시험 몫 아님
|
||||
"./B06_Section_Station_Keys": { useStationKeys: () => {}, useStationOwners: () => {} },
|
||||
"./B06_Section_Api_Fetch": {
|
||||
previewStructureSections: async (_projectId, chainages) => {
|
||||
asked.push(chainages);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
|
||||
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다.
|
||||
*/
|
||||
export {
|
||||
carryStationDrafts,
|
||||
chainageOfKey,
|
||||
keyAt,
|
||||
readKeyed,
|
||||
useStationKeys,
|
||||
useStructureIds,
|
||||
} from "../../B06_Section/B06_Section_Station_Keys";
|
||||
export {
|
||||
crossDesignChoices,
|
||||
readShoulderExtends,
|
||||
writeCrossDesignChoice,
|
||||
writeShoulderExtend,
|
||||
} from "../../B06_Section/B06_Section_Cross_Design_Session";
|
||||
export {
|
||||
createRockBoundaryStore,
|
||||
readRockBoundarySession,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
export { createCrossStage, groundSamples } from "../../B06_Section/B06_Section_Cross_Stage";
|
||||
export { createMassHaulStage } from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage";
|
||||
export { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||
export { buildAlignment, emptyEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
export { readState, stateKey, writeState } from "../../A00_Common/b_page_state";
|
||||
@@ -0,0 +1,240 @@
|
||||
/* 344-3 · 346-2 2파 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_344_3_entry.ts)을 부르고 결과를 JSON 한 벌로.
|
||||
* node helper_344_3_station_keys.mjs <묶음.mjs> <입력.json>
|
||||
* 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 }.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const store = () => {
|
||||
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(),
|
||||
};
|
||||
};
|
||||
globalThis.window = globalThis;
|
||||
globalThis.sessionStorage = store();
|
||||
globalThis.localStorage = store();
|
||||
const any = new Proxy(function () {}, {
|
||||
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
|
||||
apply: () => any,
|
||||
construct: () => any,
|
||||
});
|
||||
globalThis.document = any;
|
||||
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
|
||||
|
||||
const [bundlePath, inputPath] = process.argv.slice(2);
|
||||
const m = await import(pathToFileURL(bundlePath).href);
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||
const P = "p344";
|
||||
const R = 7;
|
||||
const R2 = 8;
|
||||
const raw = (name, route) => m.readState(name, P, route) ?? {};
|
||||
const keysOf = (name, route) =>
|
||||
Object.keys(raw(name, route))
|
||||
.filter((k) => k !== "@at")
|
||||
.sort();
|
||||
const section = (chainage_m, kind, extra = {}) => ({
|
||||
station_id: `s${chainage_m}`,
|
||||
chainage_m,
|
||||
kind,
|
||||
label: String(chainage_m),
|
||||
samples: [],
|
||||
...extra,
|
||||
});
|
||||
const detailOf = (sections) => ({
|
||||
longitudinal: {
|
||||
stations: sections,
|
||||
profile_alignment: { policy: { station_interval_m: 20 } },
|
||||
},
|
||||
cross_sections: sections,
|
||||
});
|
||||
const choice = (ground) => ({
|
||||
ground_type: ground,
|
||||
section_mode: "right_cut",
|
||||
ditch_side: "right",
|
||||
ditch_type: "l_type",
|
||||
paved: true,
|
||||
two_stage_slope: true,
|
||||
ditch_choice: false,
|
||||
});
|
||||
const out = {};
|
||||
|
||||
/* 1. 옛 초안(누가거리 열쇠) → 측점 열쇠로 옮김 · 지금 측점에 없는 것은 남겨 둠 */
|
||||
const oldRoute = detailOf([
|
||||
section(0, "bp"),
|
||||
section(20, "regular"),
|
||||
section(40, "regular"),
|
||||
section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }),
|
||||
section(52.5, "irregular", { structure: "대피로" }),
|
||||
section(60, "regular"),
|
||||
section(70, "ep"),
|
||||
]);
|
||||
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 52.5 }]);
|
||||
m.useStationKeys(P, R, oldRoute);
|
||||
m.writeState(
|
||||
"crossdesign",
|
||||
{
|
||||
"20.00": choice("soil"),
|
||||
45.05: choice("blasting_rock"),
|
||||
"52.50": choice("soil"),
|
||||
"60.00": choice("soil"),
|
||||
"99.00": choice("soil"),
|
||||
},
|
||||
P,
|
||||
R,
|
||||
);
|
||||
out.legacyRead = [...m.crossDesignChoices(P, R).keys()].sort((a, b) => a - b);
|
||||
out.legacyKeys = keysOf("crossdesign", R);
|
||||
|
||||
/* 2. 쓰기 = 측점 열쇠 · 누가거리로 다시 읽음 · 암 경계 저장소도 같은 열쇠 */
|
||||
m.writeCrossDesignChoice(P, R, 40, choice("ripping_rock"));
|
||||
m.writeShoulderExtend(P, R, 40, "left", 0.5);
|
||||
out.writeKey = keysOf("crossdesign", R).includes("grid:2");
|
||||
out.shoulderKeys = keysOf("shoulderext", R);
|
||||
const rock = m.createRockBoundaryStore({
|
||||
sessionKey: () => m.stateKey("rockb", P, R),
|
||||
detail: () => oldRoute,
|
||||
refreshCard: () => undefined,
|
||||
recompute: () => undefined,
|
||||
});
|
||||
rock.control.adjust(20, -0.3);
|
||||
out.rockKeys = keysOf("rockb", R);
|
||||
rock.load();
|
||||
out.rockLoaded = Object.fromEntries(rock.offsets);
|
||||
out.rockSession = m.readRockBoundarySession(P, R);
|
||||
|
||||
/* 3. 구조물 id 를 아직 모를 때(첫 열기) — 쓴 때 자리(@at)로 그 측점 고침을 읽음 */
|
||||
m.useStructureIds(P, []);
|
||||
m.useStationKeys(P, R, oldRoute);
|
||||
out.hintKeyNow = m.keyAt(P, R, 52.5);
|
||||
out.hintRead = m.crossDesignChoices(P, R).has(52.5);
|
||||
|
||||
/* 4. 노선이 바뀜 — 측점 열쇠로 넘김 · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장 안 넘김 · 사라진 측점 = 노선 밖 */
|
||||
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); // 관이 47 m 로 옮김
|
||||
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 55 }]);
|
||||
m.useStationKeys(
|
||||
P,
|
||||
R2,
|
||||
detailOf([
|
||||
section(0, "bp"),
|
||||
section(20, "regular"),
|
||||
section(40, "regular"),
|
||||
section(47, "irregular", { structure: "파형강관 D800", culvert: {} }),
|
||||
section(55, "irregular", { structure: "대피로" }),
|
||||
section(58, "ep"),
|
||||
]),
|
||||
);
|
||||
// 옛 노선 목록(st-1 은 52.5 · 관 장부는 옛 자리 기준으로 이미 열쇠가 섬)
|
||||
out.outside = m.carryStationDrafts(P, R, R2);
|
||||
out.carriedKeys = keysOf("crossdesign", R2);
|
||||
out.carriedChoice = raw("crossdesign", R2)["grid:1"];
|
||||
out.carriedRead = [...m.crossDesignChoices(P, R2).keys()].sort((a, b) => a - b);
|
||||
out.carriedRock = m.readRockBoundarySession(P, R2);
|
||||
out.carriedShoulder = keysOf("shoulderext", R2);
|
||||
|
||||
/* 5. cross 단계 — 가짜 입력 · 같은 입력 0 · 측점 하나 고침 = 1 · 범위 밖 재사용 */
|
||||
const grid = [0, 20, 40, 60, 80, 100];
|
||||
const groundAt = (c) => 100 + 0.05 * c - 3 * Math.sin(c / 17);
|
||||
const base = {
|
||||
policy: input.policy,
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: grid.map((c, i) => ({ chainage_m: c, elevation_m: groundAt(c) + (i % 2 ? 0.8 : -0.6) })),
|
||||
chainage: Array.from({ length: 101 }, (_, i) => i),
|
||||
ground: Array.from({ length: 101 }, (_, i) => groundAt(i)),
|
||||
stations: grid.map((c) => ({ station_id: `s${c}`, chainage_m: c })),
|
||||
};
|
||||
const profile = m.buildAlignment(base, m.emptyEdits());
|
||||
const side = (c, sign) =>
|
||||
Array.from({ length: 24 }, (_, i) => ({
|
||||
offsetM: (i + 1) * 0.5,
|
||||
z: groundAt(c) + sign * 0.3 * (i + 1) * 0.5,
|
||||
}));
|
||||
const ground = {
|
||||
stations: grid.map((c, i) => ({
|
||||
key: i === 0 ? "end:BP" : i === grid.length - 1 ? "end:EP" : `grid:${i}`,
|
||||
chainageM: c,
|
||||
x: c,
|
||||
y: 0,
|
||||
azimuthDeg: 90,
|
||||
left: [0, 1],
|
||||
kind: i === 0 || i === grid.length - 1 ? "end" : "grid",
|
||||
id: null,
|
||||
ground: { centerZ: groundAt(c), left: side(c, 1), right: side(c, -1) },
|
||||
planRadiusM: null,
|
||||
curveOuterSide: null,
|
||||
curveWideningM: null,
|
||||
})),
|
||||
longitudinal: { chainageM: [], z: [] },
|
||||
};
|
||||
const samples = m.groundSamples(ground.stations[1]);
|
||||
out.samplesSorted = samples.every((s, i) => i === 0 || s.offset_m > samples[i - 1].offset_m);
|
||||
out.samplesCount = samples.length;
|
||||
const { stage, counts } = m.createCrossStage();
|
||||
const ctxOf = (cross, range, prev, prof = profile) => ({
|
||||
generation: 1,
|
||||
range,
|
||||
signal: new AbortController().signal,
|
||||
inputs: { route: null, alignment: null, cross, pipes: null, structures: null },
|
||||
out: { profile: prof, ground },
|
||||
before: {},
|
||||
prev,
|
||||
});
|
||||
const crossInput = { standard: input.standard, byKey: { "grid:2": { groundType: "soil" } } };
|
||||
const first = stage(ctxOf(crossInput, null, null));
|
||||
out.crossFirst = { ...counts(), keys: Object.keys(first.byKey).length };
|
||||
out.crossHasArea = Object.values(first.byKey).every((r) => Number.isFinite(r.cutAreaM2));
|
||||
stage(ctxOf(crossInput, null, first));
|
||||
out.crossSame = counts();
|
||||
const touched = { ...crossInput, byKey: { ...crossInput.byKey, "grid:3": { groundType: "soil" } } };
|
||||
const third = stage(
|
||||
ctxOf(touched, { sources: ["cross"], fromM: 60, toM: 60, keys: ["grid:3"] }, first),
|
||||
);
|
||||
out.crossOne = counts();
|
||||
out.crossOneChanged = third.byKey["grid:3"].detail.ground_type;
|
||||
const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curve_radii: {} });
|
||||
stage(ctxOf(touched, { sources: ["grade"], fromM: 20, toM: 60, keys: [] }, third, edited));
|
||||
out.crossGrade = counts();
|
||||
|
||||
/* 6. masshaul 단계 — 계열 · 흙 배분 입력 */
|
||||
const conversion = {
|
||||
soil: { loose: 1.25, compacted: 0.9 },
|
||||
ripping_rock: { loose: 1.5, compacted: 1.2 },
|
||||
blasting_rock: { loose: 1.65, compacted: 1.35 },
|
||||
};
|
||||
const massStage = m.createMassHaulStage(() => ({ conversion }));
|
||||
const mass = massStage({ ...ctxOf(crossInput, null, null), out: { profile, cross: first } });
|
||||
out.massSeries = mass.series.map((s) => s.basis);
|
||||
out.massHaulInput = Array.isArray(mass.haulInput?.points);
|
||||
|
||||
/* 7. haul 단계 — 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로 */
|
||||
const posts = [];
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
if (init.signal?.aborted) throw new Error("aborted");
|
||||
posts.push(1);
|
||||
return { ok: true, json: async () => ({ haul_plan: { n: posts.length } }) };
|
||||
};
|
||||
const haul = m.createHaulStage(P, () => R);
|
||||
const haulCtx = (masshaul, prev, signal = new AbortController().signal) => ({
|
||||
...ctxOf(crossInput, null, prev),
|
||||
signal,
|
||||
out: { masshaul },
|
||||
});
|
||||
const plan1 = await haul(haulCtx(mass, null));
|
||||
const plan2 = await haul(haulCtx(mass, plan1));
|
||||
const other = massStage({
|
||||
...ctxOf(crossInput, null, null),
|
||||
out: { profile: edited, cross: third },
|
||||
});
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
const plan3 = await haul(haulCtx(other, plan1, aborted.signal));
|
||||
out.haul = { posts: posts.length, plan1, plan2, plan3 };
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""344-3 측점 열쇠 거울 — `common_util_station_key.py` ↔ `common_util_station_key.ts` 같은 열쇠.
|
||||
|
||||
일반 grid:k(누가거리 ÷ 간격 반올림 · 0.5 는 위로) · 관 pipe:<id> · 구조물 structure:<id> ·
|
||||
시종점 end:BP/EP · id 모름 at:<0.01 m> · 옛 열쇠(누가거리 글) · at: 의 누가거리.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from common_util.common_util_station_key import ( # noqa: E402
|
||||
chainage_key,
|
||||
loose_chainage,
|
||||
station_key,
|
||||
)
|
||||
|
||||
TS = PROJECT_ROOT / "common_util" / "common_util_station_key.ts"
|
||||
TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript"
|
||||
|
||||
CASES = [
|
||||
("bp", 0.0, 20.0, None, "structure"),
|
||||
("ep", 812.345, 20.0, None, "structure"),
|
||||
("regular", 40.0, 20.0, None, "structure"),
|
||||
("regular", 50.0, 20.0, None, "structure"), # 2.5 → 3(위로)
|
||||
("regular", 69.996, 20.0, None, "structure"),
|
||||
("regular", 40.0, None, None, "structure"),
|
||||
("irregular", 85.054, 20.0, "pipe-s3", "pipe"),
|
||||
("irregular", 85.054, 20.0, "st-7", "structure"),
|
||||
("irregular", 85.054, 20.0, None, "structure"),
|
||||
("irregular", 1.005, 20.0, None, "structure"),
|
||||
("pipe", 12.344, 20.0, "pipe-s1", "structure"),
|
||||
("structure", 12.345, 20.0, None, "structure"),
|
||||
]
|
||||
LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"]
|
||||
|
||||
SCRIPT = r"""
|
||||
const ts = require(process.argv[1]);
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||||
const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } };
|
||||
const js = ts.transpileModule(src, opts).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", js)(box.exports, box);
|
||||
const m = box.exports;
|
||||
const input = JSON.parse(process.argv[3]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
keys: input.cases.map((c) => m.stationKey(c[0], c[1], c[2], c[3], c[4])),
|
||||
chainage: input.cases.map((c) => m.chainageKey(c[1])),
|
||||
loose: input.loose.map((k) => m.looseChainage(k)),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def test_파이썬과_TS_열쇠_같음():
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
"-e",
|
||||
SCRIPT,
|
||||
str(TYPESCRIPT),
|
||||
str(TS),
|
||||
json.dumps({"cases": CASES, "loose": LOOSE}),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = json.loads(proc.stdout)
|
||||
assert out["keys"] == [station_key(*case) for case in CASES]
|
||||
assert out["chainage"] == [chainage_key(case[1]) for case in CASES]
|
||||
assert out["loose"] == [loose_chainage(key) for key in LOOSE]
|
||||
|
||||
|
||||
def test_열쇠_규칙():
|
||||
assert station_key("bp", 0.0, 20.0) == "end:BP"
|
||||
assert station_key("ep", 812.3, 20.0) == "end:EP"
|
||||
assert station_key("regular", 50.0, 20.0) == "grid:3"
|
||||
assert station_key("irregular", 85.054, 20.0, "pipe-s3", "pipe") == "pipe:pipe-s3"
|
||||
assert station_key("irregular", 85.054, 20.0) == "at:85.05"
|
||||
assert loose_chainage("85.05") == 85.05 and loose_chainage("grid:4") is None
|
||||
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계.
|
||||
|
||||
못박는 것
|
||||
- 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김.
|
||||
- 쓰기 = 측점 열쇠(grid · pipe · structure) · 부르는 쪽은 누가거리로 읽음 · 암 경계 저장소도 같음.
|
||||
- 구조물 id 를 아직 모를 때(첫 열기)도 쓴 때 자리로 그 측점 고침을 읽음.
|
||||
- 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 ·
|
||||
단면 유형 · 측구 방향 · 포장 · 노견 확장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
|
||||
- cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용.
|
||||
- masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로.
|
||||
|
||||
Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을
|
||||
부름.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
|
||||
|
||||
FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
work = tmp_path_factory.mktemp("b344")
|
||||
build = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../resources/tester/helper_344_3_entry.ts",
|
||||
"--outDir",
|
||||
str(work),
|
||||
"--logLevel",
|
||||
"error",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
)
|
||||
assert build.returncode == 0, build.stderr
|
||||
bundle = work / "bundle.mjs"
|
||||
shutil.copy(work / "helper_344_3_entry.js", bundle)
|
||||
policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"]
|
||||
source = work / "input.json"
|
||||
source.write_text(
|
||||
json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_344_3_station_keys.mjs"), str(bundle), str(source)],
|
||||
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_옛_초안은_읽을_때_측점_열쇠로_옮김(out):
|
||||
assert out["legacyRead"] == [20, 45.05, 52.5, 60]
|
||||
assert out["legacyKeys"] == ["99.00", "grid:1", "grid:3", "pipe:pipe-s1", "structure:st-1"]
|
||||
|
||||
|
||||
def test_쓰기는_측점_열쇠_읽기는_누가거리(out):
|
||||
assert out["writeKey"] is True
|
||||
assert out["shoulderKeys"] == ["grid:2"]
|
||||
assert out["rockKeys"] == ["grid:1"]
|
||||
assert out["rockLoaded"] == {"20.00": -0.8}
|
||||
assert out["rockSession"] == {"20.00": -0.8}
|
||||
|
||||
|
||||
def test_구조물_id_모를_때도_쓴_자리로_읽음(out):
|
||||
assert out["hintKeyNow"] == "at:52.50"
|
||||
assert out["hintRead"] is True
|
||||
|
||||
|
||||
def test_노선_바뀜_측점_열쇠로_넘김(out):
|
||||
assert out["outside"] == 2 # 60 m(노선이 짧아짐) · 99 m(옛 노선에도 없던 옛 초안)
|
||||
assert out["carriedKeys"] == ["grid:1", "grid:2", "pipe:pipe-s1", "structure:st-1"]
|
||||
assert out["carriedRead"] == [20, 40, 47, 55] # 관 · 구조물은 옮긴 자리로
|
||||
assert out["carriedChoice"] == {
|
||||
"ground_type": "soil",
|
||||
"ditch_type": "l_type",
|
||||
"ditch_choice": False,
|
||||
"two_stage_slope": True,
|
||||
}
|
||||
assert out["carriedRock"] == {"20.00": -0.8}
|
||||
assert out["carriedShoulder"] == []
|
||||
|
||||
|
||||
def test_cross_단계_국부_연산(out):
|
||||
assert out["samplesSorted"] is True and out["samplesCount"] == 49
|
||||
assert out["crossFirst"] == {"computed": 6, "reused": 0, "keys": 6}
|
||||
assert out["crossHasArea"] is True
|
||||
assert out["crossSame"] == {"computed": 0, "reused": 6}
|
||||
assert out["crossOne"] == {"computed": 1, "reused": 5}
|
||||
assert out["crossOneChanged"] == "soil"
|
||||
assert out["crossGrade"]["computed"] <= 3 and out["crossGrade"]["reused"] >= 3
|
||||
|
||||
|
||||
def test_masshaul_haul_단계(out):
|
||||
assert out["massSeries"] == ["cross", "longitudinal"]
|
||||
assert out["massHaulInput"] is True
|
||||
assert out["haul"] == {"posts": 1, "plan1": {"n": 1}, "plan2": {"n": 1}, "plan3": {"n": 1}}
|
||||
@@ -64,7 +64,8 @@ def test_등록표_밖에서_날문자열_세션_키를_안_쓴다():
|
||||
def test_옛_암경계_키가_코드에_안_남아_있다():
|
||||
"""실사고 자리 못박기 — 읽는 쪽이 등록표를 거치는지."""
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert 'readState<Record<string, number>>("rockb"' in persist, "등록표를 안 거침"
|
||||
# 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음
|
||||
assert 'readKeyed<number>("rockb", projectId, routeId)' in persist, "등록표를 안 거침"
|
||||
# 옛 키가 **코드로** 되살아나는 것만 막는다 — 주석에 사연으로 적힌 것은 남겨 둔다.
|
||||
code = "\n".join(
|
||||
line for line in persist.splitlines() if not line.lstrip().startswith(("*", "//", "/*"))
|
||||
|
||||
@@ -202,7 +202,8 @@ def test_암경계선은_등록표_키로_읽는다():
|
||||
persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'readState<Record<string, number>>("rockb"' in persist
|
||||
# 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음
|
||||
assert 'readKeyed<number>("rockb", projectId, routeId)' in persist
|
||||
|
||||
|
||||
# ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ───────────────────
|
||||
|
||||
Reference in New Issue
Block a user