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

This commit is contained in:
2026-09-04 19:17:59 +09:00
39 changed files with 1040 additions and 98 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,
@@ -1,6 +1,8 @@
"""B03 원본 입력 파일 메타데이터 분석."""
import base64
import csv
import io
import logging
import math
import re
@@ -11,6 +13,7 @@ from typing import Any
import laspy
import numpy as np
import rasterio
from PIL import Image
from pyproj import CRS
logger = logging.getLogger(__name__)
@@ -136,6 +139,11 @@ def _prepare_prj_wkt(text: str) -> tuple[str, list[str]]:
return _CUSTOM_VERTICAL_AUTHORITY_PATTERN.sub("", text), codes
# 카드 미리보기 점 그림의 점 수 상한과, 분류가 없는 파일에서 볼 청크 수 (2026-09-04).
_LAS_PREVIEW_MAX_POINTS = 5000
_LAS_PREVIEW_MAX_CHUNKS = 3
def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
"""LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다."""
source = Path(path)
@@ -170,6 +178,22 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
"has_return_number": "return_number" in dimension_names,
}
# 카드 미리보기용 탑뷰 점 그림 (2026-09-04 사용자 지시).
# 분류 통계를 훑는 **그 길에** XY 를 성기게 주워 둔다 — 파일을 다시 읽지 않으므로
# 업로드 시간이 늘지 않는다. 분류가 없어 훑지 않는 파일만 앞 몇 청크를 본다.
preview_points: list[list[int]] = []
stride = max(1, point_count // _LAS_PREVIEW_MAX_POINTS) if point_count else 1
def _collect(chunk: Any) -> None:
if len(preview_points) >= _LAS_PREVIEW_MAX_POINTS:
return
xs = np.asarray(chunk.x, dtype=np.float64)[::stride]
ys = np.asarray(chunk.y, dtype=np.float64)[::stride]
room = _LAS_PREVIEW_MAX_POINTS - len(preview_points)
# 카드 안 작은 그림이라 1m 눈금이면 충분하다 — 정수로 줄여 저장 용량을 아낀다.
for x, y in zip(xs[:room].tolist(), ys[:room].tolist(), strict=True):
preview_points.append([round(x), round(y)])
if metadata["has_classification"] and point_count > 0:
classification_counts: dict[int, int] = {}
for chunk in las_file.chunk_iterator(500_000):
@@ -179,9 +203,22 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
)
for value, count in zip(values.tolist(), counts.tolist(), strict=True):
classification_counts[value] = classification_counts.get(value, 0) + count
_collect(chunk)
metadata["classification_summary"] = {
str(key): value for key, value in sorted(classification_counts.items())
}
elif point_count > 0:
# 분류가 없으면 전 점을 훑을 이유가 없다 — 앞 몇 청크만 보고 끝낸다.
for index, chunk in enumerate(las_file.chunk_iterator(500_000)):
_collect(chunk)
if (
index + 1 >= _LAS_PREVIEW_MAX_CHUNKS
or len(preview_points) >= _LAS_PREVIEW_MAX_POINTS
):
break
if preview_points:
metadata["preview_points"] = preview_points
return metadata
@@ -247,6 +284,43 @@ def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]:
}
# 카드 미리보기 썸네일 한 변 크기(px) — 128 이면 카드 폭에서 충분히 읽힌다 (2026-09-04).
_TIF_THUMBNAIL_PX = 128
def _geotiff_thumbnail(dataset: Any) -> str | None:
"""저해상 흑백 썸네일을 data URL(PNG) 로 만든다. 만들 수 없으면 None.
오버뷰(피라미드)가 있는 파일만 대상으로 한다 — 오버뷰가 없으면 원본을 훑어 축소해야
해서 업로드가 눈에 띄게 느려진다(사용자 제약 「오래 걸리면 안 됨」).
"""
try:
if not any(dataset.overviews(index) for index in dataset.indexes):
return None
band = dataset.read(
1,
out_shape=(_TIF_THUMBNAIL_PX, _TIF_THUMBNAIL_PX),
masked=True,
)
finite = band.compressed()
if finite.size == 0:
return None
low = float(np.percentile(finite, 2))
high = float(np.percentile(finite, 98))
if high <= low:
return None
# 2~98 백분위로 늘려 대비를 준다 — DEM 은 값 폭이 좁아 그냥 펴면 밋밋하다.
scaled = np.clip((band.filled(low) - low) / (high - low), 0.0, 1.0)
grey = (scaled * 255).astype(np.uint8)
image = Image.fromarray(grey, mode="L")
buffer = io.BytesIO()
image.save(buffer, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
except Exception as exc: # 썸네일은 곁가지다 — 실패해도 분석을 막지 않는다.
logger.warning("GeoTIFF 썸네일 생성 실패: %s", exc)
return None
def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
"""TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다."""
source = Path(path)
@@ -261,6 +335,9 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
bounds = dataset.bounds
return {
"file": source.name,
# 카드 미리보기용 흑백 썸네일 (2026-09-04 사용자 지시).
# **오버뷰가 있을 때만** 만든다 — 없는 큰 파일을 축소 읽으면 수 초가 걸린다.
"preview_thumbnail": _geotiff_thumbnail(dataset),
"width": int(dataset.width),
"height": int(dataset.height),
"count": int(dataset.count),
@@ -390,6 +467,58 @@ def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]:
}
def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]:
"""DBF 머리글만 읽어 레코드 수·속성 수를 낸다 (2026-09-04 카드 표시용).
머리글 32바이트가 전부라 파일 크기와 무관하게 즉시 끝난다.
"""
source = Path(path)
head = source.read_bytes()[:32]
metadata: dict[str, Any] = {
"file": source.name,
"extension": "dbf",
"size_bytes": source.stat().st_size,
}
if len(head) >= 12:
record_count = int.from_bytes(head[4:8], "little")
header_length = int.from_bytes(head[8:10], "little")
metadata["record_count"] = record_count
# 머리글 = 32바이트 고정 + 속성마다 32바이트 + 끝 표시 1바이트.
metadata["field_count"] = max(0, (header_length - 33) // 32)
return metadata
def analyze_shx_metadata(path: str | Path) -> dict[str, Any]:
"""SHX 머리글에서 도형 개수를 센다 (2026-09-04 카드 표시용).
SHX 는 도형마다 8바이트 색인이 한 줄씩이라 파일 길이로 개수가 나온다.
"""
source = Path(path)
head = source.read_bytes()[:100]
metadata: dict[str, Any] = {
"file": source.name,
"extension": "shx",
"size_bytes": source.stat().st_size,
}
if len(head) >= 28:
# 24~27바이트: 파일 길이(16비트 워드 단위, 빅엔디안).
words = int.from_bytes(head[24:28], "big")
metadata["shape_count"] = max(0, (words * 2 - 100) // 8)
return metadata
def analyze_cpg_metadata(path: str | Path) -> dict[str, Any]:
"""CPG 는 인코딩 이름 한 줄이 전부다 (2026-09-04 카드 표시용)."""
source = Path(path)
text = source.read_text(encoding="utf-8", errors="replace").strip()
return {
"file": source.name,
"extension": "cpg",
"size_bytes": source.stat().st_size,
"encoding": text or None,
}
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
"""입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다."""
source = Path(path)
@@ -406,6 +535,13 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
return analyze_prj_metadata(source)
if extension == ".tfw":
return analyze_tfw_metadata(source)
# 부속 파일도 카드에 「구분되는 값」을 보여 준다 (2026-09-04 사용자 지시).
if extension == ".dbf":
return analyze_dbf_metadata(source)
if extension == ".shx":
return analyze_shx_metadata(source)
if extension == ".cpg":
return analyze_cpg_metadata(source)
if extension in {".tif", ".tiff"}:
return analyze_tif_metadata(source)
return {
+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
+2
View File
@@ -243,6 +243,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const terrain = terrainCoverage();
renderSlotPreview(preview, {
metadata: state.serverUploaded?.metadata,
// 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시).
completed: Boolean(state.serverUploaded),
terrainExtent: ROUTE_SLOTS.includes(slot) ? terrain.extent : null,
terrainCrs: terrain.crs,
crsFallback: readCrsLabel(slots.get("route_prj")?.serverUploaded?.metadata),
+119 -4
View File
@@ -21,9 +21,11 @@ export interface PreviewExtent {
}
const SVG_NS = "http://www.w3.org/2000/svg";
const VIEW_WIDTH = 100;
const VIEW_HEIGHT = 60;
const PADDING = 4;
// 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 파일마다
// 들쭉날쭉하지 않게. 실제 폭은 CSS 가 100% 로 늘리고, 이 값들은 좌표계 기준일 뿐이다.
const VIEW_WIDTH = 320;
const VIEW_HEIGHT = 120;
const PADDING = 6;
function finite(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -75,6 +77,63 @@ export function readPreviewPath(
return parts.length ? parts : null;
}
/** 라이다 분석기가 훑는 길에 주워 둔 탑뷰 점(없으면 null). */
export function readPreviewPoints(
metadata: Record<string, unknown> | undefined,
): number[][] | null {
const raw = metadata?.preview_points;
if (!Array.isArray(raw) || raw.length === 0) return null;
const points: number[][] = [];
for (const point of raw) {
if (!Array.isArray(point)) continue;
const x = finite(point[0]);
const y = finite(point[1]);
if (x !== null && y !== null) points.push([x, y]);
}
return points.length ? points : null;
}
/** GeoTIFF 저해상 썸네일(data URL). 오버뷰가 없는 파일은 만들지 않아 null 이다. */
export function readThumbnail(metadata: Record<string, unknown> | undefined): string | null {
const raw = metadata?.preview_thumbnail;
return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null;
}
/**
* 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **한눈에 구분되는 값**을 뽑는다
* (2026-09-04 사용자 지시). 그림 대신 이 값을 큰 글씨로 보인다.
*/
export function readFacts(metadata: Record<string, unknown> | undefined): string[] {
if (!metadata) return [];
const facts: string[] = [];
const number = (value: unknown): number | null => finite(value);
const text = (value: unknown): string | null =>
typeof value === "string" && value.trim() ? value.trim() : null;
const shapes = number(metadata.shape_count);
if (shapes !== null) facts.push(`도형 ${shapes.toLocaleString()}개`);
const records = number(metadata.record_count);
if (records !== null) facts.push(`레코드 ${records.toLocaleString()}개`);
const fields = number(metadata.field_count);
if (fields !== null) facts.push(`속성 ${fields}개`);
const encoding = text(metadata.encoding);
if (encoding) facts.push(`인코딩 ${encoding}`);
const pixelX = number(metadata.pixel_size_x);
const pixelY = number(metadata.pixel_size_y);
if (pixelX !== null && pixelY !== null) {
facts.push(`픽셀 ${Math.abs(pixelX)}×${Math.abs(pixelY)} m`);
const rotated =
(number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0;
facts.push(rotated ? "회전 있음" : "회전 없음");
}
// PRJ 는 좌표계 이름이 곧 구분값이다.
const name = text(metadata.name);
const epsgLabel = readCrsLabel(metadata);
if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name);
else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`);
return facts.slice(0, 2);
}
/**
* 좌표계 코드(EPSG 숫자). 서로 다르면 범위 대조를 하지 않는다.
* 분석기마다 숫자(`5176`)로도, 문자열(`"EPSG:5176"`)로도 온다 — 숫자만 뽑아 맞춘다.
@@ -113,6 +172,8 @@ function projector(extent: PreviewExtent): (x: number, y: number) => [number, nu
export interface PreviewOptions {
metadata?: Record<string, unknown>;
/** 업로드·분석이 끝난 카드인지 — 끝난 카드에만 「값 없음」 안내를 보인다. */
completed?: boolean;
/** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */
terrainExtent?: PreviewExtent | null;
/** 지형 자료 좌표계 — 노선과 다르면 대조를 생략한다(재투영은 하지 않는다). */
@@ -133,9 +194,47 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v
host.removeAttribute("title");
const extent = readExtent(options.metadata);
if (!extent) return;
if (!extent) {
// 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로 보인다 (2026-09-04 사용자 지시).
const facts = readFacts(options.metadata);
if (facts.length === 0) {
if (!options.completed) return;
const empty = document.createElement("div");
empty.className = "b03-file__preview-empty";
empty.textContent = "보여 줄 값이 없음";
host.append(empty);
host.classList.add("is-visible");
return;
}
const box = document.createElement("div");
box.className = "b03-file__preview-facts";
facts.forEach((fact, index) => {
const line = document.createElement("span");
line.className = index === 0 ? "b03-file__preview-fact" : "b03-file__preview-fact-sub";
line.textContent = fact;
box.append(line);
});
host.title = facts.join(" · ");
host.append(box);
host.classList.add("is-visible");
return;
}
// GeoTIFF 썸네일이 있으면 그림 그대로 보인다(오버뷰가 있는 파일만 만들어진다).
const thumbnail = readThumbnail(options.metadata);
if (thumbnail) {
const image = document.createElement("img");
image.className = "b03-file__preview-thumb";
image.src = thumbnail;
image.alt = L("B03_File_Preview_Extent");
host.title = L("B03_File_Preview_Extent");
host.append(image);
host.classList.add("is-visible");
return;
}
const path = readPreviewPath(options.metadata);
const cloud = readPreviewPoints(options.metadata);
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", `0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`);
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
@@ -154,6 +253,22 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v
svg.append(rect);
let label = L("B03_File_Preview_Extent");
// 라이다 탑뷰 점 그림 — 분석기가 훑는 길에 주워 둔 XY 를 그대로 찍는다.
if (cloud) {
label = `점 ${cloud.length.toLocaleString()}개 (솎은 탑뷰)`;
const dots = document.createElementNS(SVG_NS, "path");
dots.setAttribute("class", "b03-file__preview-cloud");
dots.setAttribute(
"d",
cloud
.map(([x, y]) => {
const [sx, sy] = project(x, y);
return `M${sx.toFixed(1)} ${sy.toFixed(1)}h0.7`;
})
.join(""),
);
svg.append(dots);
}
if (path) {
label = L("B03_File_Preview_Route");
for (const part of path) {
+56 -2
View File
@@ -603,14 +603,68 @@
margin-top: 6px;
}
/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가
파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */
.b03-file__preview.is-visible {
display: block;
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: center;
width: 100%;
height: 120px;
overflow: hidden;
border: 1px solid var(--color-border, #dcdce6);
border-radius: var(--radius-cards, 8px);
background: var(--color-surface, #fff);
}
.b03-file__preview svg {
display: block;
width: 100%;
height: 60px;
height: 100%;
}
/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */
.b03-file__preview-cloud {
fill: none;
stroke: var(--color-text-muted, #5a5a72);
stroke-linecap: round;
stroke-opacity: 0.55;
stroke-width: 0.7;
}
/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. */
.b03-file__preview-thumb {
max-width: 100%;
max-height: 100%;
image-rendering: pixelated;
object-fit: contain;
}
/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */
.b03-file__preview-facts {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
text-align: center;
}
.b03-file__preview-fact {
color: var(--color-text, #1c1c28);
font-size: var(--text-body, 15px);
font-weight: 600;
}
.b03-file__preview-fact-sub {
color: var(--color-text-muted, #5a5a72);
font-size: var(--text-caption, 12px);
}
.b03-file__preview-empty {
color: var(--color-text-muted, #8a8aa0);
font-size: var(--text-caption, 12px);
}
.b03-file__preview-extent {
+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;
/* ---------------------------------------------------------------------------