Files
Aislo/B06_Section/B06_Section_UI_Standard_Panel.ts
T
eomsangdonandClaude Opus 5 a35c8ff356 feat(B05,B06): 계획선 편집 중 횡단 재계산을 브라우저에서 수행 — 서버 왕복 제거
사용자 조작 중의 계산은 브라우저 안에서 끝나야 함(2026-09-03 사용자 확정). 계획고가
바뀔 때마다 전 측점 횡단 계산이 `POST …/cross-design/preview` 로 나가 왕복이 조작
속도를 지배했음.

- `refreshCrossDesigns` 를 로컬 계산으로 교체. 계획고는 저장 선형(`base_pvi`)에 편집
  델타를 얹어 TS `buildAlignment` 로 여기서 풂 — 호출부(B05·B06) 시그니처는 그대로라
  단일 창구가 유지됨. 서버 프리뷰는 선형 저장분이 없는 옛 데이터 폴백으로만 남음.
- 표준단면 config 기본값·암 경계 기본 오프셋을 `sections/context` 응답에서 세션에 기억
  (`rememberStandardDefaults`·`rememberRockBoundaryDefault`). 프론트에 config 수치를
  복제하지 않으면서 패널 없는 B05 도 같은 값으로 계산함.
- 디바운스 250 → 60ms — 왕복이 사라져 손을 떼는 즉시 유토곡선이 따라옴.
- 낡음 판정을 `hasStaleDesigns` 로 바꿔 첫 건에서 중단하고, 계획선 유효 샘플을
  샘플 배열당 한 번만 걸러 이진탐색(그리기 프레임당 2.33ms → 0.07ms 실측).
- 파이썬 엔진 2파일·프리뷰 라우터에 「TS 짝 파일과 한 벌」 경고 블록 추가.

검증: `tmp/tests/test_b06_cross_design_mirror.py` 10건 — 토사/암 2단계/L형 측구/포장/
세월교 하강/사면 미닫힘 등에서 파이썬과 TS 결과가 면적·설계선까지 일치. 전체 183건 통과.
`tsc --noEmit` 통과. 실측: route 127(측점 128) 전 측점 로컬 재계산 7.5ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 19:58:09 +09:00

