Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 — 572-3 D · E 가드 · 시험 충돌(양쪽 살림 · PENDING 빈 set)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 18:23:18 +09:00
co-authored by Claude Opus 5.5
12 changed files with 226 additions and 115 deletions
+20 -12
View File
@@ -1,16 +1,17 @@
/* =============================================================================
* D02_PlanBase_UI_Page.ts
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점.
* 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 저장 뒤 · 실패면 머묾(560-4)
* 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 공용 물음 [취소][폐기][저장](572-3)
* · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선).
* 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · 떠남 = 그냥.
* 캐시 = 등록표 `d02-points`(프로젝트 칸 · 떠남 비우기에 걸림).
* 첫 불러오기가 실패해도 지도 · 표는 세움(빈 화면 없음).
* 지도 [일반][위성](560-3) = 카카오 HYBRID · 고른 값 = 화면 취향(`aislo:pref:`) 기억.
* ========================================================================== */
import "./D02_PlanBase_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import {
createButton,
createInputField,
@@ -24,7 +25,14 @@ import { createText } from "@ui/ui_template_text";
import { editMap, type MapPin, type MapType } from "@ui/ui_template_kakao_map";
import { createSegment } from "@ui/ui_template_segment";
import { t } from "@ui/ui_template_locale";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import {
clearState,
readByKey,
readState,
writeByKey,
writeState,
} from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
@@ -46,8 +54,6 @@ const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
const PREF_MAP_TYPE = "aislo:pref:d02.map.type";
const cacheKey = (projectId: string): string => `d02_points_${projectId}`;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
@@ -68,7 +74,7 @@ function boundaryPoints(view: PointsView): Array<LonLat | null> {
}
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const projectId = currentProjectId();
const mapHost = el("div", { className: "d02-map" });
const table = el("div", { className: "d02-points" });
const info = createText("", "muted");
@@ -87,7 +93,9 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
mountGroupStep(root, ROUTES.D02_PLAN_BASE, {
mainContent: mapHost,
leftPanel: left,
beforeLeave: async () => !editable || !dirty() || store(),
dirty: () => editable && dirty(),
save: () => store(),
discard: () => clearState("d02-points", projectId),
});
let view: PointsView | null = null;
@@ -103,7 +111,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const serverPoints = (): Array<LonLat | null> => (view ? boundaryPoints(view) : [null, null]);
const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints());
const remember = (): void =>
sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ base: serverPoints(), points }));
writeState("d02-points", { base: serverPoints(), points }, projectId);
const map = editMap({
hint: t("D02_Map_Hint"),
@@ -232,7 +240,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
body: JSON.stringify(body),
}),
);
sessionStorage.removeItem(cacheKey(projectId));
clearState("d02-points", projectId);
active = firstEmpty();
paint();
showToast(t("D02_Saved"), "success");
@@ -254,10 +262,10 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
if (mapHint) mapHint.textContent = t("D02_View_Only");
}
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as {
const cached = readState<{
base?: Array<LonLat | null>;
points?: Array<LonLat | null>;
} | null;
}>("d02-points", projectId);
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
if (editable && sameBase && cached?.points?.length === 2) points = cached.points;
} catch (error) {
+8 -5
View File
@@ -13,7 +13,7 @@
* ========================================================================== */
import "./D03_PlanAnalysis_UI_Style.css";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { editMap } from "@ui/ui_template_kakao_map";
@@ -27,6 +27,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
@@ -79,14 +80,14 @@ const errorText = (error: unknown): string =>
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const projectId = currentProjectId();
let editable = false;
let start: Vertex = [0, 0];
let end: Vertex = [0, 0];
let epsg = 0;
let yearStart = new Date().getFullYear();
let network: PlanNetwork = emptyNetwork(start);
let savedJson = "";
let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음
let undoStack: PlanNetwork[] = [];
let redoStack: PlanNetwork[] = [];
let surface: PlanSurface | null = null;
@@ -161,11 +162,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
panel.className = "ui-sidebar-panel d03-plan__panel";
panel.append(terrainCard, drawCard, pieceCard, actions);
attachCollapsible(panel);
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 선만 저장 · 실패면 머묾
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
mainContent: main,
leftPanel: panel,
beforeLeave: async () => !editable || !dirty() || (await save()),
dirty: () => editable && dirty(),
save,
discard: () => clearState("d03-plan", projectId),
});
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
+14 -3
View File
@@ -11,7 +11,7 @@
* ========================================================================== */
import "./D04_PlanConfirm_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { kstText } from "../common_util/common_util_time_kst";
import {
createButton,
@@ -30,6 +30,7 @@ import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { searchCompanies } from "../A07_Register/A07_Register_Api_Fetch";
@@ -105,7 +106,7 @@ const sameFirm = (a: Assignee, b: Assignee): boolean =>
: b.company_id === null && (a.email ?? "").toLowerCase() === (b.email ?? "").toLowerCase());
export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const projectId = currentProjectId();
const base = `/projects/${projectId}/plan`;
const main = el("div", { className: "d04-confirm" });
const pickList = el("div", { className: "d04-confirm__picks" });
@@ -113,7 +114,17 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
pickCard.append(pickList);
const actions = el("div", { className: "ui-sidebar-actions" });
const panel = el("div", { className: "d04-confirm__panel", children: [pickCard, actions] });
mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { mainContent: main, leftPanel: panel });
// 떠날 때 저장 안 한 지정 업체가 있으면 공용 물음 [취소][폐기][저장](572-3)
mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, {
mainContent: main,
leftPanel: panel,
dirty: () => draft() !== null,
save: async () => {
await locked(save);
return draft() === null; // 실패 = 캐시 남음 = 머묾
},
discard: () => clearState("d04-assignees", projectId),
});
let summary: Summary = { name: "", years: [], pieces: [], assignees: [] };
let editable = false; // 산림청만 고침
+15 -13
View File
@@ -2,16 +2,16 @@
* E02_StabGround_UI_Page.ts
* E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지.
* 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 ·
* 조작 = 캐시 `e02-ground` 만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 ·
* 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른
* 페이지에 갔다 와도 이어 보임) · [끊기] · 떠날 때 바뀐 값 = PUT …/stab/ground(beforeLeave) ·
* 페이지에 갔다 와도 이어 보임) · [끊기] · 떠날 때 바뀐 값 = 공용 물음 [취소][폐기][저장](572-3) ·
* [초기화] = POST …/reset(초기값 덮어씀).
* 검사 = logic/E02_StabGround_Check(파이썬 짝).
* 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05).
* ========================================================================== */
import "./E02_StabGround_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import {
createButton,
el,
@@ -25,6 +25,8 @@ import { createListRow } from "@ui/ui_template_list_row";
import { createProgressBar } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
import { inferStationInterval } from "../common_util/common_util_station";
@@ -60,8 +62,6 @@ interface RunProgress {
cancelled: boolean;
}
const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`;
/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */
const STEP_TONE: Record<string, string> = {
run: "var(--color-accent)",
@@ -104,7 +104,7 @@ async function request<T = GroundResponse>(path: string, init?: RequestInit): Pr
}
export function renderE02StabGround(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
const main = el("div", { className: "e02-page" });
const status = el("div", { className: "e02-status" });
const runButton = createButton({
@@ -143,7 +143,9 @@ export function renderE02StabGround(root: HTMLElement): void {
mountGroupStep(root, ROUTES.E02_STAB_GROUND, {
mainContent: main,
leftPanel: panel,
beforeLeave: async () => !view?.editable || !dirty || store(),
dirty: () => Boolean(view?.editable) && dirty,
save: store,
discard: () => clearState("e02-ground", projectId),
});
if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty"));
@@ -247,15 +249,15 @@ export function renderE02StabGround(root: HTMLElement): void {
const remember = (): void => {
if (!view) return;
dirty = true;
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.ground));
writeState("e02-ground", view.ground, projectId);
};
const adopt = (body: GroundResponse, keepCache: boolean): void => {
if (!view || !body.defaults) return;
let ground = body.ground;
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
if (cached) ground = JSON.parse(cached) as Ground;
else sessionStorage.removeItem(cacheKey(projectId));
const cached = keepCache ? readState<Ground>("e02-ground", projectId) : null;
if (cached) ground = cached;
else clearState("e02-ground", projectId);
// 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄)
ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater };
dirty = Boolean(cached);
@@ -278,7 +280,7 @@ export function renderE02StabGround(root: HTMLElement): void {
return errors.length === 0;
}
/** 떠날 때(560-4 beforeLeave) — 해석 없이 바뀐 값만 저장 · 틀리거나 실패 = 머묾 */
/** 떠남 물음 [저장](572-3) — 해석 없이 바뀐 값만 저장 · 틀리거나 실패 = 머묾 */
async function store(): Promise<boolean> {
if (!view || !projectId || !valid()) return false;
showLoadingOverlay();
@@ -287,7 +289,7 @@ export function renderE02StabGround(root: HTMLElement): void {
method: "PUT",
body: JSON.stringify({ ground: view.ground }),
});
sessionStorage.removeItem(cacheKey(projectId));
clearState("e02-ground", projectId);
dirty = false;
showToast(t("E02_Saved"), "success");
return true;
+39 -24
View File
@@ -4,13 +4,13 @@
* 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장]
* 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프
* 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) ·
* 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음).
* 대표 단면 별 = 누를 때마다 바로 PUT(요구 390) · 떠날 때 저장 중이면 공용 물음 [취소][폐기][저장](572-3).
* 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본).
* 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률).
* ========================================================================== */
import "./E03_StabSection_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import {
createButton,
createInputField,
@@ -23,6 +23,7 @@ import {
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
import { buildList } from "./E03_StabSection_UI_List";
@@ -92,10 +93,15 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export function renderE03StabSection(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
const main = el("div", { className: "e03-page" });
const panel = el("div", { className: "ui-sidebar-panel e03-panel" });
mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel });
mountGroupStep(root, ROUTES.E03_STAB_SECTION, {
mainContent: main,
leftPanel: panel,
dirty: () => repPending > 0,
save: () => repSaving.then(() => true),
});
if (!projectId) return void main.append(emptyBox(t("E03_NoProject")));
const pid: string = projectId;
const base = `/projects/${pid}/stab`;
@@ -118,6 +124,7 @@ export function renderE03StabSection(root: HTMLElement): void {
let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
const planView: PlanView = {};
let repSaving: Promise<void> = Promise.resolve(); // 별 누름마다 차례로 저장
let repPending = 0; // 아직 안 끝난 별 저장(떠남 물음)
// ---- 좌측 ----
const condField = createSelectField({
@@ -567,26 +574,34 @@ export function renderE03StabSection(root: HTMLElement): void {
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
function toggleRep(key: string): Promise<void> {
repSaving = repSaving.then(async () => {
const row = result?.stations.find((r) => r.key === key);
if (!row) return;
const server = row.representative ?? { auto: [], user: null, on: false };
const auto = server.auto.length > 0;
const next = !server.on === auto ? null : !server.on;
const overrides: Record<string, boolean | null> = {};
for (const r of result?.stations ?? []) {
const user = r.key === key ? next : (r.representative?.user ?? null);
if (user !== null) overrides[r.key] = user;
}
try {
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
row.representative = { ...server, user: next, on: next === null ? auto : next };
paintList();
void refreshReport();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
});
repPending += 1;
repSaving = repSaving
.then(async () => {
const row = result?.stations.find((r) => r.key === key);
if (!row) return;
const server = row.representative ?? { auto: [], user: null, on: false };
const auto = server.auto.length > 0;
const next = !server.on === auto ? null : !server.on;
const overrides: Record<string, boolean | null> = {};
for (const r of result?.stations ?? []) {
const user = r.key === key ? next : (r.representative?.user ?? null);
if (user !== null) overrides[r.key] = user;
}
try {
await api(`${base}/representative`, {
method: "PUT",
body: JSON.stringify({ overrides }),
});
row.representative = { ...server, user: next, on: next === null ? auto : next };
paintList();
void refreshReport();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
})
.finally(() => {
repPending -= 1;
});
return repSaving;
}
+4 -4
View File
@@ -4,7 +4,7 @@
* 분포 입력(단면 물성 × c · φ · γ · 지하수위 · r_u — 쓰기 · 분포 · 변동계수/표준편차 · 절단 ±kσ) ·
* 표본(LHS · MC · N · 시드) · 갈래(임계면 고정 · 매회 탐색) · 방법 · c–φ 상관 →
* 서버 계산(POST …/stab/prob/{측점} · 뒤에서 · 진행 GET …/stab/prob/progress · 카드 안 진행률).
* 기본값 후보 = 서버 `prob_defaults.json`(근거 · 사용자 확정 전) · 입력 = sessionStorage 캐시(자동저장 없음).
* 기본값 후보 = 서버 `prob_defaults.json`(근거 · 사용자 확정 전) · 입력 = 캐시 `e03-prob`(자동저장 없음).
* 결과 = 요약 표 · 히스토그램 · 누적분포 · 민감도 · 토네이도(`UI_ProbChart`).
* ========================================================================== */
@@ -21,6 +21,7 @@ import { createCheckbox } from "@ui/ui_template_checkbox";
import { createKv } from "@ui/ui_template_kv";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { readState, writeState } from "../A00_Common/b_page_state";
import { card, emptyBox, muted } from "./E03_StabSection_UI_Parts";
import { e03Table } from "./E03_StabSection_UI_Table";
import {
@@ -76,13 +77,12 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
/** 탭 하나 — 준비(setup) · 결과 · 진행을 들고 다시 그릴 때 `repaint` 를 부름 */
export function createProbPanel(projectId: string, base: string, repaint: () => void) {
const cacheKey = `e03_prob_cache:${projectId}`;
const setups = new Map<string, ProbSetup | "loading" | string>();
const records = new Map<string, ProbRecord>();
let cache: ProbCache | null = JSON.parse(sessionStorage.getItem(cacheKey) ?? "null");
let cache: ProbCache | null = readState<ProbCache>("e03-prob", projectId);
let busy: Progress | null = null;
const save = (): void => sessionStorage.setItem(cacheKey, JSON.stringify(cache));
const save = (): void => writeState("e03-prob", cache, projectId);
const slotOf = (v: ProbView, side: string): string => `${v.row.key}|${side}|${v.condition}`;
async function load(slot: string, key: string, side: string, condition: string): Promise<void> {
+7 -9
View File
@@ -4,12 +4,12 @@
* 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기]
* 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면)
* 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법)
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = 캐시 `e04-settings`
* · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음).
* ========================================================================== */
import "./E04_Stab3D_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
@@ -24,6 +24,8 @@ import { createProgressBar } from "@ui/ui_template_progress";
import { createSegment } from "@ui/ui_template_segment";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
@@ -70,7 +72,6 @@ interface Progress {
key: string | null;
}
const cacheKey = (projectId: string): string => `e04_settings:${projectId}`;
const CONDITIONS = ["dry", "wet"] as const;
const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`;
const sectionKey = (start: number, end: number, condition: string): string =>
@@ -135,7 +136,7 @@ function legend(layer: MapLayer): HTMLElement {
}
export function renderE04Stab3D(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
const panel = el("div", { className: "ui-sidebar-panel e04-panel" });
const main = el("div", { className: "e04-page" });
mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel });
@@ -252,8 +253,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
const station = (chainage: number): string =>
stationLabel(chainage, inferStationInterval(overview?.stations ?? []), 1);
const remember = (): void =>
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read()));
const remember = (): void => writeState("e04-settings", settings.read(), projectId);
let shownKey = "";
settings.onChange(() => {
remember();
@@ -456,9 +456,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
}
const ov = overview!;
settings.setDefaults(ov.defaults, ov.max_section_m);
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
const cached = readState<Settings>("e04-settings", projectId);
const first = ov.saved[0] ?? ov.sections[0];
settings.write(
cached ?? {
+23 -13
View File
@@ -3,14 +3,15 @@
* E05 안정성평가 보고서(요구서 345 4-3 · 요구 347-5 · PLAN 534-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/report → 작업본(없고 2D 결과가 있으면 서버가 처음 만듦) · 판 · stale.
* 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴).
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
* 조작 = 캐시 `e05-report` 만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
* [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시).
* 열 때 해석이 바뀌었으면 서버가 저절로 갱신(`refreshed` · 덮인 시트 `overwritten` 알림).
* [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함.
* 떠날 때 고친 책이 있으면 공용 물음 [취소][폐기][저장](572-3).
* ========================================================================== */
import "./E05_StabReport_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import {
createButton,
el,
@@ -24,6 +25,8 @@ import { createEmpty } from "@ui/ui_template_empty";
import { t } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
@@ -51,8 +54,6 @@ interface CacheEntry {
book: Workbook;
}
const cacheKey = (projectId: string): string => `e05_report_cache:${projectId}`;
async function failure(response: Response): Promise<Error> {
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
return new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
@@ -91,7 +92,7 @@ function emptyBox(text: string): HTMLElement {
}
export function renderE05StabReport(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
const main = el("div", { className: "e05-page" });
const sheetHost = el("div", { className: "e05-sheet" });
const status = el("div", { className: "e05-status" });
@@ -137,7 +138,16 @@ export function renderE05StabReport(root: HTMLElement): void {
previewUrl = null;
};
mountGroupStep(root, ROUTES.E05_STAB_REPORT, { mainContent: main, leftPanel: panel });
mountGroupStep(root, ROUTES.E05_STAB_REPORT, {
mainContent: main,
leftPanel: panel,
dirty: () => editable && dirty,
save: async () => {
await save();
return !dirty; // 실패 = 고침 남음 = 머묾
},
discard: () => clearState("e05-report", projectId),
});
if (!projectId) return void sheetHost.append(emptyBox(t("E05_NoProject")));
let sheet: SpreadsheetHandle | null = null;
@@ -178,10 +188,9 @@ export function renderE05StabReport(root: HTMLElement): void {
return paint();
}
let book = body.book;
const raw = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
const cached = raw ? (JSON.parse(raw) as CacheEntry) : null;
const cached = keepCache ? readState<CacheEntry>("e05-report", projectId) : null;
if (cached && cached.rev === body.rev) book = cached.book;
else sessionStorage.removeItem(cacheKey(projectId));
else clearState("e05-report", projectId);
dirty = Boolean(cached && cached.rev === body.rev);
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
sheet?.destroy();
@@ -192,9 +201,10 @@ export function renderE05StabReport(root: HTMLElement): void {
figureSrc,
onChange: (next) => {
dirty = true;
sessionStorage.setItem(
cacheKey(projectId),
JSON.stringify({ rev: state?.rev ?? "", book: next } satisfies CacheEntry),
writeState(
"e05-report",
{ rev: state?.rev ?? "", book: next } satisfies CacheEntry,
projectId,
);
paint();
},
@@ -223,7 +233,7 @@ export function renderE05StabReport(root: HTMLElement): void {
method: "PUT",
body: JSON.stringify({ rev: state.rev, book: sheet.getDoc() }),
});
sessionStorage.removeItem(cacheKey(projectId));
clearState("e05-report", projectId);
state = { ...state, rev: body.rev, meta: body.meta, stale: body.stale };
dirty = false;
};
+27 -10
View File
@@ -4,12 +4,13 @@
* 좌 = 절리군 표 · 해석 설정 · [초기화] [해석]
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
* 조작 = sessionStorage 캐시만(자동저장 없음) · [초기화] = POST …/reset ·
* 조작 = 캐시 `e06-rock` 만(자동저장 없음) · [초기화] = POST …/reset ·
* [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음.
* 떠날 때 고친 절리군이 있으면 공용 물음 [취소][폐기][저장 = PUT](572-3).
* ========================================================================== */
import "./E06_StabRock_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
@@ -23,6 +24,8 @@ import {
import { t } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import {
buildJointCards,
@@ -61,8 +64,6 @@ interface StationResponse {
stereonet: Record<Projection, NetData> & { phi: number };
}
const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`;
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
@@ -80,10 +81,26 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
}
export function renderE06StabRock(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
const panel = el("div", { className: "ui-sidebar-panel e06-panel" });
const main = el("div", { className: "e06-page" });
mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel });
mountGroupStep(root, ROUTES.E06_STAB_ROCK, {
mainContent: main,
leftPanel: panel,
dirty: () => Boolean(view?.editable) && dirty,
save: async () => {
showLoadingOverlay();
try {
return await putJoints(); // 틀린 칸 · 실패 = 머묾
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
return false;
} finally {
hideLoadingOverlay();
}
},
discard: () => clearState("e06-rock", projectId),
});
if (!projectId) return void main.append(emptyBox(t("E06_NoProject")));
// ---- 좌 ----
@@ -191,7 +208,7 @@ export function renderE06StabRock(root: HTMLElement): void {
const remember = (): void => {
if (!view) return;
dirty = true;
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc));
writeState("e06-rock", view.doc, projectId);
};
function paintProjection(): void {
@@ -273,9 +290,9 @@ export function renderE06StabRock(root: HTMLElement): void {
const adopt = (body: RockResponse, keepCache: boolean): void => {
if (!view) return;
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
if (!cached) sessionStorage.removeItem(cacheKey(projectId));
view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints;
const cached = keepCache ? readState<JointsDoc>("e06-rock", projectId) : null;
if (!cached) clearState("e06-rock", projectId);
view.doc = cached ?? body.joints;
dirty = Boolean(cached);
resetButton.disabled = analyzeButton.disabled = !view.editable;
renderCards();
+7 -9
View File
@@ -4,12 +4,12 @@
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = 캐시 `e07-settings`
* · 없으면 지난 결과 조건 + 물성은 지금 E02 값.
* ========================================================================== */
import "./E07_StabArea_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import {
createButton,
@@ -31,6 +31,8 @@ import { createTable } from "@ui/ui_template_table";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
import {
@@ -87,8 +89,6 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [
["level_hi_pct", "E07_LevelHi", "band"],
];
const cacheKey = (projectId: string): string => `e07_settings:${projectId}`;
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
@@ -156,7 +156,7 @@ function legend(layer: MapLayer, scenario: unknown): HTMLElement {
}
export function renderE07StabArea(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const projectId = currentProjectId();
const panel = el("div", { className: "ui-sidebar-panel e07-panel" });
const main = el("div", { className: "e07-page" });
mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel });
@@ -283,7 +283,7 @@ export function renderE07StabArea(root: HTMLElement): void {
map.setBand(Number(fields.get("band_m")!.input.value) || 100);
}
const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read()));
const remember = (): void => writeState("e07-settings", read(), projectId);
panel.addEventListener("input", remember);
scenario.select.addEventListener("change", () => {
paintScenario();
@@ -477,9 +477,7 @@ export function renderE07StabArea(root: HTMLElement): void {
return;
}
const ov = overview!;
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
const cached = readState<Settings>("e07-settings", projectId);
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값
const last = ov.result?.settings as Settings | undefined;
write({
+1 -11
View File
@@ -20,17 +20,7 @@ X = "11111111-aaaa-4aaa-8aaa-000000000001"
Y = "22222222-bbbb-4bbb-8bbb-000000000002"
#: 아직 직접 읽는 B · D · E 페이지(572-2 · 572-3 창 몫) — 고치면 여기서 뺌.
PENDING = {
"D02_PlanBase/D02_PlanBase_UI_Page.ts",
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts",
"D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts",
"E02_StabGround/E02_StabGround_UI_Page.ts",
"E03_StabSection/E03_StabSection_UI_Page.ts",
"E04_Stab3D/E04_Stab3D_UI_Page.ts",
"E05_StabReport/E05_StabReport_UI_Page.ts",
"E06_StabRock/E06_StabRock_UI_Page.ts",
"E07_StabArea/E07_StabArea_UI_Page.ts",
}
PENDING: set[str] = set()
@pytest.fixture(scope="module")
+61 -2
View File
@@ -1,5 +1,6 @@
"""560-4 D 단계 이동 = 저장 뒤 이동 · 572-2 B 떠남 물음 지킴 시험 — 서버 · 화면 없이 소스만 본다.
단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움."""
단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움.
572-3 — D · E 페이지는 공용 가드에 {dirty, save} 를 둠(말없이 저장 없음) · 캐시 = 등록표 · 프로젝트 = currentProjectId."""
import re
from pathlib import Path
@@ -20,7 +21,7 @@ def test_group_step_waits_before_leave():
def test_d02_leave_saves_and_builds_map_on_fail():
ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts")
assert "beforeLeave:" in ts and "ui-step-nav" not in ts # 따로 듣개 없음
assert "dirty:" in ts and "save:" in ts and "ui-step-nav" not in ts # 따로 듣개 없음
tail = ts[ts.rindex(" try {") :]
# 불러오기 catch 뒤에 paint() — 실패해도 지도 · 표를 세움
assert re.search(r"\} catch \(error\) \{[^}]*\}\s*active = firstEmpty\(\);\s*paint\(\);", tail)
@@ -84,3 +85,61 @@ def test_b_pages_leave_other_project_cache_to_enter():
"B11_Status/B11_Status_UI_Loading.ts",
):
assert "purgeOtherProjects" not in _src(path), path
# 572-3 D · E — 고침 판별 · 저장 · [폐기] 때 지울 등록표 칸(E03 = 별 저장 진행 중만 · 지울 칸 없음)
DE_GUARDED = {
"D02_PlanBase/D02_PlanBase_UI_Page.ts": "d02-points",
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts": "d03-plan",
"D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts": "d04-assignees",
"E02_StabGround/E02_StabGround_UI_Page.ts": "e02-ground",
"E03_StabSection/E03_StabSection_UI_Page.ts": None,
"E05_StabReport/E05_StabReport_UI_Page.ts": "e05-report",
"E06_StabRock/E06_StabRock_UI_Page.ts": "e06-rock",
}
DE_PAGES = [
*DE_GUARDED,
"E03_StabSection/E03_StabSection_UI_Prob.ts",
"E04_Stab3D/E04_Stab3D_UI_Page.ts",
"E07_StabArea/E07_StabArea_UI_Page.ts",
]
DE_STATES = [
"d02-points",
"e02-ground",
"e03-prob",
"e04-settings",
"e05-report",
"e06-rock",
"e07-settings",
]
def test_de_pages_guard_leave():
for path, name in DE_GUARDED.items():
ts = _src(path)
call = ts[ts.index("mountGroupStep(root") :]
call = call[: call.index("});")]
assert "dirty: () =>" in call and "save" in call, path
assert "beforeLeave" not in ts, path # 말없이 저장 없음 — 공용 셋 물음
if name:
assert f'discard: () => clearState("{name}", projectId)' in call, path
# D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음
assert "let savedJson = JSON.stringify(network);" in _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts"
)
e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts")
assert "dirty: () => repPending > 0" in e03 and "repPending -= 1" in e03
def test_de_pages_shared_project_and_registry():
for path in DE_PAGES:
ts = _src(path)
assert "CURRENT_PROJECT_ID_KEY" not in ts and "localStorage" not in ts, path
assert "sessionStorage" not in ts, path
if path.endswith("_UI_Page.ts"):
assert "const projectId = currentProjectId();" in ts, path
registry = _src("A00_Common/b_page_state.ts")
for name in DE_STATES:
assert re.search(rf'"{name}": \{{[^}}]*bucket: "draft",[^}}]*scope: "project"', registry), (
name
)