Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1

This commit is contained in:
2026-09-04 19:14:09 +09:00
35 changed files with 727 additions and 92 deletions
+2
View File
@@ -14,6 +14,8 @@ import { navigateTo } from "./router";
export interface WorkflowState {
project_id: string;
/** 좌측 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). */
project_name?: string | null;
current_stage: number;
stages: WorkflowStage[];
}
+15 -4
View File
@@ -29,6 +29,8 @@ export interface WorkflowStageState {
export interface WorkflowState {
project_id: string;
/** 좌측 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). */
project_name?: string | null;
current_stage: number;
stages: WorkflowStageState[];
}
@@ -41,6 +43,9 @@ export interface ProjectItem {
road_type?: string | null;
project_year?: number | null;
estimated_length_m?: number | null;
/** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */
route_start_m?: number | null;
route_end_m?: number | null;
memo?: string | null;
status?: string | null;
/** 도면 표제란·표지에 실리는 값 — 프로그램이 지어낼 수 없어 사람이 넣는다 */
@@ -152,6 +157,9 @@ export interface UpdateProjectRequest {
road_type?: string | null;
project_year?: number | null;
estimated_length_m?: number | null;
/** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */
route_start_m?: number | null;
route_end_m?: number | null;
memo?: string | null;
status?: string | null;
client_org?: string | null;
@@ -412,8 +420,11 @@ export async function fetchSystemResources(days = 30): Promise<ResourceData> {
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
}
export function fetchProjectWorkflowState(projectId: string): Promise<WorkflowState> {
return request(`/projects/${projectId}/workflow-state`, {
method: "GET",
}) as Promise<WorkflowState>;
export async function fetchProjectWorkflowState(projectId: string): Promise<WorkflowState> {
// 응답은 `{status, workflow_state}` 껍데기로 온다 — 벗겨서 상태만 넘긴다.
const data = await request<{ workflow_state?: WorkflowState } & WorkflowState>(
`/projects/${projectId}/workflow-state`,
{ method: "GET" },
);
return data.workflow_state ?? data;
}
+11 -5
View File
@@ -158,7 +158,8 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status, updated_at, created_at,
estimated_length_m, route_start_m, route_end_m,
memo, status, updated_at, created_at,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id,
logo_asset_id, signature_asset_id
@@ -174,7 +175,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.estimated_length_m, p.route_start_m, p.route_end_m,
p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount, p.design_date,
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
p.logo_asset_id, p.signature_asset_id,
@@ -192,7 +194,8 @@ async def list_all_projects() -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.estimated_length_m, p.route_start_m, p.route_end_m,
p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount, p.design_date,
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
p.logo_asset_id, p.signature_asset_id,
@@ -209,7 +212,7 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, user_id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status,
estimated_length_m, route_start_m, route_end_m, memo, status,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id,
logo_asset_id, signature_asset_id
@@ -226,7 +229,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
await cursor.execute(
"""UPDATE projects
SET name = %s, region = %s, road_type = %s, project_year = %s,
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status),
estimated_length_m = %s, route_start_m = %s, route_end_m = %s,
memo = %s, status = COALESCE(%s, status),
client_org = %s, project_number = %s, work_amount = %s,
design_date = %s, pm_user_id = %s, field_lead_user_id = %s,
designer_user_id = %s, logo_asset_id = %s, signature_asset_id = %s
@@ -237,6 +241,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
data.get("road_type"),
data.get("project_year"),
data.get("estimated_length_m"),
data.get("route_start_m"),
data.get("route_end_m"),
data.get("memo"),
data.get("status"),
data.get("client_org"),
+7
View File
@@ -264,6 +264,13 @@ async def dashboard_update_project(
if not _can_edit_project(session, project):
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
data = payload.model_dump()
# 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시).
start_m, end_m = data.get("route_start_m"), data.get("route_end_m")
if start_m is not None and end_m is not None and start_m >= end_m:
raise HTTPException(
status_code=400,
detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.",
)
await check_project_refs(int(project["company_id"]), data)
if not await update_project(project_id, data, int(session["user_id"])):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
+3
View File
@@ -56,6 +56,9 @@ class UpdateProjectRequest(BaseModel):
road_type: str | None = Field(default=None, max_length=100)
project_year: int | None = Field(default=None, ge=1900, le=2100)
estimated_length_m: float | None = Field(default=None, ge=0)
# 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간.
route_start_m: float | None = Field(default=None, ge=0)
route_end_m: float | None = Field(default=None, ge=0)
memo: str | None = Field(default=None, max_length=5000)
status: str | None = Field(default=None, max_length=50)
# 도면 표제란·표지에 실리는 값 (2026-09-02). 프로그램이 지어낼 수 없어 사람이 넣는다.
+37 -4
View File
@@ -14,6 +14,7 @@ import {
type CompanyAsset,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
export interface AssetFieldHandle {
root: HTMLDivElement;
@@ -25,6 +26,13 @@ export interface AssetFieldOptions {
owner?: { id: number; name: string } | null;
/** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */
onChange?: (assetId: number | null) => void | Promise<void>;
/**
* 비었을 때 대신 보여 줄 기본값 — 프로젝트 로고를 안 고르면 도면에는 회사 로고가
* 실린다(`COALESCE(p.logo_asset_id, c.logo_asset_id)`). 그 사실을 화면에도 보인다
* (2026-09-04 사용자 지시). **저장값은 계속 null** — 회사 로고를 바꾸면 따라가야 하므로
* 값을 복사해 굳히지 않는다.
*/
fallback?: { asset: CompanyAsset | null; prefix: string; missingNote: string };
}
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
@@ -60,10 +68,29 @@ export function createAssetField(
const name = document.createElement("span");
name.className = "b01-dashboard__asset-name";
const fallback = options.fallback ?? null;
const reset = createButton({
label: "기본으로",
variant: "ghost",
onClick: () => {
selected = null;
render();
void options.onChange?.(null);
},
});
const render = (): void => {
preview.hidden = !selected;
if (selected) preview.src = companyAssetFileUrl(selected.id);
name.textContent = selected ? selected.label : "(없음)";
const shown = selected ?? fallback?.asset ?? null;
preview.hidden = !shown;
if (shown) preview.src = companyAssetFileUrl(shown.id);
if (selected) name.textContent = selected.label;
else if (fallback) {
name.textContent = fallback.asset
? `${fallback.prefix} · ${fallback.asset.label}`
: fallback.missingNote;
} else name.textContent = "(없음)";
// 프로젝트 전용 값을 골랐을 때만 기본 연결로 되돌릴 거리가 생긴다.
reset.hidden = !fallback || selected === null;
};
render();
@@ -87,6 +114,7 @@ export function createAssetField(
),
});
row.append(preview, name, pick);
if (fallback) row.append(reset);
root.append(caption, row);
return { root, value: () => selected?.id ?? null };
}
@@ -209,6 +237,7 @@ function openAssetPickerModal(
const grid = document.createElement("div");
grid.className = "b01-dashboard__asset-grid";
let dismiss: ModalDismissHandle | null = null;
const close = (): void => modal.remove();
const choose = (asset: CompanyAsset | null): void => {
onPick(asset, list);
@@ -308,10 +337,14 @@ function openAssetPickerModal(
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
actions.append(
createButton({ label: "닫기", variant: "ghost", onClick: () => void dismiss?.tryClose() }),
);
panel.append(heading, grid, addTitle, label.root, file.root);
if (pad) panel.append(pad.root);
panel.append(mine, add, actions);
modal.append(panel);
document.body.append(modal);
// 「신규 추가」에 이름·파일을 넣어 두고 바깥을 누르면 한 번 묻는다 (2026-09-04 사용자 지시).
dismiss = attachModalDismiss(modal, panel);
}
+110 -1
View File
@@ -1,5 +1,10 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import {
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
/**
@@ -33,3 +38,107 @@ export async function runRequest(action: () => Promise<unknown>): Promise<void>
export function formatDate(value?: string | null): string {
return value ? value.slice(0, 10) : "-";
}
/* -----------------------------------------------------------------------------
* 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시)
*
* 껍데기를 만드는 자리가 세 곳(Modals·AssetPicker·TempModal)이라 규칙을 여기 한 곳에
* 둔다. 고친 게 있으면 공용 `showConfirmDialog` 로 한 번 묻고, 없으면 바로 닫는다.
* -------------------------------------------------------------------------- */
/** 모달 안 입력값을 한 줄로 떠 둔다 — 열 때와 닫을 때를 견주어 변경을 판정한다. */
export function snapshotModalFields(panel: HTMLElement): string {
const parts: string[] = [];
for (const node of panel.querySelectorAll("input, select, textarea")) {
if (node instanceof HTMLInputElement && node.type === "checkbox") {
parts.push(node.checked ? "1" : "0");
} else if (node instanceof HTMLInputElement && node.type === "file") {
parts.push(
Array.from(node.files ?? [])
.map((file) => `${file.name}:${file.size}`)
.join(","),
);
} else {
parts.push((node as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).value);
}
}
return parts.join("\u0001");
}
export interface ModalDismissOptions {
/** 입력칸 밖에서 바뀌는 값(고른 로고·고른 파일 목록)을 덧붙인다. */
extra?: () => string;
/** 실제로 닫는 동작 — 기본은 모달 제거. */
close?: () => void;
}
export interface ModalDismissHandle {
isDirty: () => boolean;
/** 변경이 있으면 확인창을 거쳐 닫는다 — [취소] 단추도 이것을 쓴다. */
tryClose: () => Promise<void>;
}
export function attachModalDismiss(
modal: HTMLElement,
panel: HTMLElement,
options: ModalDismissOptions = {},
): ModalDismissHandle {
const extraOf = (): string => options.extra?.() ?? "";
let baseline = snapshotModalFields(panel);
const extraBaseline = extraOf();
// 값을 나중에 채우는 칸(비동기 조회)이 있어, 사용자가 아직 손대기 전이면 기준을 다시 뜬다.
let touched = false;
const mark = (): void => {
touched = true;
};
panel.addEventListener("input", mark, true);
panel.addEventListener("change", mark, true);
const isDirty = (): boolean => {
if (extraOf() !== extraBaseline) return true;
const now = snapshotModalFields(panel);
if (!touched) {
baseline = now;
return false;
}
return now !== baseline;
};
const onKey = (event: KeyboardEvent): void => {
if (!modal.isConnected) {
document.removeEventListener("keydown", onKey, true);
return;
}
if (event.key !== "Escape") return;
// 확인창이 떠 있으면 그쪽이 먼저고, 모달이 여럿이면 맨 위 것만 닫는다.
if (document.querySelector(".ui-confirm")) return;
const opened = document.querySelectorAll(".b01-dashboard__modal");
if (opened[opened.length - 1] !== modal) return;
event.stopPropagation();
void tryClose();
};
const close = (): void => {
document.removeEventListener("keydown", onKey, true);
if (options.close) options.close();
else modal.remove();
};
const tryClose = async (): Promise<void> => {
if (!isDirty()) return close();
const ok = await showConfirmDialog("변경한 내용이 저장되지 않습니다. 닫을까요?", "닫기");
if (ok) close();
};
// 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다.
let downOnOverlay = false;
modal.addEventListener("mousedown", (event) => {
downOnOverlay = event.target === modal;
});
modal.addEventListener("click", (event) => {
if (event.target === modal && downOnOverlay) void tryClose();
});
document.addEventListener("keydown", onKey, true);
return { isDirty, tryClose };
}
+80 -25
View File
@@ -19,6 +19,7 @@ import {
addCompanyMember,
fetchCompanyMembers,
fetchCompanyAssets,
fetchUserCompany,
updateCompanyAsset,
createCompanyAsset,
setCompanyLogo,
@@ -27,6 +28,7 @@ import {
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */
const NEW_MEMBER = "__new__";
@@ -35,7 +37,12 @@ function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<void>): void {
function openModal(
title: string,
body: HTMLElement[],
onConfirm: () => Promise<void>,
options: { extra?: () => string } = {},
): void {
const modal = document.createElement("div");
modal.className = "b01-dashboard__modal";
const panel = document.createElement("div");
@@ -45,11 +52,13 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<
heading.textContent = title;
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
// 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다.
let dismiss: ModalDismissHandle | null = null;
actions.append(
createButton({
label: L("Common_Btn_Cancel"),
variant: "ghost",
onClick: () => modal.remove(),
onClick: () => void dismiss?.tryClose(),
}),
createButton({
label: L("Common_Btn_Confirm"),
@@ -73,6 +82,7 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<
panel.append(heading, ...body, actions);
modal.append(panel);
document.body.append(modal);
dismiss = attachModalDismiss(modal, panel, { extra: options.extra });
}
export async function openEditProjectModal(
@@ -81,10 +91,18 @@ export async function openEditProjectModal(
): Promise<void> {
const isUserOnly = user.role === "USER";
// 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정).
const [members, assets] = await Promise.all([
// 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시).
const [members, assets, company] = await Promise.all([
fetchCompanyMembers(project.company_id),
fetchCompanyAssets(project.company_id),
fetchUserCompany().catch(() => null),
]);
// 남의 회사 프로젝트(시스템관리자)에서는 내 회사 로고를 기본으로 내밀지 않는다.
const sameCompany = company != null && company.id === project.company_id;
const companyLogo =
(sameCompany &&
assets.find((asset) => asset.kind === "LOGO" && asset.id === company.logo_asset_id)) ||
null;
const name = createInputField({
label: L("B01_Dashboard_Table_Project"),
@@ -106,6 +124,19 @@ export async function openEditProjectModal(
type: "number",
value: String(project.estimated_length_m ?? ""),
});
// 계획노선 사용 범위 — 등록(B02)에서 받은 값을 여기서도 고친다 (2026-09-04 사용자 지시).
const routeStart = createInputField({
label: "노선 시작 누가거리 (m)",
type: "number",
value: project.route_start_m == null ? "" : String(project.route_start_m),
placeholder: "비우면 처음부터",
});
const routeEnd = createInputField({
label: "노선 종료 누가거리 (m)",
type: "number",
value: project.route_end_m == null ? "" : String(project.route_end_m),
placeholder: "비우면 끝까지",
});
const memo = createInputField({ label: "비고", value: project.memo ?? "" });
// 도면 표제란·표지에 그대로 실리는 값 — 프로그램이 지어낼 수 없어 여기서 받는다.
const clientOrg = createInputField({
@@ -174,6 +205,16 @@ export async function openEditProjectModal(
project.logo_asset_id,
project.company_id,
user,
{
// 프로젝트가 안 고르면 도면에는 회사 로고가 실린다 — 저장값은 계속 비워 둬 연결을 유지한다.
fallback: sameCompany
? {
asset: companyLogo,
prefix: "회사 기본 로고",
missingNote: "(없음) — 회사 로고 미지정 (회사 정보 화면의 「로고 지정…」)",
}
: undefined,
},
);
if (isUserOnly) {
name.input.disabled = true;
@@ -181,6 +222,8 @@ export async function openEditProjectModal(
roadType.input.disabled = true;
year.input.disabled = true;
length.input.disabled = true;
routeStart.input.disabled = true;
routeEnd.input.disabled = true;
memo.input.disabled = true;
clientOrg.input.disabled = true;
projectNumber.input.disabled = true;
@@ -200,6 +243,8 @@ export async function openEditProjectModal(
roadType.root,
year.root,
length.root,
routeStart.root,
routeEnd.root,
memo.root,
clientOrg.root,
projectNumber.root,
@@ -212,28 +257,38 @@ export async function openEditProjectModal(
);
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
openModal(L("B01_Dashboard_EditProject"), [grid], async () => {
await updateProject(project.id, {
name: name.input.value.trim(),
region: region.input.value.trim() || null,
road_type: roadType.input.value.trim() || null,
project_year: year.input.value ? Number(year.input.value) : null,
estimated_length_m: length.input.value ? Number(length.input.value) : null,
memo: memo.input.value.trim() || null,
status: project.status,
client_org: clientOrg.input.value.trim() || null,
project_number: projectNumber.input.value.trim() || null,
work_amount: workAmount.input.value.trim() || null,
design_date: designDate.input.value || null,
pm_user_id: userId(pm.select),
field_lead_user_id: userId(fieldLead.select),
designer_user_id: userId(designer.select),
logo_asset_id: logo.value(),
// 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다.
signature_asset_id: null,
});
showToast(L("B01_Dashboard_Saved"), "success");
});
// 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다.
const editProjectExtra = (): string => String(logo.value() ?? "");
openModal(
L("B01_Dashboard_EditProject"),
[grid],
async () => {
await updateProject(project.id, {
name: name.input.value.trim(),
region: region.input.value.trim() || null,
road_type: roadType.input.value.trim() || null,
project_year: year.input.value ? Number(year.input.value) : null,
estimated_length_m: length.input.value ? Number(length.input.value) : null,
route_start_m: routeStart.input.value ? Number(routeStart.input.value) : null,
route_end_m: routeEnd.input.value ? Number(routeEnd.input.value) : null,
memo: memo.input.value.trim() || null,
status: project.status,
client_org: clientOrg.input.value.trim() || null,
project_number: projectNumber.input.value.trim() || null,
work_amount: workAmount.input.value.trim() || null,
design_date: designDate.input.value || null,
pm_user_id: userId(pm.select),
field_lead_user_id: userId(fieldLead.select),
designer_user_id: userId(designer.select),
logo_asset_id: logo.value(),
// 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다.
signature_asset_id: null,
});
showToast(L("B01_Dashboard_Saved"), "success");
},
{ extra: editProjectExtra },
);
}
export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void {
+8 -2
View File
@@ -12,7 +12,7 @@
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { L } from "./B01_Dashboard_UI_Common";
import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
export function tempFileType(fileName: string): string {
@@ -113,11 +113,13 @@ export function openTempFileModal(options: TempModalOptions): void {
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
// 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다 (2026-09-04 사용자 지시).
let dismiss: ModalDismissHandle | null = null;
actions.append(
createButton({
label: L("Common_Btn_Cancel"),
variant: "ghost",
onClick: () => modal.remove(),
onClick: () => void dismiss?.tryClose(),
}),
createButton({
label: L("Common_Btn_Confirm"),
@@ -143,4 +145,8 @@ export function openTempFileModal(options: TempModalOptions): void {
panel.append(pickRow, listHost, picker, actions);
modal.append(panel);
document.body.append(modal);
// 고른 파일은 입력칸이 아니라 목록에 쌓이므로 따로 견준다.
dismiss = attachModalDismiss(modal, panel, {
extra: () => chosen.map((file) => `${file.name}:${file.size}`).join(","),
});
}
@@ -68,12 +68,13 @@ async def create_project(
"""
INSERT INTO projects (
id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, memo, status,
project_year, estimated_length_m, route_start_m, route_end_m,
memo, status,
crs_epsg, storage_path, created_at, updated_at,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s,
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
@@ -85,6 +86,8 @@ async def create_project(
road_type,
project_year,
estimated_length_m,
fields.get("route_start_m"),
fields.get("route_end_m"),
memo,
storage_path,
now,
@@ -116,7 +119,8 @@ async def create_project(
await cursor.execute(
"""
SELECT id AS project_id, name, region, road_type, project_year,
estimated_length_m, memo, status, storage_path,
estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
FROM projects
WHERE id = %s
@@ -138,7 +142,8 @@ async def get_project_by_id(project_id: str) -> dict[str, Any] | None:
await cursor.execute(
"""
SELECT id AS project_id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, memo, status, storage_path,
project_year, estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
FROM projects
WHERE id = %s AND deleted_at IS NULL
@@ -34,8 +34,17 @@ async def post_project(
"field_lead_user_id",
"designer_user_id",
"logo_asset_id",
"route_start_m",
"route_end_m",
}
)
# 시작이 종료보다 뒤면 남는 구간이 없다 — 저장 전에 막는다 (2026-09-04 사용자 지시).
start_m, end_m = payload.route_start_m, payload.route_end_m
if start_m is not None and end_m is not None and start_m >= end_m:
raise HTTPException(
status_code=400,
detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.",
)
# 담당자·로고는 같은 회사 것만 (B01 프로젝트 수정과 같은 규칙).
await check_project_refs(int(company_id), title_block)
@@ -15,6 +15,10 @@ class CreateProjectRequest(BaseModel):
road_type: str = Field(..., pattern="^(main|fire|work)$")
project_year: int = Field(..., ge=2000, le=2100)
estimated_length_m: float | None = Field(default=None, ge=0)
# 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시).
# 둘 다 비우면 전 구간. 시작 >= 종료 는 라우터에서 막는다.
route_start_m: float | None = Field(default=None, ge=0)
route_end_m: float | None = Field(default=None, ge=0)
memo: str | None = Field(default=None, max_length=1000)
# 도면 표제란·표지 값 — 등록 때부터 받는다 (2026-09-02 사용자 지시).
# 프로젝트 수정 모달(B01)과 같은 칸이며, 비워 두면 도면에 빈칸으로 나간다.
@@ -37,6 +41,8 @@ class CreateProjectResponse(BaseModel):
road_type: str | None
project_year: int | None
estimated_length_m: float | None
route_start_m: float | None = None
route_end_m: float | None = None
memo: str | None
status: str
storage_path: str
@@ -90,6 +90,20 @@ export function renderB02ProjRegister(root: HTMLElement): void {
type: "number",
min: 0,
});
// 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시).
// 둘 다 비우면 전 구간을 쓴다.
const routeStartField = createInputField({
label: "노선 시작 누가거리 (m)",
placeholder: "비우면 처음부터",
type: "number",
min: 0,
});
const routeEndField = createInputField({
label: "노선 종료 누가거리 (m)",
placeholder: "비우면 끝까지",
type: "number",
min: 0,
});
const memoField = createInputField({
label: L("B02_Proj_Field_Memo"),
placeholder: L("B02_Proj_Field_Memo_Placeholder"),
@@ -189,6 +203,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
regionField.setError();
yearField.setError();
lengthField.setError();
routeStartField.setError();
routeEndField.setError();
// 1차 유효성: 필수값 검사
let hasError = false;
@@ -212,6 +228,25 @@ export function renderB02ProjRegister(root: HTMLElement): void {
lengthField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
const routeStart = isBlank(routeStartField.input.value)
? null
: Number.parseFloat(routeStartField.input.value);
const routeEnd = isBlank(routeEndField.input.value)
? null
: Number.parseFloat(routeEndField.input.value);
if (routeStart !== null && (!Number.isFinite(routeStart) || routeStart < 0)) {
routeStartField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
if (routeEnd !== null && (!Number.isFinite(routeEnd) || routeEnd < 0)) {
routeEndField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
// 시작이 종료보다 뒤면 남는 구간이 없다 — 서버도 같은 규칙으로 막는다.
if (routeStart !== null && routeEnd !== null && routeStart >= routeEnd) {
routeEndField.setError("종료 누가거리는 시작보다 커야 합니다.");
hasError = true;
}
if (hasError) return;
showLoadingOverlay();
@@ -226,6 +261,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
road_type: roadTypeField.select.value,
project_year: projectYear,
estimated_length_m: estimatedLength,
route_start_m: routeStart,
route_end_m: routeEnd,
memo: memoField.input.value.trim() || null,
client_org: clientOrgField.input.value.trim() || null,
project_number: projectNumberField.input.value.trim() || null,
@@ -269,6 +306,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
roadTypeField.root,
yearField.root,
lengthField.root,
routeStartField.root,
routeEndField.root,
memoField.root,
clientOrgField.root,
projectNumberField.root,
+5 -3
View File
@@ -164,13 +164,15 @@ async def upload_project_files(
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
},
)
csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames)
if csv_count != 1:
# 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다
# (2026-09-04 사용자 지시: 사용자는 CSV 를 쓰지 않음. CSV 는 내부 정본 한 벌뿐).
route_count = sum(Path(filename).suffix.lower() in {".csv", ".shp"} for filename in filenames)
if route_count != 1:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.",
"message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.",
},
)
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
+21 -9
View File
@@ -1,7 +1,7 @@
"""B03 업로드 이후 자동 설계 체인 — WF1 확정 다음을 잇는다.
WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어도 서버가 이어서
① B05 기본 경로 계산·확정(계획노선 CSV 기반) ② B06 기본 횡단 설계 확정까지
① B05 기본 경로 계산·확정(계획노선 정본 기반) ② B06 기본 횡단 설계 확정까지
기본값으로 진행해 영구저장소에 남긴다(2026-08-04 사용자 확정). 이후 사용자가
대시보드에서 B05/B06에 들어오면 저장본을 바로 로딩해 검토·수정만 하면 된다.
@@ -26,16 +26,19 @@ logger = logging.getLogger(__name__)
def _planned_route_points_in_project_crs(
project_root: Path, surface: dict[str, Any] | None = None
project_root: Path,
surface: dict[str, Any] | None = None,
route_range: tuple[float | None, float | None] | None = None,
) -> list[dict[str, float]] | None:
"""설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None.
읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도
같은 함수를 쓰므로 여기만 트림되는 일이 없다.
같은 함수를 쓰므로 여기만 트림되는 일이 없다. 사용자가 정한 사용 범위(`route_range`)는
서피스 트림보다 먼저 적용된다 (2026-09-04 사용자 지시).
"""
from common_util.common_util_route_geometry import load_design_route
planned = load_design_route(project_root, surface)
planned = load_design_route(project_root, surface, route_range)
if planned is None:
if surface:
logger.warning(
@@ -164,7 +167,7 @@ async def run_auto_design_chain(
)
return None
# 2) 계획노선 CSV → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다.
# 2) 계획노선 정본 → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다.
project_root = Path(resolve_stored_project_path(stored_path))
# 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면
# 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장).
@@ -176,11 +179,20 @@ async def run_auto_design_chain(
# 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다.
async with pool.acquire() as connection:
defaults = await get_surface_confirmation_params(connection, str(project_id))
# 사용자가 B02·B01 에서 정한 계획노선 사용 범위 (2026-09-04 사용자 지시).
# 비어 있으면 전 구간 — 지금까지와 같다.
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s",
(str(project_id),),
)
range_row = await cursor.fetchone()
route_range = (range_row[0], range_row[1]) if range_row else None
points = _planned_route_points_in_project_crs(project_root, defaults)
points = _planned_route_points_in_project_crs(project_root, defaults, route_range)
if not points:
logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id)
mark_design_failed(project_root, "계획노선 CSV가 없어 초기 노선을 세울 수 없습니다.")
logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id)
mark_design_failed(project_root, "계획노선 없어 초기 노선을 세울 수 없습니다.")
return None
# 3) B05 경로 계산
@@ -300,7 +312,7 @@ async def run_redesign_chain(
새 경로에 이월하고, 표준단면 설정(data.options)도 함께 넘긴다. 나머지 미지정
측점은 확정 시 기본값으로 채워진다.
경로가 아예 없으면 신규 자동 체인(계획노선 CSV 기본값)으로 되돌아간다.
경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다.
"""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route
+3 -3
View File
@@ -137,7 +137,7 @@ export interface SurfaceConfirmedResponse {
z_min: number;
z_max: number;
} | null;
/** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */
/** 계획노선(B03 정본)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */
route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null;
}
@@ -308,7 +308,7 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise
});
}
/** 계획노선(B03 업로드 CSV)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */
/** 계획노선(B03 정본)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */
export interface PlannedRouteResponse {
status: string;
points: Array<{ x: number; y: number }>;
@@ -322,7 +322,7 @@ export async function fetchPlannedRoute(projectId: string): Promise<PlannedRoute
}
/* ── 배수유역 분석 (B04_PreProcess_Router_Watershed.py) ────────────────────
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
* 관리자 확인용. 계획 노선(B03 정본) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
* 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다.
* ------------------------------------------------------------------------ */
@@ -7,6 +7,7 @@
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import { stationLabel } from "@util/common_util_svg";
/** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
@@ -184,3 +185,100 @@ export function drawRidgeRing(
context.stroke();
context.restore();
}
/* -----------------------------------------------------------------------------
* 계획선 위 측점 눈금·번호 (2026-09-04 사용자 지시)
*
* 종단·3D와 같은 `측점번호+잔여거리` 표기다. 배율이 낮으면 글자가 붙으므로 3D 라벨과 같은
* 단계 규칙으로 솎는다(5칸 → 2칸 → 전부). 관 마커가 있는 측점은 라벨을 계획선 **반대쪽**
* 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다.
* -------------------------------------------------------------------------- */
export interface StationTickOptions {
/** 규칙 측점 간격(m). */
intervalM: number;
/** 화면 1m 당 픽셀 — 라벨 솎기 단계를 여기서 정한다. */
pxPerMeter: number;
toScreen: (x: number, y: number) => [number, number];
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
avoidChainages?: ReadonlyArray<number>;
}
export function drawStationTicks(
context: CanvasRenderingContext2D,
points: ReadonlyArray<{ x: number; y: number }>,
options: StationTickOptions,
): void {
if (points.length < 2) return;
const interval = options.intervalM > 0 ? options.intervalM : 20;
// 라벨 사이가 좁아지면 솎는다 — 화면에서 잰 간격(px)으로 정한다.
const gapPx = interval * options.pxPerMeter;
const step = gapPx >= 90 ? 1 : gapPx >= 40 ? 2 : 5;
const avoid = options.avoidChainages ?? [];
// 정점 누가거리 — 측점 자리는 정점 사이에 떨어지므로 보간해서 찍는다.
const cumulative: number[] = [0];
for (let index = 1; index < points.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y),
);
}
const total = cumulative[cumulative.length - 1];
if (total <= 0) return;
context.save();
context.font = "11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과
// 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다).
const drawn: Array<{ x: number; y: number; half: number }> = [];
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += interval) {
const stationNo = Math.round(chainage / interval);
if (stationNo % step !== 0) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = points[cursor - 1];
const front = points[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
const px = back.x + (front.x - back.x) * ratio;
const py = back.y + (front.y - back.y) * ratio;
const [sx, sy] = options.toScreen(px, py);
const [bx, by] = options.toScreen(back.x, back.y);
const [fx, fy] = options.toScreen(front.x, front.y);
const dx = fx - bx;
const dy = fy - by;
const length = Math.hypot(dx, dy) || 1;
// 계획선에 직각인 방향 — 눈금과 라벨을 이 방향으로 놓는다.
const ux = -dy / length;
const uy = dx / length;
const nearPipe = avoid.some((pipe) => Math.abs(pipe - chainage) < interval / 2);
const side = nearPipe ? -1 : 1;
context.beginPath();
context.moveTo(sx - ux * 6, sy - uy * 6);
context.lineTo(sx + ux * 6, sy + uy * 6);
context.lineWidth = 1.2;
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
context.stroke();
const label = stationLabel(chainage, interval);
const lx = sx + ux * side * 16;
const ly = sy + uy * side * 16;
const width = context.measureText(label).width + 6;
const half = width / 2;
const collides = drawn.some(
(item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16,
);
if (collides) continue;
drawn.push({ x: lx, y: ly, half });
// 배경을 깔아 등고선 위에서도 읽히게 한다.
context.fillStyle = "rgba(255, 255, 255, 0.78)";
context.fillRect(lx - half, ly - 8, width, 16);
context.fillStyle = "#222222";
context.fillText(label, lx, ly);
}
context.restore();
}
@@ -121,6 +121,59 @@ export function computeMapRect(meta: VWorldMeta | null, width: number, height: n
* (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */
export const ROUTE_VIEW_MARGIN_M = 200;
/**
* 사업지 미터 좌표를 화면 좌표로 옮기는 변환기 (B04 지도·B05 배수유역도 공용).
*
* 배수유역도에서 쓰던 것을 여기로 옮겼다 — 두 화면이 같은 자리에 측점 눈금을 찍어야 한다
* (2026-09-04). `pxPerMeter` 는 라벨 솎기·축척 계산에 쓴다.
*/
export function createMetricProjector(
meta: VWorldMeta,
view: ViewState,
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return {
toScreen: (x, y) => [
((x - meta.x_min) / spanX) * ax + bx,
(1 - (y - meta.y_min) / spanY) * ay + by,
],
pxPerMeter: ax / spanX,
};
}
/** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */
export const MAP_STATION_INTERVAL_M = 20;
/** 최대 확대에서 화면 폭에 들어올 실거리(m) — 규칙 측점 20m 기준 1~2측점
* (2026-09-04 사용자 지시). 고정 배율(8배·16배)로는 도엽 크기마다 체감이 달라진다. */
export const MAX_ZOOM_VIEW_WIDTH_M = 20;
/** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */
export const ZOOM_SCALE_HARD_CAP = 2000;
/**
* 「화면 폭이 `MAX_ZOOM_VIEW_WIDTH_M` 가 될 때까지」에 해당하는 배율 상한을 구한다.
*
* 배율 1에서 도엽 실폭(`meta.width_meters`)이 지도 사각형 폭(px)을 채우므로,
* 화면 폭(px)에 들어오는 실거리 = width_meters × viewportWidth / (mapRect.width × scale) 이다.
* 이것을 20m 로 놓고 scale 을 푼다. 메타가 없으면 종전 고정값으로 되돌아간다.
*/
export function computeMaxScale(
meta: VWorldMeta | null,
mapRectWidth: number,
viewportWidth: number,
fallback: number,
): number {
if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback;
const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M);
return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale));
}
/** 평면 좌표(m) 범위. */
export interface PlanBounds {
x_min: number;
+31 -2
View File
@@ -26,7 +26,10 @@ import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength";
import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed";
import {
computeMapRect,
computeMaxScale,
computeRouteView,
createMetricProjector,
MAP_STATION_INTERVAL_M,
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
@@ -41,6 +44,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
export interface SurfaceMapViewer {
@@ -160,8 +164,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
);
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer]));
let showContourLabels = CONTOUR_LABEL_DEFAULT_ON;
// 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
// 계획선(B03 계획노선 정본) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
let routeLayer: PreparedLayer | null = null;
// 측점 눈금·번호를 찍기 위한 원본 점 목록 (2026-09-04 사용자 지시).
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
let showRoute = true;
let scale = 1;
let offsetX = 0;
@@ -314,6 +320,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
image.style.imageRendering = scale > 4 ? "pixelated" : "auto";
});
}
@@ -416,6 +425,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
context.strokeStyle = routeLineColor();
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 측점 눈금·번호 — 계획선 위, 유역 오버레이 아래. B05 배수유역도와 같은 규칙이다.
if (showRoute && meta && routePoints.length > 1) {
const projector = createMetricProjector(meta, view);
drawStationTicks(context, routePoints, {
intervalM: MAP_STATION_INTERVAL_M,
pxPerMeter: projector.pxPerMeter,
toScreen: projector.toScreen,
});
}
// 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다.
flowStrength.draw(context, normalizer, view);
// 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다.
@@ -443,6 +461,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
meta = null;
preparedLayers.clear();
routeLayer = null;
routePoints = [];
resetView();
status.textContent = L("B04_Surface_Map_Loading");
showProgress(0, L("B04_Surface_Map_Loading"));
@@ -480,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
normalizer = createNormalizer(nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
routePoints = planned.points;
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
flowStrength.setRoute(planned.points, nextMeta);
// 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다.
@@ -518,7 +538,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
event.preventDefault();
const prevScale = scale;
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다.
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 상한은 「화면 폭 20m」로 계산한다 — 도엽 크기가 달라도 체감이 같다(2026-09-04 사용자 지시).
const wheelRect = viewport.getBoundingClientRect();
const wheelWidth = Math.max(1, Math.floor(wheelRect.width));
const maxScale = computeMaxScale(
meta,
computeMapRect(meta, wheelWidth, Math.max(1, Math.floor(wheelRect.height))).width,
wheelWidth,
8,
);
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
+1 -1
View File
@@ -113,7 +113,7 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
물린 자리와 측점 자리가 어긋난다.
관 지점 파일에는 저장 당시 노선 지문이 함께 있다. 지문이 달라도 저장분에 좌표가 있으면
**이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 CSV)과 여기 계획선은
**이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 정본)과 여기 계획선은
같은 자리를 지나면서 연장이 다르다(실측 350.11m vs 354.83m). 그래서 지문은 거의 항상
달랐고 관이 통째로 빠졌다(2026-08-30 사용자 지적). 좌표가 없는 구 저장분만 버린다.
"""
@@ -9,10 +9,12 @@
* ========================================================================== */
import {
computeMaxScale,
lonLatToScreen,
type Normalizer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
@@ -29,6 +31,8 @@ export interface DrainageInteractParams {
currentView: () => ViewState;
getScale: () => number;
setScale: (value: number) => void;
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
getMeta: () => VWorldMeta | null;
getOffset: () => { x: number; y: number };
setOffset: (x: number, y: number) => void;
scheduleDraw: () => void;
@@ -55,7 +59,10 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
event.preventDefault();
const prevScale = params.getScale();
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다.
const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
const view = currentView();
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
params.setScale(scale);
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
@@ -12,6 +12,7 @@ import {
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
MAP_STATION_INTERVAL_M,
createNormalizer,
prepareLayer,
prepareMetricPolyline,
@@ -200,6 +201,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
function updateImageTransform(): void {
backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
// 크게 당기면 도엽 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 축척 막대가 실제 크기를 알려 준다.
backgroundImage.style.imageRendering = scale > 4 ? "pixelated" : "auto";
}
function draw(): void {
@@ -244,6 +248,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor,
pipeColor,
markedChainage,
stationIntervalM: MAP_STATION_INTERVAL_M,
});
updateImageTransform();
}
@@ -488,6 +493,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
setScale: (value) => {
scale = value;
},
getMeta: () => meta,
getOffset: () => ({ x: offsetX, y: offsetY }),
setOffset: (x, y) => {
offsetX = x;
+2 -18
View File
@@ -171,24 +171,8 @@ export function renderBasinRows(
/** (m) px .
* . */
export function createMetricProjector(
meta: VWorldMeta,
view: ViewState,
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return {
toScreen: (x, y) => [
((x - meta.x_min) / spanX) * ax + bx,
(1 - (y - meta.y_min) / spanY) * ay + by,
],
pxPerMeter: ax / spanX,
};
}
// 미터 좌표 → 화면 좌표 변환기는 B04 지도와 공용이다(정의처: MapRender).
export { createMetricProjector } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
/** , .
@@ -22,6 +22,7 @@ import {
drawFilledRing,
drawRidgeRing,
drawRingBadge,
drawStationTicks,
drawUpstreamLines,
ringCenterOnScreen,
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
@@ -64,6 +65,8 @@ export interface DrainageScene {
pipeColor: (chainage: number, position: number) => string;
/** 선택된 측점의 누가거리(m). 계획선 위 그 자리에 선택 표식을 그린다(null=없음). */
markedChainage: number | null;
/** 규칙 측점 간격(m) — 눈금·번호 표기 기준 (2026-09-04 사용자 지시). */
stationIntervalM: number;
}
export function drawDrainageScene(
@@ -182,6 +185,13 @@ export function drawDrainageScene(
context.stroke();
context.restore();
}
// 측점 눈금·번호 — 관 마커 위, 유역 번호 아래 (2026-09-04 사용자 지시). B04 지도와 공용.
drawStationTicks(context, scene.strengthSamples, {
intervalM: scene.stationIntervalM,
pxPerMeter: projector.pxPerMeter,
toScreen: projector.toScreen,
avoidChainages: scene.pipeEditor.chainages(),
});
// 유역 번호 — 무엇에도 가리지 않게 맨 마지막.
drawBadges(context, badges);
}
+1 -1
View File
@@ -3,7 +3,7 @@
화면이 같은 목록과 같은 세부유역을 보여 주려면 **입력이 글자도 달라선 된다**
(2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 곳에서 만들어 양쪽에 넘긴다.
노선 기준선은 B05가 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**. B04 격자
노선 기준선은 B05가 최적 경로가 아니라 **B03이 받은 원청 계획노선(정본)**. B04 격자
해석이 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 위치가
어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 [[common_util_route_profile]].
"""
+2 -2
View File
@@ -9,7 +9,7 @@
종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다.
좌표를 같이 남기는 이유(2026-08-30 사용자 지적 "결국 노선 위에 위치해야 한다"): 자리를
정한 (계획노선 CSV) 화면에 그려지는 (B05 최적 경로) **같은 자리를 지나면서 연장이
정한 (계획노선 정본) 화면에 그려지는 (B05 최적 경로) **같은 자리를 지나면서 연장이
다르다**(실측 350.11m vs 354.83m). 누가거리만 남기면 읽는 쪽이 선에 따라 같은 값이 3~4m
미끄러져 관이 옆에 떨어진 것처럼 보인다. 좌표를 남겨 두면 어느 선으로 읽든 좌표를
투영해 **항상 위에** 앉힐 있다.
@@ -416,7 +416,7 @@ def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: st
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다).
`crs` 좌표를 WGS84로 바꿀 **사업지 좌표계**. 되읽는 (B07 유역도) 같은
좌표계로 되돌려야 하는데, 예전에는 값을 남겨 노선 CSV EPSG 라벨로 되돌렸다
좌표계로 되돌려야 하는데, 예전에는 값을 남겨 노선 정본 EPSG 라벨로 되돌렸다
(2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 자리로 갔다).
"""
path = detail_basins_path(stored_path)
+61 -1
View File
@@ -213,7 +213,9 @@ def find_planned_route_file(input_dir: Path) -> Path | None:
def load_design_route(
project_root: Path, surface_params: dict[str, Any] | None = None
project_root: Path,
surface_params: dict[str, Any] | None = None,
route_range: tuple[float | None, float | None] | None = None,
) -> PlannedRoute | None:
"""설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다.
@@ -225,6 +227,9 @@ def load_design_route(
`surface_params`(확정 필터·방식·스무딩) 주면 지표면이 덮지 못하는 구간을 잘라 내고,
B05 격자 탐색이 계획노선을 바꾸지 않도록 정점 간격을 직결 문턱 아래로 좁힌다.
주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다.
`route_range`(시작·종료 누가거리 m) 주면 **서피스 트림보다 먼저** 구간만 남긴다
(2026-09-04 사용자 지시). 순서가 바뀌면 사용자가 정한 시점이 서피스 트림에 밀린다.
"""
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_initial_snapshot import design_route_csv_path
@@ -264,6 +269,10 @@ def load_design_route(
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
points = [transformer.transform(x, y) for x, y in points]
# 사용자가 정한 범위가 먼저다 — 그 다음에 서피스 밖을 깎는다.
if route_range:
points = clip_route_by_chainage(points, route_range[0], route_range[1])
if surface_params:
from common_util.common_util_surface_sampler import build_surface_sampler
@@ -311,6 +320,57 @@ def replace_vertices(
)
def clip_route_by_chainage(
points: list[tuple[float, float]],
start_m: float | None,
end_m: float | None,
) -> list[tuple[float, float]]:
"""사용자가 정한 누가거리 구간만 남긴다 (2026-09-04 사용자 지시).
계획노선 자료가 공사지 전체일 있어 ** 구간을 사용자가 정한다**(B02 등록 화면).
경계는 정점 사이에 떨어질 있으므로 자리에 점을 하나 만들어 끼운다.
없으면 원본 그대로. 남는 구간이 2 미만이면 원본을 돌려준다 범위가 자료를
벗어난 경우까지 여기서 노선을 지우면 원인을 찾는다(판정·안내는 화면·라우터 ).
"""
if len(points) < 2 or (start_m is None and end_m is None):
return list(points)
low = max(0.0, float(start_m)) if start_m is not None else 0.0
high = float(end_m) if end_m is not None else float("inf")
if high <= low:
return list(points)
clipped: list[tuple[float, float]] = []
travelled = 0.0
for index in range(1, len(points)):
ax, ay = points[index - 1]
bx, by = points[index]
length = math.dist((ax, ay), (bx, by))
if length <= 0.0:
continue
seg_start, seg_end = travelled, travelled + length
travelled = seg_end
if seg_end < low or seg_start > high:
continue
# 이 구간에서 남길 부분의 시작·끝 비율.
t0 = max(0.0, (low - seg_start) / length)
t1 = min(1.0, (high - seg_start) / length)
if t1 <= t0:
continue
first = (ax + (bx - ax) * t0, ay + (by - ay) * t0)
last = (ax + (bx - ax) * t1, ay + (by - ay) * t1)
if not clipped:
clipped.append(first)
clipped.append(last)
if len(clipped) < 2:
logger.warning(
"계획노선 범위 절단: 남는 구간이 없어 전 구간을 씁니다 — 범위 %s~%s m",
start_m,
end_m,
)
return list(points)
return clipped
def _log_trim_wipeout(points: list[tuple[float, float]], sampler: Any, target_crs: str) -> None:
"""트림이 노선을 통째로 지운 이유를 **수치로** 남긴다.
+17 -2
View File
@@ -168,7 +168,16 @@ async def fail_stage(
async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Dict[str, Any]:
"""프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다."""
"""프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다.
프로젝트 이름도 함께 싣는다 (2026-09-04 사용자 지시) B03~B08 좌측 제목 오른쪽에
이름을 붙이는데, 화면이 들고 있는 것은 프로젝트 id 뿐이라 여기서 내려 준다. 새로고침·
주소 직접 입력으로 들어와도 같은 값이 따라온다.
"""
await cursor.execute("SELECT name FROM projects WHERE id = %s", (project_id,))
name_row = await cursor.fetchone()
project_name = name_row["name"] if name_row else None
await cursor.execute(
"""
SELECT stage_no, stage_key, state, progress_percent, params, message,
@@ -183,7 +192,12 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di
rows = await cursor.fetchall()
if not rows:
return {"project_id": project_id, "current_stage": 0, "stages": []}
return {
"project_id": project_id,
"project_name": project_name,
"current_stage": 0,
"stages": [],
}
stages_list = []
for r in rows:
@@ -221,6 +235,7 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di
return {
"project_id": project_id,
"project_name": project_name,
"current_stage": current_stage,
"stages": stages_list,
}
+2 -1
View File
@@ -55,7 +55,8 @@ SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")
# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m).
# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면
# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정).
SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0"))
# 30m 는 너무 많이 깎는다는 지적으로 3m 로 낮춤 (2026-09-04 사용자 지시).
SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "3.0"))
# ─────────────────────────────────────────────────────────────────────────
# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree)
+16
View File
@@ -0,0 +1,16 @@
-- 015_route_range.sql
-- 계획노선 사용 범위 (2026-09-04 사용자 지시)
--
-- 계획노선 자료가 공사지 전체일 수 있어 **어느 구간을 쓸지 사용자가 정한다**.
-- B02 등록 화면에서 시작·종료 누가거리(m)를 받고, B03 이 계획노선을 세울 때 이 범위로
-- 먼저 자른 뒤 서피스 밖을 잘라 낸다(순서가 바뀌면 사용자가 정한 시점이 밀린다).
--
-- 둘 다 NULL 이면 지금처럼 **전 구간**을 쓴다 — 기존 행·기존 동작은 그대로다.
USE aislo_db;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS route_start_m DOUBLE NULL
COMMENT '계획노선 시작 누가거리(m) — NULL 이면 처음부터' AFTER estimated_length_m,
ADD COLUMN IF NOT EXISTS route_end_m DOUBLE NULL
COMMENT '계획노선 종료 누가거리(m) — NULL 이면 끝까지' AFTER route_start_m;
+1 -1
View File
@@ -222,7 +222,7 @@ const BASE_CSS = `
align-items: center;
justify-content: center;
background-color: rgba(38, 17, 74, 0.35);
z-index: var(--z-overlay);
z-index: var(--z-confirm);
}
.ui-confirm__panel {
min-width: 300px;
+2 -2
View File
@@ -364,8 +364,8 @@ export const ui_locales_b1 = {
"A file for this slot is already selected.",
],
B03_File_Error_RequiredSlots: [
"필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.",
"Fill every required card: the planned route (a CSV or a full shapefile set), " +
"필수 카드를 모두 채우세요 — 계획노선(shapefile 한 벌 또는 CSV), LAS/LAZ, 지형 PRJ·TFW.",
"Fill every required card: the planned route (a full shapefile set or a CSV), " +
"LAS/LAZ, and the terrain PRJ and TFW.",
],
B03_File_Error_SlotType: [
+18
View File
@@ -93,6 +93,24 @@
cursor: pointer;
}
/* 제목 오른쪽 프로젝트 이름 (2026-09-04 사용자 지시) 길면 말줄임, 전체는 툴팁.
줄어드는 쪽은 이름만 페이지 제목은 짧은 고정 문구라 밀리면 된다. */
.ui-workflow-overlay__panel--title .ui-workflow-overlay__title {
flex: 0 0 auto;
}
.ui-workflow-overlay__project {
flex: 0 1 auto;
max-width: 60%;
min-width: 0;
overflow: hidden;
color: var(--color-text-muted, var(--color-text));
font-size: var(--text-body-sm);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-workflow-overlay__toggle {
flex: 0 0 auto;
width: var(--spacing-32);
+31
View File
@@ -1,6 +1,8 @@
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";
import { fetchProjectWorkflowState } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
@@ -110,12 +112,38 @@ function splitSidebarActions(body: HTMLElement): void {
}
}
/**
* (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 fetchProjectWorkflowState(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}`;
@@ -137,6 +165,8 @@ function createPanel(
if (isSidebar) {
header.append(title);
// 제목은 왼쪽, 프로젝트 이름은 같은 행 오른쪽 끝 (2026-09-04 사용자 지시).
if (titleAside) header.append(titleAside);
root.append(header, toggle);
} else {
header.append(title, toggle);
@@ -210,6 +240,7 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
titleBody,
TITLE_OVERLAY_STATE_KEY,
options.onOptionsOpenChange,
createProjectNameTag(),
);
root.append(titlePanel.root);
setTitleOpen = titlePanel.setOpen;
+2
View File
@@ -197,6 +197,8 @@
--z-dropdown: 200;
--z-overlay: 900;
--z-modal: 1000;
/* 확인창은 모달 위에 떠야 한다 — 모달 닫기 확인이 모달 뒤에 깔리면 못 누른다. */
--z-confirm: 1050;
--z-toast: 1100;
/* ---------------------------------------------------------------------------