feat(help): B02~B09 좌측 패널 [?] 설명서 모달 · 패널 안 설명 글을 파일로 옮김(54-2)

- [?] = 좌측 패널 아래 단추 묶음 맨 왼쪽(공용 한 벌 · B02 는 아래 단추 줄 · B08·B09 는 [?] 줄만)
- 모달 = 왼쪽 차례 · 오른쪽 글 · 고른 구조물 종류 · 연 상자 항목 먼저
- 글 = resources/help/<B0N>/*.md · 서버가 열 때마다 읽음(파일만 고치면 반영) · 설명서는 뼈대
- 옮김 = B03 안내 셋 · B05 구조물 저장 규칙 · 측구 주인 · 기슭막이 근거 · 칸 기본값 근거 · B06 표준 횡단면 두 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-06 10:17:07 +09:00
co-authored by Claude Opus 5.5
parent bcc3cbb407
commit 73e8d4ea56
49 changed files with 622 additions and 242 deletions
+84
View File
@@ -0,0 +1,84 @@
/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 */
.ui-sidebar-actions .ui-help-btn,
.ui-help-btn {
flex: none;
width: auto;
min-width: 2.25rem;
padding-inline: var(--spacing-12);
}
.ui-help.ui-modal {
width: min(960px, 92vw);
height: var(--modal-max, 80vh);
}
.ui-help .ui-modal__body {
flex: 1 1 auto;
min-height: 0;
}
.ui-help__grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--spacing-16);
height: 100%;
min-height: 0;
}
.ui-help__toc {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-right: var(--spacing-8);
border-right: 1px solid var(--color-border);
overflow-y: auto;
}
.ui-help__link {
padding: var(--spacing-4) var(--spacing-8);
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
font: inherit;
text-align: left;
cursor: pointer;
}
.ui-help__link:hover,
.ui-help__link.is-active {
background: color-mix(in srgb, var(--color-border) 60%, transparent);
color: var(--color-text-body);
}
.ui-help__text {
overflow-y: auto;
line-height: 1.6;
}
.ui-help__text h2 {
margin: 0 0 var(--spacing-12);
font-size: 1.4em;
}
.ui-help__text h3 {
margin: var(--spacing-16) 0 var(--spacing-4);
font-size: 1.1em;
}
.ui-help__text h4,
.ui-help__text h5 {
margin: var(--spacing-12) 0 var(--spacing-4);
font-size: 1em;
}
.ui-help__text p,
.ui-help__text ul,
.ui-help__text ol {
margin: 0 0 var(--spacing-8);
}
.ui-help__text ul,
.ui-help__text ol {
padding-left: 1.4em;
}
+146
View File
@@ -0,0 +1,146 @@
/* =============================================================================
* ui_template_help.ts
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
* 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.
* ========================================================================== */
import "./ui_template_help.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, el } from "./ui_template_elements";
import { openModal } from "./ui_template_modal";
interface HelpItem {
key: string;
title: string;
text: string;
}
const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" };
function currentPage(): string {
const n = /^#\/b0(\d)/i.exec(location.hash)?.[1];
return n ? `B0${n}` : "";
}
function escapeHtml(text: string): string {
return text.replace(
/[&<>"]/g,
(c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`,
);
}
function inline(text: string): string {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`(.+?)`/g, "<code>$1</code>");
}
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */
export function renderHelpMarkdown(text: string): string {
const out: string[] = [];
let list: "ul" | "ol" | null = null;
let para: string[] = [];
const flush = (): void => {
if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`);
para = [];
if (list) out.push(`</${list}>`);
list = null;
};
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
const head = /^(#{1,4})\s+(.*)$/.exec(line);
const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line);
if (!line) flush();
else if (head) {
flush();
out.push(`<h${head[1].length + 1}>${inline(head[2])}</h${head[1].length + 1}>`);
} else if (item) {
const kind = item[1] ? "ul" : "ol";
if (para.length || list !== kind) flush();
if (!list) out.push(`<${kind}>`);
list = kind;
out.push(`<li>${inline(item[2])}</li>`);
} else {
if (list) flush();
para.push(line);
}
}
flush();
return out.join("");
}
function visibleTopics(): string[] {
const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document;
return [...panel.querySelectorAll<HTMLElement>("[data-help-topic]")]
.filter((node) => node.offsetParent !== null)
.map((node) => node.dataset.helpTopic ?? "");
}
async function fetchItems(page: string): Promise<HelpItem[]> {
const response = await fetch(`${API_BASE_URL}/help/${page}`, {
credentials: "include",
cache: "no-store",
});
return response.ok ? ((await response.json()) as { items: HelpItem[] }).items : [];
}
/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */
const PAIR: Record<string, string> = { B05: "B06", B06: "B05" };
async function openHelp(): Promise<void> {
const page = currentPage();
const pair = PAIR[page];
const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]);
const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))];
const topics = visibleTopics();
const first = [...topics].reverse().find((key) => items.some((item) => item.key === key));
openModal({
title: `${TEXT.title} · ${page}`,
closeLabel: TEXT.close,
dialogClass: "ui-help",
mount: (body) => {
const article = el("article", { className: "ui-help__text" });
const toc = el("nav", { className: "ui-help__toc" });
const show = (key: string): void => {
const item = items.find((entry) => entry.key === key);
article.innerHTML = item ? renderHelpMarkdown(item.text) : `<p>${TEXT.empty}</p>`;
article.dataset.helpKey = key;
toc
.querySelectorAll("button")
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key));
};
for (const item of items) {
const link = el("button", {
className: "ui-help__link",
text: item.title,
attrs: { type: "button" },
});
link.dataset.helpKey = item.key;
link.addEventListener("click", () => show(item.key));
toc.append(link);
}
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
show(first ?? items[0]?.key ?? "");
},
});
}
export function createHelpButton(): HTMLButtonElement {
const button = createButton({
label: TEXT.button,
variant: "ghost",
onClick: () => void openHelp(),
});
button.classList.add("ui-help-btn");
button.title = button.ariaLabel = TEXT.tip;
return button;
}
/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */
export function placeHelpButton(actions: HTMLElement): void {
if (actions.querySelector(".ui-help-btn")) return;
(actions.querySelector<HTMLElement>("[data-help-row]") ?? actions).prepend(createHelpButton());
}
-40
View File
@@ -228,46 +228,6 @@ export const ui_locales_b1 = {
"Upload the required planned route, terrain, and point cloud files.",
],
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
/* --- 좌측 안내 패널 (2026-09-03) --- */
B03_Guide_Files_Title: ["필요한 파일", "Files you need"],
B03_Guide_Files_Route: [
"계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.",
"Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).",
],
B03_Guide_Files_Terrain: [
"지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.",
"Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.",
],
B03_Guide_Files_Optional: [
"지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.",
"The terrain TIF (DEM) is optional — surface analysis runs without it.",
],
B03_Guide_How_Title: ["고르는 방법", "How to pick"],
B03_Guide_How_Drop: [
"여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.",
"Drop several files at once — each is assigned to a card by its extension.",
],
B03_Guide_How_Card: [
"카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.",
"Use a card's [Select] to set one slot, or ✕ to clear it.",
],
B03_Guide_How_Temp: [
"[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.",
"[Temporary storage] pulls files you staged on the dashboard.",
],
B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"],
B03_Guide_Notes_Replace: [
"이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.",
"Uploading again to a finished project replaces the existing analysis.",
],
B03_Guide_Notes_Crs: [
"작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.",
"The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.",
],
B03_Guide_Notes_LasFree: [
"라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.",
"With no LiDAR, turn on [Design without LAS] and work from sheet contours.",
],
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
B03_File_Upload_Button: ["파일 업로드", "Upload files"],
-8
View File
@@ -528,10 +528,6 @@ export const ui_locales_b2 = {
B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"],
B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"],
B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"],
B06_Std_LType_Note: [
"L형 측구는 각 횡단면도에서 선택합니다.",
"L-type ditch is chosen per cross-section drawing.",
],
B06_Std_Reset: ["기본값 복원", "Restore defaults"],
B06_Std_ApplyAll: ["전체 측점 반영", "Apply to all stations"],
B06_Std_ApplyAll_Success: [
@@ -563,10 +559,6 @@ export const ui_locales_b2 = {
B06_Std_Diagram_Fill: ["성토경사", "Fill slope"],
B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"],
B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"],
B06_Std_Diagram_Caption: [
"표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).",
"Standard cut-fill section schematic — shows where each value applies (not to scale).",
],
/* --- B07_DesignDetail 상세 설계 --- */
B07_Design_Title: ["상세설계", "Detail Design"],
+2
View File
@@ -1,6 +1,7 @@
import "./ui_template_overlay.css";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { t } from "./ui_template_locale";
import { placeHelpButton } from "./ui_template_help";
import { makePanelDraggable } from "./ui_template_overlay_drag";
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
@@ -126,6 +127,7 @@ export function splitSidebarActions(body: HTMLElement): void {
while (container.firstChild && container.firstChild !== actions)
scroll.append(container.firstChild);
container.prepend(scroll);
placeHelpButton(actions);
body.classList.add("ui-sidebar-split");
// body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장).
for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) {
+8 -1
View File
@@ -162,6 +162,12 @@ export function createStepBar(
return bar;
}
function helpOnlyRow(): HTMLElement {
const row = document.createElement("div");
row.className = "ui-sidebar-actions";
return row;
}
export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLayoutHandle {
const root = document.createElement("div");
root.className = "ui-workflow-layout";
@@ -185,7 +191,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
});
const overlays = createWorkflowOverlays({
title: options.title,
optionsContent: options.leftPanel,
// 좌측 패널이 없는 화면(B08 · B09)도 [?] 줄은 둠(54-2)
optionsContent: options.leftPanel ?? helpOnlyRow(),
progressContent,
onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen),
});