실측(B05 진입): auth/session 4회, workflow-state 2회, 구조물 이관 POST 가 진입할 때마다 나갔다. - auth/session: 라우터 가드·상단 바·워크플로 확인이 각각 부르던 것을 5초 캐시로 묶음. 로그인·로그아웃에서 즉시 버린다. - workflow-state: 공용 창구 하나로 모으고 같은 방식으로 캐시. 상단 오버레이가 따로 부르던 경로를 그 창구로 돌림. 단계 이동에서 캐시를 버린다. - 구 구조물 측점 이관 POST: 탭 수명 동안 노선마다 한 번만. 실측 결과: B05 첫 진입 20 → 18건(핵심 API 10건), B06→B05 이동 7건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
import "./ui_template_overlay.css";
|
||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||
import { t } from "./ui_template_locale";
|
||
import { makePanelDraggable } from "./ui_template_overlay_drag";
|
||
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
|
||
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
|
||
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
|
||
|
||
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
|
||
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
|
||
const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos";
|
||
|
||
export interface WorkflowOverlayOptions {
|
||
title: string;
|
||
progressContent: HTMLElement;
|
||
optionsContent?: HTMLElement;
|
||
showTitlePanel?: boolean;
|
||
onOptionsOpenChange?: (isOpen: boolean) => void;
|
||
}
|
||
|
||
export interface WorkflowOverlayHandle {
|
||
root: HTMLElement;
|
||
setTitleOpen: (isOpen: boolean) => void;
|
||
setProgressOpen: (isOpen: boolean) => void;
|
||
}
|
||
|
||
export interface WorkflowPanelHandle {
|
||
root: HTMLButtonElement;
|
||
setOpen: (isOpen: boolean) => void;
|
||
}
|
||
|
||
export function createWorkflowPanelHandle(
|
||
placement: "side" | "bottom",
|
||
/**
|
||
* 패널이 접히면서 사라지는 쪽. 화살표가 항상 그 방향을 가리키게 만든다.
|
||
*
|
||
* 기본값 `"left"`는 왼쪽에 도킹해 왼쪽으로 접히는 패널(제목 카드)용이다.
|
||
* 오른쪽에 도킹해 오른쪽으로 접히는 패널(B05 배수유역도)은 `"right"`를 넘겨야
|
||
* 화살표가 뒤집히지 않는다 — 도킹 방향이 반대인데 같은 회전을 쓰면 열기/닫기
|
||
* 표시가 정확히 거꾸로 보인다.
|
||
*
|
||
* `placement: "bottom"`은 위아래로 접히는 패널이다. 기본은 아래로 접히는 하단 도킹
|
||
* 패널(`"down"`)이고, **위로 접히는 상단 도킹 패널**(B06 종단·유토곡선 패널)은 `"up"`을
|
||
* 넘겨야 한다. 좌우와 같은 이유로, 도킹 방향이 반대인데 같은 회전을 쓰면 표시가 거꾸로 된다.
|
||
*/
|
||
collapseToward: "left" | "right" | "up" | "down" = "left",
|
||
/**
|
||
* 손잡이 옆에 붙일 짧은 이름표(선택, 2026-08-04 사용자 지시 — 무엇의 손잡이인지 보이게).
|
||
* bottom 손잡이는 화살표 **오른쪽**에 가로로, side 손잡이는 화살표 **위쪽**에 90° 세로로
|
||
* 놓인다. 사이드바(제목 패널)처럼 이름이 필요 없는 곳은 생략하면 현행과 같다.
|
||
*/
|
||
label?: string,
|
||
): WorkflowPanelHandle {
|
||
const root = document.createElement("button");
|
||
root.type = "button";
|
||
root.className = `ui-workflow-overlay__handle ui-workflow-overlay__handle--${placement}`;
|
||
const icon = document.createElement("span");
|
||
icon.className = "ui-workflow-overlay__handle-icon";
|
||
if (label) {
|
||
root.classList.add("is-labeled");
|
||
const text = document.createElement("span");
|
||
text.className = "ui-workflow-overlay__handle-label";
|
||
text.textContent = label;
|
||
// side는 이름표가 화살표 위, bottom은 화살표 오른쪽 — DOM 순서로 자리를 정한다.
|
||
if (placement === "side") root.append(text, icon);
|
||
else root.append(icon, text);
|
||
} else {
|
||
root.append(icon);
|
||
}
|
||
|
||
function setOpen(isOpen: boolean): void {
|
||
// 삼각형은 회전 0°에서 오른쪽(▶)을 가리킨다. 펼친 상태면 접히는 쪽을,
|
||
// 접힌 상태면 펼쳐질 쪽을 가리키도록 90°/180°를 붙였다 뗀다.
|
||
const pointsRight = isOpen === (collapseToward === "right");
|
||
const pointsUp = isOpen === (collapseToward === "up");
|
||
const rotation = placement === "side" ? (pointsRight ? 0 : 180) : pointsUp ? -90 : 90;
|
||
icon.style.transform = `rotate(${rotation}deg)`;
|
||
root.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand");
|
||
root.setAttribute("aria-label", root.title);
|
||
root.setAttribute("aria-expanded", String(isOpen));
|
||
}
|
||
|
||
return { root, setOpen };
|
||
}
|
||
|
||
function readOpenState(key: string): boolean {
|
||
return sessionStorage.getItem(key) !== "false";
|
||
}
|
||
|
||
/**
|
||
* 사이드 패널 본문을 [스크롤 영역][고정 액션 영역] 두 칸으로 쪼갠다.
|
||
*
|
||
* 액션 줄(`.ui-sidebar-actions`)을 sticky로만 붙여 두면 본문 높이가 패널 높이를
|
||
* 넘나들 때마다 자리가 흔들린다(2026-08-18 사용자 보고). 액션 줄을 스크롤 대상에서
|
||
* 아예 빼고, 나머지 그룹만 마진·패딩 없는 래퍼(`.ui-sidebar-scroll`)에 담아 그쪽만
|
||
* 스크롤시킨다. 페이지는 지금처럼 본문 마지막에 액션 줄을 붙이기만 하면 된다.
|
||
*/
|
||
function splitSidebarActions(body: HTMLElement): void {
|
||
// 액션 줄은 보통 페이지 패널 루트(b05-route__panel 등) **안**에 있다 — body 직계가
|
||
// 아니므로 깊이 무관하게 찾고, 액션 줄의 실제 부모 기준으로 쪼갠다.
|
||
const actions = body.querySelector<HTMLElement>(".ui-sidebar-actions");
|
||
const container = actions?.parentElement;
|
||
if (!actions || !container) return;
|
||
const scroll = document.createElement("div");
|
||
scroll.className = "ui-sidebar-scroll";
|
||
// 액션 줄 앞의 형제만 옮긴다 — 뒤에 붙은 요소(없어야 정상)는 그대로 둔다.
|
||
while (container.firstChild && container.firstChild !== actions)
|
||
scroll.append(container.firstChild);
|
||
container.prepend(scroll);
|
||
body.classList.add("ui-sidebar-split");
|
||
// body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장).
|
||
for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) {
|
||
el.classList.add("ui-sidebar-fill");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시).
|
||
*
|
||
* 화면이 들고 있는 것은 프로젝트 id 뿐이라 워크플로 상태 응답에서 이름을 받아 온다 —
|
||
* 새로고침·주소 직접 입력으로 들어와도 따라온다. 이름이 없으면 칸을 비워 둔다.
|
||
* 여기 한 곳에 두어 제목 패널을 쓰는 화면(B03~B08)이 모두 같은 값을 보인다.
|
||
*/
|
||
function createProjectNameTag(): HTMLElement {
|
||
const tag = document.createElement("span");
|
||
tag.className = "ui-workflow-overlay__project";
|
||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||
if (!projectId) return tag;
|
||
void fetchWorkflowState(projectId)
|
||
.then((state) => {
|
||
const name = state.project_name ?? "";
|
||
tag.textContent = name;
|
||
// 이름이 길면 말줄임으로 자르고 전체는 툴팁으로 본다.
|
||
if (name) tag.title = name;
|
||
})
|
||
.catch(() => {
|
||
/* 조회 실패는 제목만 보이면 된다 — 빈 칸으로 둔다. */
|
||
});
|
||
return tag;
|
||
}
|
||
|
||
function createPanel(
|
||
variant: "title" | "progress",
|
||
titleText: string,
|
||
body: HTMLElement,
|
||
storageKey: string,
|
||
onOpenChange?: (isOpen: boolean) => void,
|
||
titleAside?: HTMLElement,
|
||
): { root: HTMLElement; setOpen: (isOpen: boolean) => void } {
|
||
const root = document.createElement("aside");
|
||
root.className = `ui-workflow-overlay__panel ui-workflow-overlay__panel--${variant}`;
|
||
|
||
// title 패널은 사이드바 도킹형 — 토글이 패널 우측 가장자리 세로 중앙 핸들로 나감
|
||
const isSidebar = variant === "title";
|
||
|
||
const header = document.createElement("div");
|
||
header.className = "ui-workflow-overlay__header";
|
||
const title = document.createElement("h2");
|
||
title.className = "ui-workflow-overlay__title";
|
||
title.textContent = titleText;
|
||
const panelHandle = isSidebar ? createWorkflowPanelHandle("side") : null;
|
||
const toggle = panelHandle?.root ?? document.createElement("button");
|
||
if (!panelHandle) {
|
||
toggle.type = "button";
|
||
toggle.className = "ui-workflow-overlay__toggle";
|
||
}
|
||
|
||
if (isSidebar) {
|
||
header.append(title);
|
||
// 제목은 왼쪽, 프로젝트 이름은 같은 행 오른쪽 끝 (2026-09-04 사용자 지시).
|
||
if (titleAside) header.append(titleAside);
|
||
root.append(header, toggle);
|
||
} else {
|
||
header.append(title, toggle);
|
||
root.append(header);
|
||
}
|
||
if (body.childElementCount > 0) {
|
||
body.classList.add("ui-workflow-overlay__body");
|
||
if (isSidebar) splitSidebarActions(body);
|
||
root.append(body);
|
||
}
|
||
|
||
let dragHandle: { refresh: () => void } | null = null;
|
||
|
||
function setOpen(isOpen: boolean): void {
|
||
root.classList.toggle("is-collapsed", !isOpen);
|
||
if (panelHandle) panelHandle.setOpen(isOpen);
|
||
else {
|
||
toggle.textContent = isOpen ? "−" : "+";
|
||
toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand");
|
||
toggle.setAttribute("aria-label", toggle.title);
|
||
toggle.setAttribute("aria-expanded", String(isOpen));
|
||
}
|
||
sessionStorage.setItem(storageKey, String(isOpen));
|
||
// 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다.
|
||
if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh());
|
||
onOpenChange?.(isOpen);
|
||
}
|
||
|
||
function toggleOpen(): void {
|
||
setOpen(root.classList.contains("is-collapsed"));
|
||
}
|
||
|
||
title.tabIndex = 0;
|
||
title.setAttribute("role", "button");
|
||
title.addEventListener("click", toggleOpen);
|
||
title.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
toggleOpen();
|
||
}
|
||
});
|
||
toggle.addEventListener("click", toggleOpen);
|
||
setOpen(readOpenState(storageKey));
|
||
// 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바).
|
||
if (variant === "progress") {
|
||
dragHandle = makePanelDraggable(root, header, PROGRESS_OVERLAY_POSITION_KEY);
|
||
}
|
||
return { root, setOpen };
|
||
}
|
||
|
||
export function createWorkflowOverlays(options: WorkflowOverlayOptions): WorkflowOverlayHandle {
|
||
const root = document.createElement("div");
|
||
root.className = "ui-workflow-overlay";
|
||
|
||
const progressBody = document.createElement("div");
|
||
progressBody.append(options.progressContent);
|
||
|
||
const progressPanel = createPanel(
|
||
"progress",
|
||
t("Workflow_Progress_Title"),
|
||
progressBody,
|
||
PROGRESS_OVERLAY_STATE_KEY,
|
||
);
|
||
let setTitleOpen = (_isOpen: boolean): void => {};
|
||
if (options.showTitlePanel !== false) {
|
||
const titleBody = document.createElement("div");
|
||
if (options.optionsContent) titleBody.append(options.optionsContent);
|
||
const titlePanel = createPanel(
|
||
"title",
|
||
options.title,
|
||
titleBody,
|
||
TITLE_OVERLAY_STATE_KEY,
|
||
options.onOptionsOpenChange,
|
||
createProjectNameTag(),
|
||
);
|
||
root.append(titlePanel.root);
|
||
setTitleOpen = titlePanel.setOpen;
|
||
}
|
||
root.append(progressPanel.root);
|
||
|
||
return {
|
||
root,
|
||
setTitleOpen,
|
||
setProgressOpen: progressPanel.setOpen,
|
||
};
|
||
}
|