fix(472 덧 B06 헛 초안 · 지반): B06 열기만 해도 세션 표준 횡단 초안(std-cross)이 생기던 흠 — 패널이 열 때 config 를 · 설정값 받기가 저장분을 베껴 씀 → 안 씀 · 계산 = 세션 → 저장분(section-context) → config · 같은 값이면 칸 지움 · 카드 단추가 일곱 칸 통째로 실어 안 바꾼 지반(ripping_rock) · 단면 유형을 사용자 선택으로 박던 흠 → 바꾼 칸만(앞서 바꾼 칸에 얹음 · [저장] patch · 노선 넘김도 빈 칸 안 실음) · 소단 사본 berm = 떠남 확인 밖 · 카드 「토사」 = 단추 꺼짐 = 상세 ripping_rock(값 같음) · 시험 test_472_open_no_draft(옛 코드 빨강 9) · 옛 씨앗 단언 셋 고침(364_6 · stored_standard · user_values) · 위키 세션 칸 · 규칙 · B06 조작 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 16:41:03 +09:00
co-authored by Claude Opus 5.5
parent b14de92621
commit 8ebb8258f9
19 changed files with 460 additions and 93 deletions
+2 -1
View File
@@ -3,7 +3,7 @@
* B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때
* 공용 물음(`ui_template_leave_dialog` · [취소] [폐기] [저장]).
*
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) ·
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김 · 소단 사본 = 구조물 파생) ·
* 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
* 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄:
* [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음.
@@ -31,6 +31,7 @@ const NOT_EDITS = new Set<StateName>([
"structure-pick",
"structure-type-cache",
"std-cross",
"berm", // 소단 사본 — 열 때 시설 줄이 구조물에서 씀(272 · 고침은 `structures` 칸)
]);
const OTHER_PAGE = /^b0[89]-/;
@@ -23,7 +23,10 @@ import {
sectionConfirmBody,
type StandardCrossSection,
} from "../B06_Section/B06_Section_Api_Fetch";
import { readStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel";
import {
readStandardCrossSession,
readStoredStandard,
} from "../B06_Section/B06_Section_UI_Standard_Panel";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import type { SavePart } from "../A00_Common/design/design_save_api";
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
@@ -161,7 +164,9 @@ export function routeCarryPart(
projectId: string,
fromRouteId: number,
): { carry: Record<string, unknown>; cross: SavePart } {
const standard = readStandardCrossSession(projectId) as StandardCrossSection | null;
// 세션 고침 → 저장분(세션에 안 베낌 · 272) — 서버 새 측점 기본 설계가 저장분 표준으로 섬
const standard = (readStandardCrossSession(projectId) ??
readStoredStandard(projectId)) as StandardCrossSection | null;
const uphill = Object.keys(readState("uphill", projectId) ?? {}).length;
return {
carry: routeCarryInputs(projectId, fromRouteId),
+7 -29
View File
@@ -3,23 +3,24 @@
* 종횡단 설정값(`GET …/sections/context`)의 **세션 캐시 창구** — 화면 쪽.
*
* `B06_Section_Api_Fetch.ts` 에서 갈라냄(472-2) — 서버 Node 묶음이 Api_Fetch 를 끌어도
* 세션(`b_page_state`) · 되돌리기 사진첩(`Profile_History`)을 함께 끌지 않게.
* 세션(`b_page_state`)을 함께 끌지 않게.
* ========================================================================== */
import { clearState, readState, stateKey, writeState } from "../A00_Common/b_page_state";
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { requestSectionContext } from "./B06_Section_Api_Fetch";
import type { SectionContextResponse } from "./B06_Section_Api_Types";
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
/** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다
* (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */
* (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다.
* 저장된 표준 횡단면(`stored_standard_cross_section`)은 이 칸에서 바로 읽음(`readStoredStandard`) —
* 초안 칸(`std-cross`)에 베끼지 않음(272 · 값이 저장분과 같으면 초안 칸 없음). */
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
const cached = readState<SectionContextResponse>("section-context", projectId);
if (cached) return seedStandardCross(projectId, cached);
if (cached) return cached;
const fresh = await requestSectionContext(projectId);
writeState("section-context", fresh, projectId);
return seedStandardCross(projectId, fresh);
return fresh;
}
/** 서버가 지금 보는 노선 id — 세션 캐시를 건너뛰고 묻는다. [저장] · [확정] 앞에서 화면이 쥔
@@ -30,29 +31,6 @@ export async function fetchCurrentRouteId(projectId: string): Promise<number | n
return fresh.route_id;
}
/**
* 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07).
*
* 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만
* 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해
* 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭
* **0.9m**, config 기본값은 **3%**·**0.69m**).
*
* 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가
* 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다.
*/
function seedStandardCross(
projectId: string,
context: SectionContextResponse,
): SectionContextResponse {
const stored = context.stored_standard_cross_section;
if (stored && readState<unknown>("std-cross", projectId) === null) {
writeState("std-cross", stored, projectId);
seedPageHistory(stateKey("std-cross", projectId)); // 씨앗 — 되돌리기 걸음 아님(364-1)
}
return context;
}
export function clearSectionContextCache(projectId: string): void {
clearState("section-context", projectId);
}
@@ -14,10 +14,10 @@
import { chainageKey } from "@util/common_util_station_key";
import { readKeyed, writeKeyed } from "./B06_Section_Station_Keys";
/** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */
/** 카드에서 고를 수 있는 값 — 서버 `CrossDesignRequest` 와 같은 이름 · **사용자가 바꾼 칸만**(272). */
export interface CrossDesignChoice {
ground_type: string;
section_mode: string;
ground_type?: string;
section_mode?: string;
ditch_side?: string | null;
ditch_type?: string | null;
paved?: boolean;
@@ -42,6 +42,15 @@ export function writeCrossDesignChoice(
writeKeyed("crossdesign", projectId, routeId, new Map([[at, choice]]), [at]);
}
/** 한 측점의 지금 선택값(없으면 undefined) — 바꾼 칸을 얹을 바탕(272). */
export function readCrossDesignChoice(
projectId: string,
routeId: number,
chainageM: number,
): CrossDesignChoice | undefined {
return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? undefined;
}
/** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */
export function crossDesignChoices(
projectId: string | null,
+3 -1
View File
@@ -54,6 +54,7 @@ import {
effectiveStandardCross,
readRockBoundaryDefault,
readStandardCrossSession,
readStoredStandard,
} from "./B06_Section_UI_Standard_Panel";
import {
buildAlignment,
@@ -501,7 +502,8 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
projectId,
routeId,
edits,
readStandardCrossSession(projectId) ?? undefined,
// 세션 고침 → 저장분(세션에 안 베낌 · 272) — 둘 다 없으면 서버 config
readStandardCrossSession(projectId) ?? readStoredStandard(projectId) ?? undefined,
{
fullDesigns: true,
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
+3 -5
View File
@@ -411,11 +411,9 @@ export function routeCarryPatches(input: RouteCarryInput): {
// 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 옛 `sessionOverlay` 와 같은 칸(445 H5)
const overlay: CrossSectionPatch[] = [];
byChainage(read<CardChoice>("crossdesign")).forEach((choice, chainage) => {
const patch: Record<string, unknown> = {
chainage_m: chainage,
ground_type: choice.ground_type,
section_mode: choice.section_mode,
};
const patch: Record<string, unknown> = { chainage_m: chainage };
if (choice.ground_type !== undefined) patch.ground_type = choice.ground_type;
if (choice.section_mode !== undefined) patch.section_mode = choice.section_mode;
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
+23 -16
View File
@@ -104,17 +104,18 @@ const DITCH_TYPE_OPTIONS: Array<[DitchType, keyof typeof ui_locales]> = [
["l_type", "B06_Design_DitchType_LType"],
];
/** 카드 고름 — **바꾼 칸만** 실림(272 · 안 바꾼 지반 · 단면 유형을 사용자 선택으로 박지 않음). */
export interface CrossDesignChange {
ground_type: GroundType;
section_mode: SectionMode;
ditch_side: DitchSide | null;
ditch_type: DitchType;
paved: boolean;
ground_type?: GroundType;
section_mode?: SectionMode;
ditch_side?: DitchSide | null;
ditch_type?: DitchType;
paved?: boolean;
/** 암 지반 2단계 경사(암반 경계 아래=암, 위=토사) 적용 여부. 기본 true, 토글로 해제. */
two_stage_slope: boolean;
two_stage_slope?: boolean;
/** 측구를 둘지 **사용자가 정한 선택**. `null` = 자동 판정.
* ⚠ 결과(`design.ditch_enabled` = 실제 섰나)와 **다른 값**이다(2026-09-09 정리). */
ditch_choice: boolean | null;
ditch_choice?: boolean | null;
/** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */
shoulder_extend?: { side: "left" | "right"; valueM: number };
/** 소단 높이(194-1) — 있으면 그 쪽 소단 깊이(노면 끝 아래 m)만 바꾸는 변경. */
@@ -274,19 +275,25 @@ export function buildDesignControls(
// 경사 방향 선택이 필요한 경우 = 양절(both_cut)·양성(both_fill). 편절은 절토측 자동.
// 양성은 측구가 없어도 노면 기울기 방향(생략된 측구 방향)을 여기서 지정한다(N-2-3).
const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill";
const choiceOf = (): Required<Omit<CrossDesignChange, "shoulder_extend" | "berm_depth">> => ({
ground_type: state.ground,
section_mode: state.mode,
ditch_side: needsDitch() ? (state.ditch ?? "left") : null,
ditch_type: state.ditchType,
paved: state.paved,
two_stage_slope: state.twoStage,
ditch_choice: state.ditchEnabled,
});
const opened = choiceOf(); // 카드가 선 때 — 이와 다른 칸만 보냄(272)
const emit = (): void => {
if (!state.ground || !state.mode) return;
// L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다.
if (!isRock(state.ground)) state.ditchType = "standard";
onChange(section.chainage_m, {
ground_type: state.ground,
section_mode: state.mode,
ditch_side: needsDitch() ? (state.ditch ?? "left") : null,
ditch_type: state.ditchType,
paved: state.paved,
two_stage_slope: state.twoStage,
ditch_choice: state.ditchEnabled,
});
const now = choiceOf();
const changed = Object.fromEntries(
Object.entries(now).filter(([key, value]) => opened[key as keyof typeof opened] !== value),
) as CrossDesignChange;
if (Object.keys(changed).length) onChange(section.chainage_m, changed);
};
// 지반유형: 제목행 배치용으로 분리 반환(D-6). 「토사」 토글 하나 — 끄면 암이다(2026-09-07).
+20 -7
View File
@@ -11,9 +11,11 @@
* ========================================================================== */
import {
readCrossDesignChoice,
writeBermDepth,
writeCrossDesignChoice,
writeShoulderExtend,
type CrossDesignChoice,
} from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements";
import { L } from "./B06_Section_UI_Page_Common";
@@ -41,6 +43,17 @@ export interface DesignSyncController {
applyPanelToAll: () => Promise<void>;
}
/** 카드 고름 칸 — 세션 `crossdesign` 에 담는 것(바꾼 칸만 · 272). */
const CHOICE_KEYS = [
"ground_type",
"section_mode",
"ditch_side",
"ditch_type",
"paved",
"two_stage_slope",
"ditch_choice",
] as const satisfies ReadonlyArray<keyof CrossDesignChoice & keyof CrossDesignChange>;
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
const { projectId } = ctx;
// 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세 · 칸 크기 그림(173-4)
@@ -85,14 +98,14 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
// 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
// 바꾼 칸만(272) — 같은 측점 앞서 바꾼 칸에 얹음
const picked = Object.fromEntries(
CHOICE_KEYS.filter((key) => change[key] !== undefined).map((key) => [key, change[key]]),
) as CrossDesignChoice;
if (!Object.keys(picked).length) return;
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? null,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
...readCrossDesignChoice(projectId, currentRouteId, chainageM),
...picked,
});
// (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다.
await reconcileStaleDesigns({ force: true });
+2 -2
View File
@@ -324,8 +324,8 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
// 유일한 길이다(2026-09-06 사용자 확정: 버튼을 누를 때 서버가 저장하지 않는다).
crossDesignChoices(ctx.projectId, ctx.routeId()).forEach((choice, chainage) => {
const patch = patchFor(chainage);
patch.ground_type = choice.ground_type;
patch.section_mode = choice.section_mode;
if (choice.ground_type !== undefined) patch.ground_type = choice.ground_type;
if (choice.section_mode !== undefined) patch.section_mode = choice.section_mode;
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
+32 -11
View File
@@ -21,11 +21,10 @@ import {
clearState,
readState,
readStateRaw,
stateKey,
writeState,
writeStateRaw,
} from "../A00_Common/b_page_state";
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
import type { SectionContextResponse } from "./B06_Section_Api_Types";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements";
import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram";
@@ -75,11 +74,26 @@ export function readRockBoundaryDefault(projectId: string): number | null {
}
/**
* 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**.
* 이 프로젝트에 **저장된** 표준 횡단면(`sections/context` 의 `stored_standard_cross_section`) —
* 세션 칸에 베껴 두지 않고 결과 칸(`section-context`)에서 바로 읽음(272 · 값이 저장분과 같으면 초안 칸 없음).
*/
export function readStoredStandard(projectId: string): StandardCrossSection | null {
return (
readState<SectionContextResponse>("section-context", projectId)
?.stored_standard_cross_section ?? null
);
}
/**
* 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값 → 저장분 → config 기본값**.
* 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다.
*/
export function effectiveStandardCross(projectId: string): StandardCrossSection | null {
return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId);
return (
readStandardCrossSession(projectId) ??
readStoredStandard(projectId) ??
readStandardDefaults(projectId)
);
}
/** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */
@@ -284,10 +298,17 @@ export function createStandardPanel(
): StandardPanelController {
// 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다.
rememberStandardDefaults(projectId, defaults);
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
const sessionValue = readStandardCrossSession(projectId);
// 세션값 우선, 없으면 저장분 → config 기본값. defaults는 복원 기준으로 보존한다.
// 세션 칸 = 저장분(없으면 config)과 다를 때만 — 같으면 칸 없음(272 · 열기만 해서 초안이 생기지 않게).
const baseline = (): string => JSON.stringify(readStoredStandard(projectId) ?? defaults);
let sessionValue = readStandardCrossSession(projectId);
if (sessionValue && JSON.stringify(sessionValue) === baseline()) {
clearStandardCrossSession(projectId); // 옛 판이 열 때 베껴 둔 같은 값
sessionValue = null;
}
const hadSession = sessionValue !== null;
const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults);
const state: StandardCrossSection =
sessionValue ?? cloneDefaults(readStoredStandard(projectId) ?? defaults);
const root = document.createElement("div");
root.className = "b06-std";
@@ -305,7 +326,7 @@ export function createStandardPanel(
// (`handleDesignChange` 가 패널 값을 실어 보냄). 알려 주지 않으면 한 화면에 옛 값과 새 값이
// 섞여 서고, 사용자는 왜 그 측점만 값이 달라졌는지 알 길이 없다.
// ⓘ 실측 예 — 측점 20.0m 절토 계 3.33㎡(저장분) ↔ 3.90㎡(패널 초안: 암 측구 상폭 0.69→0.9).
let applied = JSON.stringify(defaults);
let applied = baseline();
const pending = document.createElement("p");
pending.className = "b06-std__pending";
pending.textContent =
@@ -320,7 +341,8 @@ export function createStandardPanel(
};
const persist = (): void => {
writeSession(projectId, state);
if (JSON.stringify(state) === baseline()) clearStandardCrossSession(projectId);
else writeSession(projectId, state);
showPending();
};
@@ -416,8 +438,7 @@ export function createStandardPanel(
body.replaceChildren(buildDetails());
};
// 열기 · 복원 · 불러오기 = 그룹 값 그대로(서버가 그 값으로 셈) — 공통 칸은 고칠 때만 펼침(364-6).
persist();
if (!hadSession) seedPageHistory(stateKey("std-cross", projectId)); // 비었던 칸 채움 = 씨앗(364-1)
// 열 때는 세션에 안 씀 — 고친 값이 없으면 칸 없음(272).
renderBody();
/** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */
@@ -16,14 +16,14 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
| 조작 | 처음 놓이는 곳 | 브라우저 즉시 셈(모듈) | 서버 뒤 셈(API) | [저장]·[확정] 때 | 사본 | 위치(파일:줄) |
|---|---|---|---|---|---|---|
| 카드 단추(지반 · 단면 · 측구 · 포장 · 2단) | 세션 `crossdesign` 만(메모리 상세 설계 안 덮음) | 횡단 다시 셈 · 카드 = 다시 셈 알림(`onCrossDesignsUpdated`)이 그림 | — | 횡단 패치 · 파이썬 설계선 다시 | 5 | `B06_Section_UI_Cross_Design.ts:278-289` · `B06_Section_UI_Page_Design_Sync.ts:59-104` · `B06_Section_Cross_Design_Session.ts:44-52` |
| 카드 단추(지반 · 단면 · 측구 · 포장 · 2단) | 세션 `crossdesign` 만 · 바꾼 칸만(앞서 바꾼 칸에 얹음 · 안 바꾼 지반 · 단면을 안 박음) · 메모리 상세 설계 안 덮음 | 횡단 다시 셈 · 카드 = 다시 셈 알림(`onCrossDesignsUpdated`)이 그림 | — | 횡단 패치 · 파이썬 설계선 다시 | 5 | `B06_Section_UI_Cross_Design.ts:278-289` · `B06_Section_UI_Page_Design_Sync.ts:59-104` · `B06_Section_Cross_Design_Session.ts:44-52` |
| 설계선 쉬프트(노견 확장) | 세션 `shoulderext` | 횡단 다시 셈 | — | 횡단 패치 | 5 | `B06_Section_UI_Cross_Design_Pick.ts:402` · `B06_Section_UI_Page_Design_Sync.ts:71-76` · `B06_Section_Cross_Refresh.ts:393-397` |
| 소단 구간(구조물) | 구조물 → 시설 줄 `syncBerm` 이 세션 `berm`(같으면 안 씀 · 쓰면 cross 한 번 · B05 만 열어도) | 횡단 다시 셈 · 사본 없으면 저장분 `design.berm` | — | 서버가 `structures.json` 에서(`load_berm_spans`) | 6 | `design_facility_lane.ts:151-166` · `B06_Section_Cross_Refresh.ts:362-368 · 521-522` · `B06_Section_Engine_Structures_Wall.py:124` |
| 소단 깊이 ▲▼ | 세션 `bermdepth` | 횡단 다시 셈 | — | 횡단 패치 | 5 | `B06_Section_UI_Cross_Design_Pick.ts:408` · `B06_Section_UI_Page_Design_Sync.ts:78-83` · `B06_Section_Cross_Refresh.ts:513-519` |
| 암선 · 암 경사 | 세션 칸 `rockb` · `cutslope`(값 주인 · 읽을 때마다 칸 · 메모리 사본 없음 · ↶ ↷ 가 칸을 바꾸면 카드 · 면적 다시) | 횡단 다시 셈(암 측점 2단 무릎 · 면적) | — | 횡단 패치 · 파이썬 설계선 | 5 | `B06_Section_UI_Cross_Line_Cell.ts:161 · 166` · `B06_Section_UI_Cross_CutSlope.ts:94 · 114` · `B06_Section_Cross_Refresh.ts:229-243 · 306` |
| 조정창(흙막이 · 집수정 · 유입 · 경간 · 세월교 · BOX · 연동) | 세션 칸(`revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` · 값 주인 · 메모리 사본 없음 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells`) · 빈 칸 = 칸 지움 · 관 옵션 = `culvertopt` | 카드 다시 · 구조물 면적 · 누가토량 · ↶ ↷ 가 칸을 바꾸면 상세 얹기(고치기 전 칸 + 지금 칸) · 카드 · 면적 다시 | — | 횡단 패치 · Node 구조물 면적 · 관 옵션 = 관 옵션 차례 `pipe_points` PUT | 6 | `B06_Section_UI_Page_Station_Controls.ts` · `B06_Section_Session_Cells.ts` · `B06_Section_Section_Adjusts.ts` noteAdjustBase · withDraftAdjusts · `design_view_cross.ts` onRestore · `logic/B06_Section_UI_Page_Patches.ts:41-136` |
| 반폭 ◀▶ | 측점 = 세션 `crossw` · 전역 = `cross-display` | 표시만 · 횡단 전체 강제 다시 셈(헛일) | — | 측점 반폭만 패치 · `cross-display` 안 감 | 3 | `B06_Section_UI_Cross_View.ts:664-670` · `design_view_cross.ts:251-263 · 448-457` · `B06_Section_Display_Width.ts:20-23` |
| 표준횡단 · [전체 반영] | 세션 `std-cross`([폐기]에도 안 지움) | 전 측점 다시 셈(지문에 표준 포함) | — | `standard_cross_section` 으로 · 서버 설계선 다시 | 3 | `B06_Section_UI_Standard_Panel.ts:303-325 · 450-458` · `B06_Section_Api_Fetch.ts:101-111` · `B06_Section_Router_Confirm.py:157-162` |
| 표준횡단 · [전체 반영] | 세션 `std-cross` — 저장분(없으면 config)과 다를 때만 · 열기만 = 칸 없음 · 계산 = 세션 → 저장분 → config([폐기]에도 안 지움) | 전 측점 다시 셈(지문에 표준 포함) | — | `standard_cross_section` 으로 · 서버 설계선 다시 | 3 | `B06_Section_UI_Standard_Panel.ts:303-325 · 450-458` · `B06_Section_Api_Fetch.ts:101-111` · `B06_Section_Router_Confirm.py:157-162` |
## 시설 · 평면 · 저장 밖
+1
View File
@@ -33,6 +33,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| B05 · B06 캐시 = 한 벌 · [저장] 한 번에 함께 나감 | `design_shared.ts:395-448` · `design_save.ts:237` | — |
| 관 · 구조물 주인 = 가게 칸 하나 — 화면 사본은 가게 알림으로 맞춤(제 쓰기는 같은 값 · 남 고침은 다시 실음) | `design_view_profile.ts` 가게 구독 · `design_view_cross.ts` onShop · `B06_Section_UI_Page_Structures_Panel.ts` seen | `test_472_5_shop_owner` |
| 정본 자리 = 값을 낳은 주인 — 노선 시설 = `pipe_points.json` · `structures.json` · 횡단 값 = 횡단 패치 | `B05_Profile_Api_Pipes_Draft.ts:30-37` · `B05_Profile_Structures_Router.py:386-405` · `B06_Section_Section_Store.ts:449` | — |
| 열기만 = 초안 칸 없음 — 값이 저장분과 같으면 칸 없음(표준 횡단 = 결과 칸 `section-context` 에서 읽음) · 카드 고름 = 바꾼 칸만 · 소단 사본 = 떠남 확인 밖 | `B06_Section_UI_Standard_Panel.ts` effectiveStandardCross · persist · `B06_Section_UI_Cross_Design.ts` emit · `B06_Section_UI_Page_Design_Sync.ts` handleDesignChange · `design_leave.ts` NOT_EDITS | `test_472_open_no_draft` |
| 관 자리 읽기 한 벌 = 지금 노선에 얹은 관 지점(측점 계획 · 관 지점 GET · 서버 상세 형상 얹기 · 카드 이름 · 세월교 노면 하강 · 물넘이포장 구간 · B07 관 글 · 화면) — 지문 어긋난 옛 저장분도 같은 자리 · 관 파일 누가거리를 그대로 읽지 않음 | `B05_Profile_Engine_Sections.py:249` route_pipe_points | `test_472_d_pipe_route_attach` · `test_472_d_route_pipe_readers` |
## 바꿀 때 할 일
@@ -22,12 +22,12 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common
| `pipes` · `structures` · `plus-stations` | `:136` · `:127` · `:129` | 시설 줄 · 배수판 · B06 패널 · 추가 측점 창 | pipes · structures · plusStations |
| `culvertopt` · `kindreset` | `:154` · `:152` | 관 조정창 옵션 예약 · 종류 바꾼 관 | culvertOptions · cross(structure_reset) |
| `uphill` | `:131` | 상단측 | uphill(노선 바뀌면 cross 뒤) |
| `crossdesign` · `shoulderext` · `bermdepth` | `:196` · `:198` · `:200` | 카드 단추 · 설계선 쉬프트 · 소단 깊이 | cross |
| `crossdesign` · `shoulderext` · `bermdepth` | `:196` · `:198` · `:200` | 카드 단추(바꾼 칸만 · 앞서 바꾼 칸에 얹음) · 설계선 쉬프트 · 소단 깊이 | cross |
| `rockb` · `cutslope` · `crossw` | `:158` · `:162` · `:184` | 암선 · 암 경사 · 측점 반폭 | cross |
| `revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` | `:185-192` | 조정창 칸(값 주인 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells` · 메모리 사본 없음 · 빈 칸 = 칸 지움) | cross |
| `structure-notes` · `structure-note-cads` · `detail-table` | `:121` · `:123` · `:125` | [상세] 모달 | cross |
| `std-cross` | `:175` | 표준횡단 패널([폐기] · [저장]에도 안 지움 · [초기화]만) | cross(`standard_cross_section`) |
| `berm` | `:166` | 시설 줄 `syncBerm` 만(구조물 파생) | 안 감 — 서버가 `structures.json` 에서 |
| `std-cross` | `:175` | 표준횡단 패널 — 저장분(`section-context` 의 `stored_standard_cross_section`) · 없으면 config 와 다를 때만 · 같으면 칸 없음([폐기] · [저장]에도 안 지움 · [초기화]만) | cross(`standard_cross_section`) |
| `berm` | `:166` | 시설 줄 `syncBerm` 만(구조물 파생 · 열 때 씀 · 떠남 확인 밖) | 안 감 — 서버가 `structures.json` 에서 |
| `cross-display` | `:177` | 전역 표시 반폭 | 안 감 |
| `selection` · `structure-pick` · `pipe-ids` · `pipe-option-queue` · `structure-type-cache` | `:148` · `:138` · `:144` · `:146` · `:150` | 고름 · 관 id 장부 · 옵션 넘김 · 종류 캐시 | 안 감 · `structure-type-cache` 는 저장 뒤 비움 |
@@ -0,0 +1,195 @@
/* 472 덧(요구 272 C · D) 헬퍼 — 진짜 모듈(helper_472_open_no_draft_entry.ts)로
* ① 표준 횡단 패널 · 설정값 받기 = 세션 초안 칸 안 씀(같은 값이면 칸 없음) · 계산은 저장분
* ② 카드 단추 = 바꾼 칸만 보냄 · 세션 고름은 앞서 바꾼 칸에 얹음
* node helper_472_open_no_draft.mjs <묶음.mjs> <표준 횡단 고정 자료.json>
* 짝 = `test_472_open_no_draft.py` · 표준출력 JSON 한 벌.
*/
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(),
};
};
/** 작은 가짜 요소 — 자식 · 듣는 함수 · 글만 들고 나머지는 빈 함수. */
function element(tag) {
const base = {
tagName: String(tag).toUpperCase(),
children: [],
listeners: {},
attrs: {},
style: {},
dataset: {},
textContent: "",
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
append(...kids) {
base.children.push(...kids);
},
prepend(...kids) {
base.children.unshift(...kids);
},
insertBefore(kid) {
base.children.push(kid);
},
replaceChildren(...kids) {
base.children = kids;
},
addEventListener(type, fn) {
(base.listeners[type] ??= []).push(fn);
},
setAttribute(k, v) {
base.attrs[k] = String(v);
},
getAttribute: (k) => base.attrs[k] ?? null,
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
clientWidth: 0,
offsetWidth: 0,
scrollWidth: 0,
};
return new Proxy(base, {
get: (t, k) => (k in t ? t[k] : typeof k === "string" ? () => undefined : undefined),
});
}
const noop = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : noop),
apply: () => noop,
construct: () => noop,
});
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
globalThis.document = new Proxy(
{ createElement: element, createElementNS: (_ns, tag) => element(tag) },
{ get: (t, k) => (k in t ? t[k] : noop) },
);
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
};
globalThis.getComputedStyle = () => ({ gap: "0" });
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
globalThis.addEventListener = () => undefined;
globalThis.removeEventListener = () => undefined;
globalThis.fetch = async () => new Response("{}", { status: 404 });
const m = await import(pathToFileURL(process.argv[2]).href);
const draftKeys = () =>
Object.keys(
Object.fromEntries([...Array(sessionStorage.length)].map((_, i) => [sessionStorage.key(i), 1])),
).filter((k) => k.startsWith("aislo:draft:"));
const out = {};
// ① 표준 횡단 — config 와 저장분이 다른 프로젝트
const config = JSON.parse(readFileSync(process.argv[3], "utf8")).standard_cross_section;
const stored = JSON.parse(JSON.stringify(config));
stored.rock.ditch.top_width_m = 0.9; // 저장분 = 사용자가 고쳐 저장한 값(config 와 다름)
const context = {
project_id: "pS",
standard_cross_section: config,
stored_standard_cross_section: stored,
};
m.writeState("section-context", context, "pS");
await m.fetchSectionContext("pS"); // 캐시 길 — 예전엔 여기서 저장분을 초안 칸에 베낌
out.ctxDrafts = draftKeys();
const panel = m.createStandardPanel("pS", config, () => {});
out.panelDrafts = draftKeys();
out.panelRockDitch = panel.getValues().rock.ditch.top_width_m;
out.effectiveRockDitch = m.effectiveStandardCross("pS").rock.ditch.top_width_m;
panel.applyStored(stored);
out.afterStored = draftKeys();
// 옛 판이 남긴 같은 값 칸 — 열면 걷음
m.writeState("std-cross", stored, "pS2");
m.writeState("section-context", { ...context, project_id: "pS2" }, "pS2");
m.createStandardPanel("pS2", config, () => {});
out.oldSameCell = m.readState("std-cross", "pS2");
// 저장분 없는 프로젝트 — config 그대로 = 칸 없음 · 계산은 config
m.writeState("section-context", { project_id: "pC", standard_cross_section: config }, "pC");
m.createStandardPanel("pC", config, () => {});
out.configDrafts = draftKeys();
out.configRockDitch = m.effectiveStandardCross("pC").rock.ditch.top_width_m;
// 사용자가 고친 값은 칸에 남음
const edited = JSON.parse(JSON.stringify(stored));
edited.soil.road_width_m = 3.5;
m.writeState("std-cross", edited, "pE");
m.writeState("section-context", { ...context, project_id: "pE" }, "pE");
m.createStandardPanel("pE", config, () => {});
out.editedRoadWidth = m.readState("std-cross", "pE")?.soil.road_width_m ?? null;
// ② 카드 단추 — 리핑암 · 좌절토 · 측구 자동 판정
sessionStorage.clear();
const section = {
station_id: "No.1",
chainage_m: 20,
uphill_side: "left",
design: {
ground_type: "ripping_rock",
section_mode: "left_cut",
ditch_side: null,
ditch_type: "standard",
paved: false,
two_stage_slope: false,
ditch_choice: null,
ditch_enabled: true,
},
samples: [],
};
const sent = [];
const { bar, groundSegment } = m.buildDesignControls(section, (_c, change) => sent.push(change));
const buttons = [];
const walk = (node) => {
if (!node || typeof node !== "object") return;
if (node.tagName === "BUTTON") buttons.push(node);
(node.children ?? []).forEach(walk);
};
walk(groundSegment);
walk(bar);
const press = (pick) => buttons.find(pick)?.listeners.click?.forEach((fn) => fn({}));
out.soilPressed = buttons[0]?.getAttribute("aria-pressed");
press(
(b) => b.textContent === "측구" || b.textContent === "측구 없음" || /측구/.test(b.textContent),
);
out.ditchChange = sent.at(-1) ?? null;
// 새 카드 · 토사 단추 — 지반만(측구형식은 이미 일반이라 안 실림)
buttons.length = 0;
const again = m.buildDesignControls(section, (_c, change) => sent.push(change));
walk(again.groundSegment);
press(() => true);
out.soilChange = sent.at(-1) ?? null;
// 세션 고름 — 앞서 바꾼 칸에 얹음(지반은 안 박음)
const detail = { cross_sections: [section] };
const sync = m.createDesignSync({
projectId: "pD",
routeId: () => 7,
detail: () => detail,
view: () => noop,
});
await sync.handleDesignChange(20, { ditch_choice: false }).catch(() => undefined);
await sync.handleDesignChange(20, { two_stage_slope: true }).catch(() => undefined);
out.choice = Object.fromEntries(m.crossDesignChoices("pD", 7));
await sync.handleDesignChange(20, {}).catch(() => undefined);
out.choiceAfterEmpty = Object.fromEntries(m.crossDesignChoices("pD", 7));
// 소단 사본(시설 줄이 열 때 구조물에서 씀) — 떠남 확인에 안 잡힘 · 진짜 고침은 잡힘
sessionStorage.clear();
m.writeState(
"berm",
[{ start_m: 0, end_m: 40, width_m: 1, interval_m: 5, slope_deg: 45, depth_m: 1 }],
"pB",
7,
);
out.dirtyBermOnly = m.designDirty("pB");
m.writeCrossDesignChoice("pB", 7, 20, { ditch_choice: false });
out.dirtyWithEdit = m.designDirty("pB");
process.stdout.write(JSON.stringify(out));
process.exit(0);
@@ -0,0 +1,15 @@
/* 472 덧(요구 272 C · D) 시험 묶음 진입점 — 열기만 = 초안 칸 없음 · 카드 고름 = 바꾼 칸만(vite ssr). */
export {
createStandardPanel,
effectiveStandardCross,
rememberStandardDefaults,
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
export { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Context";
export { buildDesignControls } from "../../B06_Section/B06_Section_UI_Cross_Design";
export { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync";
export {
crossDesignChoices,
writeCrossDesignChoice,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export { readState, writeState } from "../../A00_Common/b_page_state";
export { designDirty } from "../../A00_Common/design/design_leave";
@@ -81,7 +81,8 @@ def test_B06_칸_열어도_B05_값(case, out):
@pytest.mark.parametrize("case", ["config", "stored"])
def test_칸_열기는_표준_횡단을_안_고침(case, out):
assert out[case]["session"] == out["standard"]
# 472 덧(요구 272 C) — 저장분 · config 와 같은 값이면 초안 칸 없음(옛 판은 같은 값을 칸에 씀)
assert out[case]["session"] is None
def test_고치기_전_공통_칸_끝에_암_값(out):
+109
View File
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""472 덧(요구 272 C · D) — B06 를 열기만 해도 초안이 생기던 흠 ·
카드 고름이 안 바꾼 지반을 박던 흠.
까닭
C. 표준 횡단 패널이 열 때 세션 `std-cross` 에 config 기본값을, 설정값 받기(`seedStandardCross`)가
저장분을 베껴 씀 → 조작 없이 초안 칸이 섬. 규칙 = 값이 저장분과 같으면 칸 없음 · 계산은 저장분.
D. 카드 단추 하나를 눌러도 지반 · 단면 유형 · 측구형식 · 포장 등 일곱 칸을 통째로 세션에 실음 →
[측구] 만 눌렀는데 ripping_rock 이 사용자 선택으로 박힘. 규칙 = 바꾼 칸만.
덧. 소단 사본(`berm` · 시설 줄이 열 때 구조물에서 씀)이 떠남 확인 고침으로 잡힘 →
소단 있는 프로젝트는 열기만 해도 물음.
Node 헬퍼 `helper_472_open_no_draft.mjs` 가 vite ssr 로 묶은 진짜 모듈
(`helper_472_open_no_draft_entry.ts`)을 부름.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
FIXTURE = HERE / "fixtures" / "b06_364_6_rock_route_304.json"
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b472_open")
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_472_open_no_draft_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_472_open_no_draft_entry.js", bundle)
proc = subprocess.run(
["node", str(HERE / "helper_472_open_no_draft.mjs"), str(bundle), str(FIXTURE)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize("step", ["ctxDrafts", "panelDrafts", "afterStored", "configDrafts"])
def test_열기만_하면_초안_칸_0(step, out):
assert out[step] == []
def test_계산은_저장분(out):
assert out["panelRockDitch"] == 0.9
assert out["effectiveRockDitch"] == 0.9
def test_저장분_없으면_config(out):
assert out["configRockDitch"] == 0.69
def test_옛_판이_남긴_같은_값_칸은_걷음(out):
assert out["oldSameCell"] is None
def test_고친_값은_칸에_남음(out):
assert out["editedRoadWidth"] == 3.5
def test_카드_지반_글_상태는_상세_ground_type(out):
# 상세 = ripping_rock → 「토사」 단추 꺼짐(단추 이름은 늘 「토사」 · 켜짐/꺼짐이 값)
assert out["soilPressed"] == "false"
def test_측구_단추는_측구_칸만(out):
assert out["ditchChange"] == {"ditch_choice": False}
def test_토사_단추는_지반_칸만(out):
assert out["soilChange"] == {"ground_type": "soil"}
def test_세션_고름은_바꾼_칸끼리_얹음(out):
assert out["choice"] == {"20": {"ditch_choice": False, "two_stage_slope": True}}
assert out["choiceAfterEmpty"] == out["choice"]
def test_소단_사본은_떠남_확인에_안_잡힘(out):
assert out["dirtyBermOnly"] is False
assert out["dirtyWithEdit"] is True
@@ -7,7 +7,8 @@ config 기본값은 **3%** · **0.69m**. 표준단면은 모든 측점의 횡단
면적·유토곡선·수량까지 그대로 흐른다.
고침은 두 줄이다 — `sections/context` 가 저장분을 **한 칸 더** 실어 보내고(기존 기본값 칸은
그대로), 브라우저가 **세션이 비었을 때만** 그 값으로 세운다. 이 시험은 그 두 성질을 지킨다.
그대로), 브라우저가 **세션이 비었을 때만** 그 값으로 셈(초안 칸에 베끼지 않음 · 472 덧).
이 시험은 그 두 성질을 지킨다.
"""
import re
@@ -53,12 +54,18 @@ def test_라우터가_저장분을_실어_보낸다():
def test_브라우저는_세션이_빈_경우에만_저장분으로_선다():
"""사용자가 그 탭에서 고친 값이 있으면 건드리면 안 된다 — 초안이 언제나 우선."""
"""사용자가 그 탭에서 고친 값이 있으면 그 값 — 없으면 저장분(472 덧 · 요구 272 C).
옛 판은 세션이 비면 저장분을 초안 칸(`std-cross`)에 베꼈음 → 열기만 해도 초안이 섬.
지금은 설정값 칸(`section-context`)에서 바로 읽음 — 세션 고침 → 저장분 → config 차례.
"""
source = _FETCH.read_text(encoding="utf-8")
assert "function seedStandardCross(" in source, "세우는 자리가 없음"
guard = re.search(
r"if \(stored && readState<unknown>\(\"std-cross\", projectId\) === null\)", source
assert '"std-cross"' not in source, "설정값 받기가 초안 칸에 씀"
panel = (_FETCH.parent / "B06_Section_UI_Standard_Panel.ts").read_text(encoding="utf-8")
assert "stored_standard_cross_section" in panel, "저장분을 안 읽음"
order = re.search(
r"readStandardCrossSession\(projectId\) \?\?\s*readStoredStandard\(projectId\) \?\?\s*"
r"readStandardDefaults\(projectId\)",
panel,
)
assert guard, "세션이 있어도 덮어쓰는 모양임"
# 두 갈래(캐시·새 조회) 모두 지나야 새 탭에서도 선다.
assert source.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움"
assert order, "세션 → 저장분 → config 차례가 아님"
+7 -2
View File
@@ -219,8 +219,13 @@ def test_저장된_표준단면이_브라우저까지_간다():
fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Context.ts").read_text(
encoding="utf-8"
)
assert "function seedStandardCross(" in fetch
assert fetch.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움"
# 472 덧(요구 272 C) — 초안 칸에 베끼던 seedStandardCross 대신
# 계산이 설정값 칸 저장분을 바로 읽음
assert '"std-cross"' not in fetch, "설정값 받기가 초안 칸에 씀"
panel = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Standard_Panel.ts").read_text(
encoding="utf-8"
)
assert "readStoredStandard(projectId) ??" in panel, "계산이 저장분을 안 봄"
# ── ⑨ 다단 기슭막이가 물량에 안 닿던 것 (`e9ad8cdf`) ──────────────────────────