260706_0_회사싱크 후 커밋

This commit is contained in:
2026-07-07 17:02:12 +09:00
parent 3abc2edba6
commit 404941c47a
117 changed files with 2060 additions and 58423 deletions
+99
View File
@@ -0,0 +1,99 @@
/* =============================================================================
* B05_wf2_Route_Api_Fetch.ts
* 2차 워크플로우(경로 설계) API 클라이언트
*
* 백엔드 계약 (B05_wf2_Route_Router.py):
* POST /api/projects/{project_id}/route/solve → 경로 탐색 + DB 기록
* POST /api/projects/{project_id}/route/confirm → 최신 경로 확정
*
* 규칙:
* - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지).
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend";
/** 경로 제어점 (BP/EP/CP) */
export interface RoutePoint {
x: number;
y: number;
order?: number;
}
/** 경로 탐색 실행 요청 (RouteSolveRequest) */
export interface RouteSolveRequest {
filter_key: string;
method?: string;
smooth?: boolean;
surface_model_id?: number | null;
algorithm?: string;
bp: RoutePoint;
ep: RoutePoint;
cp?: RoutePoint[];
grade_class?: string;
min_curve_radius_m?: number | null;
max_uphill_grade?: number | null;
max_downhill_grade?: number | null;
}
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
export interface RouteSolveResponse {
status: string;
project_id: string;
route_id: number;
total_length_m: number;
metrics: Record<string, unknown>;
required_points_ok: boolean;
route_data_path: string;
}
/** 경로 확정 결과 (RouteConfirmResponse) */
export interface RouteConfirmResponse {
status: string;
project_id: string;
route_id: number;
confirmed: boolean;
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
const token = localStorage.getItem(AUTH_TOKEN_KEY);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers ?? {}),
},
signal: controller.signal,
});
const payload = (await response.json()) as T & { message?: string };
if (!response.ok) {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
} finally {
window.clearTimeout(timeoutId);
}
}
/** 경로 탐색을 실행한다 (비용면 생성 → Dijkstra/ridge-valley → GeoJSON 저장). */
export async function solveRoute(
projectId: string,
request: RouteSolveRequest,
): Promise<RouteSolveResponse> {
return requestJson<RouteSolveResponse>(`/projects/${projectId}/route/solve`, {
method: "POST",
body: JSON.stringify(request),
});
}
/** 프로젝트의 최신 경로를 확정한다. */
export async function confirmRoute(projectId: string): Promise<RouteConfirmResponse> {
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
method: "POST",
});
}
+326 -9
View File
@@ -2,27 +2,344 @@
* B05_wf2_Route_UI_Page.ts
* 로그인 후 05: 2차 워크플로우 (경로 설계)
*
* ⚠️ 좌측 입력 패널 / 우측 WebCAD 뷰어 본문은 준비 중 — 워크플로우 셸(헤더+
* 스텝바 = 3단 레이아웃)만 구성. 실제 본문은 0_old 참고하여 추후 구체화.
* 3단 레이아웃 (frontend.md §2):
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell)
* 좌측: 경로 제어점(BP/EP/CP) 좌표 + 기반 지표면 + 설계 제약 폼
* 우측: 경로 탐색 결과(연장·경사·비용) 카드
*
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowShell 재사용.
* 이벤트 핸들러 명명 (frontend.md §4): onB05_Route_[기능]_[액션]
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createWorkflowShell,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
type InputFieldHandle,
} from "@ui/ui_template_elements";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
confirmRoute,
solveRoute,
type RoutePoint,
type RouteSolveResponse,
} from "./B05_wf2_Route_Api_Fetch";
import "./B05_wf2_Route_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
/** 임도 등급 (config_system.ROUTE_GRADE_CLASSES) */
const GRADE_CLASSES = ["trunk", "branch", "work"] as const;
/** 경로 알고리즘 (Schema.algorithm 허용값) */
const ALGORITHMS = ["dijkstra", "ridge_valley"] as const;
/** 라벨 + select 요소 하나 생성. */
function buildSelect(
label: string,
values: readonly string[],
): { root: HTMLElement; select: HTMLSelectElement } {
const root = document.createElement("div");
root.className = "ui-field";
const labelEl = document.createElement("label");
labelEl.className = "ui-field__label";
labelEl.textContent = label;
const select = document.createElement("select");
select.className = "ui-input b05-route__select";
for (const value of values) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
root.append(labelEl, select);
return { root, select };
}
/** X/Y 좌표 한 쌍 입력 행 생성. */
function buildPointRow(label: string): {
root: HTMLElement;
x: InputFieldHandle;
y: InputFieldHandle;
} {
const root = document.createElement("div");
root.className = "b05-route__point";
const caption = document.createElement("span");
caption.className = "b05-route__point-label";
caption.textContent = label;
const x = createInputField({ label: L("B05_Route_Field_X"), type: "number" });
const y = createInputField({ label: L("B05_Route_Field_Y"), type: "number" });
const row = document.createElement("div");
row.className = "b05-route__point-row";
row.append(x.root, y.root);
root.append(caption, row);
return { root, x, y };
}
/** 숫자 입력값을 파싱. 빈 값이면 null. */
function parseNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
export function renderB05Route(root: HTMLElement): void {
renderPendingWorkflow(root, {
const shell = createWorkflowShell({
title: L("B05_Route_Title"),
steps: workflowSteps(),
activeStep: 1,
});
/* ---- 좌측: 경로 제어점 ---- */
const pointsGroup = document.createElement("fieldset");
pointsGroup.className = "b05-route__group";
const pointsLegend = document.createElement("legend");
pointsLegend.className = "b05-route__group-legend";
pointsLegend.textContent = L("B05_Route_Group_Points");
const bpRow = buildPointRow(L("B05_Route_Point_BP"));
const epRow = buildPointRow(L("B05_Route_Point_EP"));
const cpContainer = document.createElement("div");
cpContainer.className = "b05-route__cp-list";
const cpRows: { root: HTMLElement; x: InputFieldHandle; y: InputFieldHandle }[] = [];
function onB05_Route_AddCp_Click(): void {
const row = buildPointRow(L("B05_Route_Point_CP"));
const removeBtn = createButton({
label: L("B05_Route_Btn_RemoveCp"),
variant: "ghost",
onClick: () => {
const idx = cpRows.indexOf(row);
if (idx >= 0) cpRows.splice(idx, 1);
row.root.remove();
},
});
row.root.append(removeBtn);
cpRows.push(row);
cpContainer.append(row.root);
}
const addCpButton = createButton({
label: L("B05_Route_Btn_AddCp"),
variant: "ghost",
onClick: () => onB05_Route_AddCp_Click(),
});
pointsGroup.append(pointsLegend, bpRow.root, epRow.root, cpContainer, addCpButton);
/* ---- 좌측: 기반 지표면 ---- */
const surfaceGroup = document.createElement("fieldset");
surfaceGroup.className = "b05-route__group";
const surfaceLegend = document.createElement("legend");
surfaceLegend.className = "b05-route__group-legend";
surfaceLegend.textContent = L("B05_Route_Group_Surface");
const filterField = createInputField({ label: L("B05_Route_Field_Filter"), type: "text" });
const methodField = createInputField({
label: L("B05_Route_Field_Method"),
type: "text",
value: "dtm",
});
const surfaceIdField = createInputField({
label: L("B05_Route_Field_SurfaceId"),
type: "number",
min: 1,
});
surfaceGroup.append(surfaceLegend, filterField.root, methodField.root, surfaceIdField.root);
/* ---- 좌측: 설계 제약 ---- */
const constraintGroup = document.createElement("fieldset");
constraintGroup.className = "b05-route__group";
const constraintLegend = document.createElement("legend");
constraintLegend.className = "b05-route__group-legend";
constraintLegend.textContent = L("B05_Route_Group_Constraints");
const gradeSelect = buildSelect(L("B05_Route_Field_GradeClass"), GRADE_CLASSES);
const algorithmSelect = buildSelect(L("B05_Route_Field_Algorithm"), ALGORITHMS);
const maxUphillField = createInputField({
label: L("B05_Route_Field_MaxUphill"),
type: "number",
});
const maxDownhillField = createInputField({
label: L("B05_Route_Field_MaxDownhill"),
type: "number",
});
const minRadiusField = createInputField({
label: L("B05_Route_Field_MinRadius"),
type: "number",
});
const smoothLabel = document.createElement("label");
smoothLabel.className = "b05-route__check";
const smoothBox = document.createElement("input");
smoothBox.type = "checkbox";
const smoothText = document.createElement("span");
smoothText.textContent = L("B05_Route_Field_Smooth");
smoothLabel.append(smoothBox, smoothText);
constraintGroup.append(
constraintLegend,
gradeSelect.root,
algorithmSelect.root,
maxUphillField.root,
maxDownhillField.root,
minRadiusField.root,
smoothLabel,
);
const solveButton = createButton({
label: L("B05_Route_Btn_Solve"),
variant: "filled",
onClick: () => void onB05_Route_Solve_Click(),
});
const confirmButton = createButton({
label: L("B05_Route_Btn_Confirm"),
variant: "ghost",
onClick: () => void onB05_Route_Confirm_Click(),
});
const actionRow = document.createElement("div");
actionRow.className = "b05-route__actions";
actionRow.append(solveButton, confirmButton);
const leftForm = document.createElement("div");
leftForm.className = "b05-route__form";
leftForm.append(pointsGroup, surfaceGroup, constraintGroup, actionRow);
shell.leftPanel.append(leftForm);
/* ---- 우측: 결과 ---- */
const resultTitle = document.createElement("h3");
resultTitle.className = "b05-route__result-title";
resultTitle.textContent = L("B05_Route_Result_Title");
const resultBody = document.createElement("div");
resultBody.className = "b05-route__result-body";
function renderEmptyResult(): void {
resultBody.replaceChildren();
const empty = document.createElement("p");
empty.className = "b05-route__empty";
empty.textContent = L("B05_Route_Result_Empty");
resultBody.append(empty);
}
function metricRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b05-route__metric";
const key = document.createElement("span");
key.className = "b05-route__metric-key";
key.textContent = label;
const val = document.createElement("span");
val.className = "b05-route__metric-val";
val.textContent = value;
row.append(key, val);
return row;
}
function renderResult(result: RouteSolveResponse): void {
resultBody.replaceChildren();
const metrics = result.metrics as Record<string, number | undefined>;
const fmt = (v: number | undefined): string => (v === undefined ? "-" : v.toFixed(3));
resultBody.append(
metricRow(L("B05_Route_Result_Length"), result.total_length_m.toFixed(2)),
metricRow(L("B05_Route_Result_MinSlope"), fmt(metrics.min_slope)),
metricRow(L("B05_Route_Result_MaxSlope"), fmt(metrics.max_slope)),
metricRow(L("B05_Route_Result_MeanSlope"), fmt(metrics.mean_slope)),
metricRow(L("B05_Route_Result_Cost"), fmt(metrics.cost_score)),
metricRow(L("B05_Route_Result_Path"), result.route_data_path),
);
}
const resultCard = document.createElement("div");
resultCard.className = "b05-route__result";
resultCard.append(resultTitle, resultBody);
shell.rightArea.append(resultCard);
/* ---- 이벤트 핸들러 ---- */
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) showToast(L("B05_Route_Error_Project"), "error");
return projectId;
}
/** 필수 좌표(BP/EP)와 필터 키를 검증하고, 유효하면 요청 페이로드를 반환. */
function collectRequest(): {
filter_key: string;
bp: RoutePoint;
ep: RoutePoint;
cp: RoutePoint[];
} | null {
const bpX = parseNumber(bpRow.x.input.value);
const bpY = parseNumber(bpRow.y.input.value);
const epX = parseNumber(epRow.x.input.value);
const epY = parseNumber(epRow.y.input.value);
if (bpX === null || bpY === null || epX === null || epY === null) {
showToast(L("B05_Route_Error_Points"), "error");
return null;
}
const filterKey = filterField.input.value.trim();
if (!filterKey) {
filterField.setError(L("B05_Route_Error_Filter"));
return null;
}
filterField.setError();
const cp: RoutePoint[] = [];
for (const row of cpRows) {
const x = parseNumber(row.x.input.value);
const y = parseNumber(row.y.input.value);
if (x !== null && y !== null) cp.push({ x, y });
}
return { filter_key: filterKey, bp: { x: bpX, y: bpY }, ep: { x: epX, y: epY }, cp };
}
async function onB05_Route_Solve_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
const base = collectRequest();
if (!base) return;
const surfaceId = parseNumber(surfaceIdField.input.value);
showLoadingOverlay();
try {
const result = await solveRoute(projectId, {
...base,
method: methodField.input.value.trim() || "dtm",
smooth: smoothBox.checked,
surface_model_id: surfaceId,
algorithm: algorithmSelect.select.value,
grade_class: gradeSelect.select.value,
max_uphill_grade: parseNumber(maxUphillField.input.value),
max_downhill_grade: parseNumber(maxDownhillField.input.value),
min_curve_radius_m: parseNumber(minRadiusField.input.value),
});
renderResult(result);
showToast(L("B05_Route_Solve_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? error.message : L("B05_Route_Solve_Failed");
showToast(`${L("B05_Route_Solve_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
async function onB05_Route_Confirm_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
showLoadingOverlay();
try {
await confirmRoute(projectId);
showToast(L("B05_Route_Confirm_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? error.message : L("B05_Route_Confirm_Failed");
showToast(`${L("B05_Route_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
renderEmptyResult();
root.replaceChildren(shell.root);
}
+132
View File
@@ -0,0 +1,132 @@
/* =============================================================================
* B05_wf2_Route_UI_Style.css
* 2차 워크플로우(경로 설계) 페이지 전용 스타일.
*
* 원칙(frontend.md §1): 하드코딩 색상 금지. theme.css 변수(var(--...))만 참조.
* 공통 컴포넌트 스타일은 ui_template_elements.ts가 주입하므로 여기서는
* B05 고유 레이아웃(제어점 행/제약 그룹/결과 메트릭)만 정의한다.
* ========================================================================== */
/* --- 좌측 입력 폼 --- */
.b05-route__form {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.b05-route__group {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
margin: 0;
padding: var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b05-route__group-legend {
padding: 0 var(--spacing-8);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
/* --- 제어점 (X/Y 쌍) --- */
.b05-route__point {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b05-route__point-label {
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-body);
}
.b05-route__point-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.b05-route__cp-list {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
/* --- select (지표면/제약) --- */
.b05-route__select {
appearance: auto;
cursor: pointer;
}
/* --- 체크박스 --- */
.b05-route__check {
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
color: var(--color-text-body);
cursor: pointer;
}
.b05-route__check input {
accent-color: var(--color-primary);
}
/* --- 액션 버튼 행 --- */
.b05-route__actions {
display: flex;
gap: var(--spacing-8);
}
/* --- 우측 결과 --- */
.b05-route__result {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
padding: var(--spacing-24);
}
.b05-route__result-title {
font-size: var(--text-subheading);
color: var(--color-text);
}
.b05-route__empty {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.b05-route__result-body {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
max-width: 480px;
}
.b05-route__metric {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-16);
padding: var(--spacing-8) var(--spacing-16);
border-radius: var(--radius-inputs);
background-color: var(--color-surface);
}
.b05-route__metric-key {
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.b05-route__metric-val {
font-family: var(--font-mono);
font-size: var(--text-body-sm);
color: var(--color-text-body);
word-break: break-all;
text-align: right;
}