433 lines
16 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Standard_Panel.ts
* 좌측 사이드 "표준 횡단면 설정" 패널 (토사 / 암 / 포장 3그룹).
*
* 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 config
* (STANDARD_CROSS_SECTION, context.standard_cross_section)에서 내려오고, 사용자가
* 편집한 값은 프론트 세션(sessionStorage)에 프로젝트 단위로 보관한다. 종·횡단
* 확정 시 이 값을 백엔드/DB에 저장하기 위해 getValues()를 노출한다.
*
* 암 그룹은 일반 측구 + L형 측구 두 세트를 보관하며, 둘 중 선택은 각 횡단면도
* 카드에서 이뤄진다(여기서는 값만 보관).
* ========================================================================== */
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";
import {
getCompanyStandard,
listCompanyStandards,
type StandardCrossGroup,
type StandardCrossKey,
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [
["soil", "B06_Std_Group_Soil"],
["rock", "B06_Std_Group_Rock"],
["paved", "B06_Std_Group_Paved"],
];
const SESSION_PREFIX = "b06:std-cross:";
/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */
const DEFAULTS_PREFIX = "b06:std-cross-default:";
function sessionKey(projectId: string): string {
return `${SESSION_PREFIX}${projectId}`;
}
function defaultsKey(projectId: string): string {
return `${DEFAULTS_PREFIX}${projectId}`;
}
/**
* 서버가 내려 준 표준횡단 **config 기본값**을 세션에 둔다(`sections/context` 응답).
*
* 횡단 계산이 브라우저에서 돌므로 config 수치가 프론트에도 있어야 하는데, 상수를
* 복제하면 정본이 둘이 된다. 그래서 복제 대신 **서버가 준 값을 그대로 기억**한다.
* 패널을 열지 않는 B05도 이 값으로 계산해야 두 화면 결과가 같다(2026-09-03 로컬 전환).
*/
export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void {
try {
window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults));
} catch {
/* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */
}
}
/** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */
export function readStandardDefaults(projectId: string): StandardCrossSection | null {
try {
const raw = window.sessionStorage.getItem(defaultsKey(projectId));
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
} catch {
return null;
}
}
const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:";
/**
* 암반 경계선 기본 오프셋(config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`)을 기억한다.
* 저장분에 경계 오프셋이 없는 옛 암 측점을 서버와 **같은 기본값**으로 다시 계산하려면
* 브라우저에도 이 값이 있어야 한다 — 상수 복제 대신 컨텍스트 응답을 기억하는 방식이다.
*/
export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void {
try {
window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM));
} catch {
/* 세션 저장 실패는 무시. */
}
}
/** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */
export function readRockBoundaryDefault(projectId: string): number | null {
try {
const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
/**
* 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**.
* 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다.
*/
export function effectiveStandardCross(projectId: string): StandardCrossSection | null {
return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId);
}
/** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */
export function clearStandardCrossSession(projectId: string): void {
try {
window.sessionStorage.removeItem(sessionKey(projectId));
} catch {
/* 세션 접근이 막혀도 초기화는 계속한다. */
}
}
/** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */
function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection {
return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection;
}
/**
* 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null.
*
* 패널 밖에서도 필요하다 — 횡단 재계산 단일 창구(`B06_Section_Cross_Refresh`)가 B05처럼
* 패널이 없는 화면에서도 **같은 표준 단면값**을 서버로 보내야 두 화면 결과가 같다.
*/
export function readStandardCrossSession(projectId: string): StandardCrossSection | null {
try {
const raw = window.sessionStorage.getItem(sessionKey(projectId));
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
} catch {
return null;
}
}
function writeSession(projectId: string, value: StandardCrossSection): void {
try {
window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(value));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
export interface StandardPanelController {
root: HTMLElement;
/** 확정 시 백엔드/DB 저장에 쓰는 현재 편집값. */
getValues: () => StandardCrossSection;
/**
* DB에 확정 저장된 값으로 복원한다. 단, 세션에 미확정 편집값이 있으면
* 그쪽을 우선하고 무시한다(진행 중 편집 보호).
*/
applyStored: (stored: StandardCrossSection) => void;
}
interface NumberFieldSpec {
label: string;
get: (group: StandardCrossGroup) => number;
set: (group: StandardCrossGroup, value: number) => void;
/** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */
only?: StandardCrossKey;
}
/** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */
const FIELD_SPECS: NumberFieldSpec[] = [
{
label: "B06_Std_Field_RoadWidth",
get: (g) => g.road_width_m,
set: (g, v) => (g.road_width_m = v),
},
{
label: "B06_Std_Field_ShoulderLeft",
get: (g) => g.shoulder_left_m,
set: (g, v) => (g.shoulder_left_m = v),
},
{
label: "B06_Std_Field_ShoulderRight",
get: (g) => g.shoulder_right_m,
set: (g, v) => (g.shoulder_right_m = v),
},
{
label: "B06_Std_Field_DitchTop",
get: (g) => g.ditch.top_width_m,
set: (g, v) => (g.ditch.top_width_m = v),
},
{
label: "B06_Std_Field_DitchBottom",
get: (g) => g.ditch.bottom_width_m,
set: (g, v) => (g.ditch.bottom_width_m = v),
},
{
label: "B06_Std_Field_DitchDepth",
get: (g) => g.ditch.depth_m,
set: (g, v) => (g.ditch.depth_m = v),
},
{
label: "B06_Std_Field_LDitchWidth",
only: "rock",
get: (g) => g.ditch_l_type?.width_m ?? 0,
set: (g, v) => {
g.ditch_l_type = { width_m: v, depth_m: g.ditch_l_type?.depth_m ?? 0 };
},
},
{
label: "B06_Std_Field_LDitchDepth",
only: "rock",
get: (g) => g.ditch_l_type?.depth_m ?? 0,
set: (g, v) => {
g.ditch_l_type = { width_m: g.ditch_l_type?.width_m ?? 0, depth_m: v };
},
},
{
label: "B06_Std_Field_CutSlope",
get: (g) => g.cut_slope_ratio,
set: (g, v) => (g.cut_slope_ratio = v),
},
{
label: "B06_Std_Field_FillSlope",
get: (g) => g.fill_slope_ratio,
set: (g, v) => (g.fill_slope_ratio = v),
},
{
label: "B06_Std_Field_CrossSlopeMin",
get: (g) => g.cross_slope_pct.min,
set: (g, v) => (g.cross_slope_pct.min = v),
},
{
label: "B06_Std_Field_CrossSlopeMax",
get: (g) => g.cross_slope_pct.max,
set: (g, v) => (g.cross_slope_pct.max = v),
},
];
/**
* 표준 횡단면 설정 패널을 만든다.
* @param projectId 세션 캐시 스코프.
* @param defaults config에서 내려온 기본값(복원 기준).
*/
export function createStandardPanel(
projectId: string,
defaults: StandardCrossSection,
onApplyAll?: () => void | Promise<void>,
): StandardPanelController {
// 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다.
rememberStandardDefaults(projectId, defaults);
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
const sessionValue = readStandardCrossSession(projectId);
const hadSession = sessionValue !== null;
const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults);
const root = document.createElement("div");
root.className = "b06-std";
// 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5).
root.append(buildStandardDiagram());
// 그룹 재구성(리셋 시) 편의를 위해 본문 컨테이너를 분리한다.
const body = document.createElement("div");
body.className = "b06-std__body";
const persist = (): void => writeSession(projectId, state);
const buildGroup = (key: StandardCrossKey, legendKey: keyof typeof ui_locales): HTMLElement => {
const group = state[key];
// B05 "기준값 직접 지정"과 동일한 details/summary 패턴, 기본 접힘(N-4-1).
const fieldset = document.createElement("details");
fieldset.className = "b06-std__group";
const legend = document.createElement("summary");
legend.className = "b06-std__legend";
legend.textContent = L(legendKey);
fieldset.append(legend);
const grid = document.createElement("div");
grid.className = "b06-std__grid";
for (const spec of FIELD_SPECS) {
if (spec.only && spec.only !== key) continue;
const field = createInputField({
label: L(spec.label as keyof typeof ui_locales),
type: "number",
value: String(spec.get(group)),
onInput: (raw) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
spec.set(group, parsed);
persist();
},
});
field.input.step = "0.1";
field.input.min = "0";
grid.append(field.root);
}
fieldset.append(grid);
if (key === "rock") {
const note = document.createElement("p");
note.className = "b06-std__note";
note.textContent = L("B06_Std_LType_Note");
fieldset.append(note);
}
return fieldset;
};
const renderBody = (): void => {
body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey)));
};
renderBody();
/** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */
const applyStandard = (source: StandardCrossSection): void => {
(Object.keys(source) as StandardCrossKey[]).forEach((key) => {
if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup;
});
persist();
renderBody();
};
// 다른 프로젝트에서 설계값 불러오기: 선택·미리보기만으로는 현재 값이 바뀌지 않고,
// "적용" 버튼을 눌러야만 반영된다(작업 C-6).
const loader = buildProjectLoader(projectId, applyStandard);
const resetButton = createButton({
label: L("B06_Std_Reset"),
variant: "ghost",
onClick: () => {
const fresh = cloneDefaults(defaults);
(Object.keys(fresh) as StandardCrossKey[]).forEach((key) => {
state[key] = fresh[key];
});
persist();
renderBody();
},
});
const actions = document.createElement("div");
actions.className = "b06-std__actions";
// [전체 반영](N-2-1): 패널 수치를 design 보유 전 측점에 서버 재계산으로 일괄 반영한다.
// 측점별 버튼 선택값(지반/단면 등)은 보존하고 표준단면 수치만 갱신한다.
if (onApplyAll) {
actions.append(
createButton({
label: L("B06_Std_ApplyAll"),
variant: "filled",
onClick: () => void onApplyAll(),
}),
);
}
actions.append(resetButton);
// 횡단 반폭은 사이드의 **별도 컨테이너**로 뺐다(2026-08-23) — 여기는 표준단면 설정만.
root.append(body, loader, actions);
return {
root,
getValues: () => state,
applyStored: (stored) => {
if (hadSession) return; // 진행 중 세션 편집값이 우선.
(Object.keys(stored) as StandardCrossKey[]).forEach((key) => {
if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup;
});
renderBody();
},
};
}
/**
* "다른 프로젝트에서 불러오기" 컨트롤. 같은 회사의 최근 프로젝트 5개를 보여주고,
* 사용자가 선택하면 그 프로젝트의 저장 설계값을 즉시 현재 설정(그룹 값)에 적용한다.
* 각 횡단면도 카드에서 사용자가 고른 버튼 옵션(지반유형·단면유형 등)은 건드리지 않는다
* — 이 값들은 측점별 design으로 별도 보관되며 패널 값과 독립이다.
*/
function buildProjectLoader(
projectId: string,
onApply: (source: StandardCrossSection) => void,
): HTMLElement {
const wrap = document.createElement("details");
wrap.className = "b06-std__loader";
const summary = document.createElement("summary");
summary.className = "b06-std__loader-summary";
summary.textContent = L("B06_Std_Load_Title");
wrap.append(summary);
const status = document.createElement("p");
status.className = "b06-std__loader-status";
status.textContent = L("B06_Std_Load_Loading");
const field = createSelectField({
label: L("B06_Std_Load_Select"),
options: [{ value: "", text: L("B06_Std_Load_Placeholder") }],
onChange: (value) => void onSelect(value),
});
field.root.hidden = true;
async function onSelect(sourceId: string): Promise<void> {
if (!sourceId) {
status.textContent = "";
return;
}
status.textContent = L("B06_Std_Load_Loading");
try {
const response = await getCompanyStandard(projectId, sourceId);
onApply(response.standard_cross_section);
status.textContent = L("B06_Std_Load_Applied");
} catch (error) {
// 404 = 해당 프로젝트에 저장된 설계값 없음(목록은 보유 여부 무관 최근 5개).
const message = error instanceof Error ? error.message : "";
status.textContent = message.includes("찾을 수 없")
? L("B06_Std_Load_None")
: L("B06_Std_Load_Failed");
}
}
// 같은 회사 최근 프로젝트 5개 비동기 로드 → 셀렉트 채우기.
void (async () => {
try {
const response = await listCompanyStandards(projectId);
if (!response.projects.length) {
status.textContent = L("B06_Std_Load_Empty");
return;
}
for (const project of response.projects) {
const option = document.createElement("option");
option.value = project.project_id;
option.textContent = project.name;
field.select.append(option);
}
field.root.hidden = false;
status.textContent = "";
} catch {
status.textContent = L("B06_Std_Load_Failed");
}
})();
wrap.append(status, field.root);
return wrap;
}